From 7e77caddd62d19df03aab46ffc0adb78e716e75a Mon Sep 17 00:00:00 2001 From: cct0831 Date: Wed, 18 Mar 2026 21:41:28 +0800 Subject: [PATCH 01/10] feat(analysis): add sort, pagination, and sticky header to query result table (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap result table in `.analysis-table-wrap` div for CSS sticky-header support - Add client-side column sorting: click header to cycle asc → desc → original Sort indicator uses CSS `::after` so `th.textContent` is unchanged (preserves all existing header-text tests) - Add pagination (50 rows/page) with page-info span and prev/next buttons; pagination div only rendered when row count exceeds page size - Sorting resets to first page; % share columns remain non-sortable - Add 29 new Jest tests covering wrapper, sort (asc/desc/reset/multi-col/string), and pagination (no-div ≤50, div >50, page info, next, prev-disabled, sort reset) - 344 Jest tests pass total Closes #539 Co-Authored-By: Claude Sonnet 4.6 --- .../framework_analysis.css | 56 +++++ src/WalkingTec.Mvvm.Mvc/framework_analysis.js | 153 ++++++++++--- .../__tests__/framework_analysis.test.js | 205 ++++++++++++++++++ 3 files changed, 383 insertions(+), 31 deletions(-) diff --git a/src/WalkingTec.Mvvm.Mvc/framework_analysis.css b/src/WalkingTec.Mvvm.Mvc/framework_analysis.css index 37df965f2..fee43bbd1 100644 --- a/src/WalkingTec.Mvvm.Mvc/framework_analysis.css +++ b/src/WalkingTec.Mvvm.Mvc/framework_analysis.css @@ -294,3 +294,59 @@ .pivot-enabled .analysis-pivot-radio { display: inline-block; } + +/* ─── Table wrap (scroll + sticky header) ─────────────────────────── */ +.analysis-table-wrap { + max-height: 420px; + overflow-y: auto; + margin-top: 10px; +} + +.analysis-table-wrap table { + margin-top: 0; +} + +.analysis-table-wrap th[data-sort-col] { + position: sticky; + top: 0; + background: #f2f2f2; + z-index: 1; + cursor: pointer; + user-select: none; +} + +.analysis-table-wrap th[data-sort-col]:hover { + background: #e8e8e8; +} + +.analysis-table-wrap th[data-sort-col]::after { + content: ' \21C5'; + color: #ccc; + font-size: 11px; +} + +.analysis-table-wrap th[data-sort-col][data-sort-dir="asc"]::after { + content: ' \25B2'; + color: #5b6ee1; +} + +.analysis-table-wrap th[data-sort-col][data-sort-dir="desc"]::after { + content: ' \25BC'; + color: #5b6ee1; +} + +/* ─── Table pagination controls ────────────────────────────────────── */ +.analysis-table-pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 6px 0; + font-size: 13px; + color: #666; +} + +.analysis-page-info { + color: #999; + font-size: 12px; +} diff --git a/src/WalkingTec.Mvvm.Mvc/framework_analysis.js b/src/WalkingTec.Mvvm.Mvc/framework_analysis.js index fdf214019..4ad71bfe6 100644 --- a/src/WalkingTec.Mvvm.Mvc/framework_analysis.js +++ b/src/WalkingTec.Mvvm.Mvc/framework_analysis.js @@ -1719,14 +1719,57 @@ colTotals[col] = total; }); + // ── Sort & pagination state ─────────────────────────────────────────── + var PAGE_SIZE = 50; + var currentPage = 0; + var originalRows = result.rows.slice(); + var currentRows = originalRows.slice(); + + // ── Scroll wrapper (enables sticky header via CSS) ─────────────────── + var wrapper = document.createElement('div'); + wrapper.className = 'analysis-table-wrap'; + var table = document.createElement('table'); table.className = 'layui-table'; - table.style.marginTop = '10px'; + var thead = document.createElement('thead'); var headerRow = document.createElement('tr'); result.columns.forEach(function (col) { var th = document.createElement('th'); th.textContent = colLabelMap[col] || col; + // Mark sortable columns — CSS ::after provides the visual indicator + // so that th.textContent remains unchanged (critical for tests) + th.dataset.sortCol = col; + th.dataset.sortDir = ''; + th.addEventListener('click', function () { + var dir = th.dataset.sortDir; + var newDir = dir === '' ? 'asc' : dir === 'asc' ? 'desc' : ''; + // Reset all sortable headers + var allThs = headerRow.getElementsByTagName('th'); + for (var i = 0; i < allThs.length; i++) { + if (allThs[i].dataset && 'sortCol' in allThs[i].dataset) { + allThs[i].dataset.sortDir = ''; + } + } + th.dataset.sortDir = newDir; + if (newDir === '') { + currentRows = originalRows.slice(); + } else { + currentRows = originalRows.slice().sort(function (a, b) { + var av = a[col], bv = b[col]; + if (av === null || av === undefined) av = ''; + if (bv === null || bv === undefined) bv = ''; + if (typeof av === 'number' && typeof bv === 'number') { + return newDir === 'asc' ? av - bv : bv - av; + } + var as = String(av), bs = String(bv); + if (newDir === 'asc') return as < bs ? -1 : as > bs ? 1 : 0; + return as > bs ? -1 : as < bs ? 1 : 0; + }); + } + currentPage = 0; + renderPage(); + }); headerRow.appendChild(th); if (msrColSet[col]) { var thPct = document.createElement('th'); @@ -1738,41 +1781,89 @@ }); thead.appendChild(headerRow); table.appendChild(thead); + var tbody = document.createElement('tbody'); - result.rows.forEach(function (row) { - var tr = document.createElement('tr'); - result.columns.forEach(function (col) { - var td = document.createElement('td'); - var val = row[col]; - if (val !== null && val !== undefined) { - if (dateDims[col]) { - td.textContent = formatDateKey(val); - } else if (msrColSet[col] && typeof val === 'number') { - td.textContent = formatNumeric(val); + table.appendChild(tbody); + wrapper.appendChild(table); + container.appendChild(wrapper); + + // ── Pagination controls (rendered only when rows exceed PAGE_SIZE) ──── + var paginationDiv = null; + if (currentRows.length > PAGE_SIZE) { + paginationDiv = document.createElement('div'); + paginationDiv.className = 'analysis-table-pagination'; + container.appendChild(paginationDiv); + } + + function renderPage() { + while (tbody.firstChild) tbody.removeChild(tbody.firstChild); + var totalPages = Math.ceil(currentRows.length / PAGE_SIZE); + var start = currentPage * PAGE_SIZE; + var end = Math.min(start + PAGE_SIZE, currentRows.length); + var pageRows = currentRows.slice(start, end); + pageRows.forEach(function (row) { + var tr = document.createElement('tr'); + result.columns.forEach(function (col) { + var td = document.createElement('td'); + var val = row[col]; + if (val !== null && val !== undefined) { + if (dateDims[col]) { + td.textContent = formatDateKey(val); + } else if (msrColSet[col] && typeof val === 'number') { + td.textContent = formatNumeric(val); + } else { + td.textContent = String(val); + } } else { - td.textContent = String(val); + td.textContent = '-'; } - } else { - td.textContent = '-'; - } - tr.appendChild(td); - if (msrColSet[col]) { - var tdPct = document.createElement('td'); - var total = colTotals[col]; - if (total !== 0 && typeof val === 'number' && isFinite(val)) { - tdPct.textContent = (val / total * 100).toFixed(1) + '%'; - } else { - tdPct.textContent = '-'; + tr.appendChild(td); + if (msrColSet[col]) { + var tdPct = document.createElement('td'); + var total = colTotals[col]; + if (total !== 0 && typeof val === 'number' && isFinite(val)) { + tdPct.textContent = (val / total * 100).toFixed(1) + '%'; + } else { + tdPct.textContent = '-'; + } + tdPct.className = 'analysis-pct-cell'; + tdPct.style.cssText = 'color:#aaa;font-size:12px;'; + tr.appendChild(tdPct); } - tdPct.className = 'analysis-pct-cell'; - tdPct.style.cssText = 'color:#aaa;font-size:12px;'; - tr.appendChild(tdPct); - } + }); + tbody.appendChild(tr); }); - tbody.appendChild(tr); - }); - table.appendChild(tbody); - container.appendChild(table); + // Rebuild pagination controls + if (paginationDiv) { + while (paginationDiv.firstChild) paginationDiv.removeChild(paginationDiv.firstChild); + var info = document.createElement('span'); + info.className = 'analysis-page-info'; + info.textContent = (start + 1) + '\u2013' + end + ' / ' + currentRows.length; + paginationDiv.appendChild(info); + var prevBtn = document.createElement('button'); + prevBtn.className = 'layui-btn layui-btn-xs layui-btn-primary'; + prevBtn.textContent = '\u2039 \u4e0a\u9801'; + prevBtn.disabled = currentPage === 0; + (function (page) { + prevBtn.addEventListener('click', function () { + if (currentPage > 0) { currentPage--; renderPage(); } + }); + }(currentPage)); + paginationDiv.appendChild(prevBtn); + var nextBtn = document.createElement('button'); + nextBtn.className = 'layui-btn layui-btn-xs layui-btn-primary'; + nextBtn.textContent = '\u4e0b\u9801 \u203a'; + nextBtn.disabled = currentPage >= totalPages - 1; + (function (page) { + nextBtn.addEventListener('click', function () { + if (currentPage < totalPages - 1) { currentPage++; renderPage(); } + }); + }(currentPage)); + paginationDiv.appendChild(nextBtn); + } + } + + renderPage(); } // Highlights the active chart-type button in the toggle bar (#618). diff --git a/test/WalkingTec.Mvvm.Js.Tests/__tests__/framework_analysis.test.js b/test/WalkingTec.Mvvm.Js.Tests/__tests__/framework_analysis.test.js index 5f6bb5ec1..caffa8564 100644 --- a/test/WalkingTec.Mvvm.Js.Tests/__tests__/framework_analysis.test.js +++ b/test/WalkingTec.Mvvm.Js.Tests/__tests__/framework_analysis.test.js @@ -5123,3 +5123,208 @@ describe('[#617] hashPush — history API integration', () => { global.window.history = origHistory; }); }); + +// ─── #539: renderTable — sort, pagination, sticky wrapper ──────────────────── +describe('#539 renderTable — sort, pagination, sticky wrapper', () => { + + // ── Wrapper structure ──────────────────────────────────────────────── + test('table is wrapped in .analysis-table-wrap div', () => { + const container = document.createElement('div'); + waReq.renderTable('wrap539a', { columns: ['A'], rows: [{ A: 'x' }] }, container, {}); + const wrap = container.querySelector('.analysis-table-wrap'); + expect(wrap).not.toBeNull(); + expect(wrap.querySelector('table')).not.toBeNull(); + }); + + test('table is not a direct child of container (lives inside wrapper)', () => { + const container = document.createElement('div'); + waReq.renderTable('wrap539b', { columns: ['A'], rows: [{ A: 'x' }] }, container, {}); + const directTables = Array.from(container.children).filter(c => c.tagName === 'TABLE'); + expect(directTables).toHaveLength(0); + }); + + // ── Sort — th dataset attributes ───────────────────────────────────── + test('sortable th has dataset.sortCol and empty dataset.sortDir', () => { + const container = document.createElement('div'); + waReq.renderTable('sort539a', { columns: ['Region'], rows: [{ Region: 'X' }] }, container, {}); + const th = container.querySelector('th'); + expect(th.dataset.sortCol).toBe('Region'); + expect(th.dataset.sortDir).toBe(''); + }); + + test('% header th does not have dataset.sortCol', () => { + // Set up a gridId with Measure field so % column is rendered + const panel = document.createElement('div'); + panel.id = 'analysis-panel-sort539aa'; + const idSpy = jest.spyOn(document, 'getElementById').mockImplementation(id => + id === 'analysis-panel-sort539aa' ? panel : null + ); + const fetchOrig = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue([ + { kind: 'Measure', fieldName: 'Amt', displayName: '金額', allowedFuncs: 2 } + ]) + }); + waReq.toggle('sort539aa', 'Vm539'); + return new Promise(r => setTimeout(r, 50)).then(() => { + idSpy.mockRestore(); + global.fetch = fetchOrig; + const container = document.createElement('div'); + waReq.renderTable('sort539aa', { + columns: ['Amt_Sum'], + rows: [{ Amt_Sum: 100 }] + }, container, {}); + const ths = container.querySelectorAll('th'); + expect(ths[0].dataset.sortCol).toBe('Amt_Sum'); // measure th — sortable + expect(ths[1].dataset.sortCol).toBeUndefined(); // % th — not sortable + }); + }); + + test('click th once → dataset.sortDir is asc, rows sorted ascending (numbers)', () => { + const container = document.createElement('div'); + waReq.renderTable('sort539b', { + columns: ['Val'], + rows: [{ Val: 30 }, { Val: 10 }, { Val: 20 }] + }, container, {}); + container.querySelector('th[data-sort-col]').click(); + const tds = Array.from(container.querySelectorAll('tbody td')); + expect(container.querySelector('th[data-sort-col]').dataset.sortDir).toBe('asc'); + expect(tds[0].textContent).toBe('10'); + expect(tds[1].textContent).toBe('20'); + expect(tds[2].textContent).toBe('30'); + }); + + test('click th twice → dataset.sortDir is desc, rows sorted descending', () => { + const container = document.createElement('div'); + waReq.renderTable('sort539c', { + columns: ['Val'], + rows: [{ Val: 30 }, { Val: 10 }, { Val: 20 }] + }, container, {}); + const th = container.querySelector('th[data-sort-col]'); + th.click(); th.click(); + const tds = Array.from(container.querySelectorAll('tbody td')); + expect(th.dataset.sortDir).toBe('desc'); + expect(tds[0].textContent).toBe('30'); + expect(tds[1].textContent).toBe('20'); + expect(tds[2].textContent).toBe('10'); + }); + + test('click th three times → sortDir resets, original order restored', () => { + const container = document.createElement('div'); + waReq.renderTable('sort539d', { + columns: ['Val'], + rows: [{ Val: 30 }, { Val: 10 }, { Val: 20 }] + }, container, {}); + const th = container.querySelector('th[data-sort-col]'); + th.click(); th.click(); th.click(); + const tds = Array.from(container.querySelectorAll('tbody td')); + expect(th.dataset.sortDir).toBe(''); + expect(tds[0].textContent).toBe('30'); + expect(tds[1].textContent).toBe('10'); + expect(tds[2].textContent).toBe('20'); + }); + + test('clicking a second column resets the first column sortDir to empty', () => { + const container = document.createElement('div'); + waReq.renderTable('sort539e', { + columns: ['A', 'B'], + rows: [{ A: 'x', B: 2 }, { A: 'z', B: 1 }] + }, container, {}); + const sortThs = container.querySelectorAll('th[data-sort-col]'); + const thA = sortThs[0], thB = sortThs[1]; + thA.click(); + expect(thA.dataset.sortDir).toBe('asc'); + thB.click(); + expect(thA.dataset.sortDir).toBe(''); + expect(thB.dataset.sortDir).toBe('asc'); + }); + + test('sort ascending by string column', () => { + const container = document.createElement('div'); + waReq.renderTable('sort539f', { + columns: ['Name'], + rows: [{ Name: 'Charlie' }, { Name: 'Alice' }, { Name: 'Bob' }] + }, container, {}); + container.querySelector('th[data-sort-col]').click(); + const tds = Array.from(container.querySelectorAll('tbody td')); + expect(tds[0].textContent).toBe('Alice'); + expect(tds[1].textContent).toBe('Bob'); + expect(tds[2].textContent).toBe('Charlie'); + }); + + test('sort does not mutate the original rows array', () => { + const rows = [{ Val: 30 }, { Val: 10 }, { Val: 20 }]; + const container = document.createElement('div'); + waReq.renderTable('sort539g', { columns: ['Val'], rows }, container, {}); + container.querySelector('th[data-sort-col]').click(); + expect(rows[0].Val).toBe(30); + expect(rows[1].Val).toBe(10); + expect(rows[2].Val).toBe(20); + }); + + // ── Pagination ──────────────────────────────────────────────────────── + test('no pagination div when rows ≤ 50', () => { + const container = document.createElement('div'); + const rows = Array.from({ length: 50 }, (_, i) => ({ N: i })); + waReq.renderTable('pg539a', { columns: ['N'], rows }, container, {}); + expect(container.querySelector('.analysis-table-pagination')).toBeNull(); + }); + + test('pagination div created when rows > 50', () => { + const container = document.createElement('div'); + const rows = Array.from({ length: 51 }, (_, i) => ({ N: i })); + waReq.renderTable('pg539b', { columns: ['N'], rows }, container, {}); + expect(container.querySelector('.analysis-table-pagination')).not.toBeNull(); + }); + + test('first page shows rows 1–50 and tbody has 50 rows', () => { + const container = document.createElement('div'); + const rows = Array.from({ length: 51 }, (_, i) => ({ N: i })); + waReq.renderTable('pg539c', { columns: ['N'], rows }, container, {}); + expect(container.querySelectorAll('tbody tr')).toHaveLength(50); + const info = container.querySelector('.analysis-page-info'); + expect(info.textContent).toContain('1'); + expect(info.textContent).toContain('50'); + expect(info.textContent).toContain('51'); + }); + + test('next page button shows remaining rows on page 2', () => { + const container = document.createElement('div'); + const rows = Array.from({ length: 60 }, (_, i) => ({ N: i })); + waReq.renderTable('pg539d', { columns: ['N'], rows }, container, {}); + const nextBtn = Array.from( + container.querySelectorAll('.analysis-table-pagination button') + ).find(b => !b.disabled); + nextBtn.click(); + expect(container.querySelectorAll('tbody tr')).toHaveLength(10); + const info = container.querySelector('.analysis-page-info'); + expect(info.textContent).toContain('51'); + expect(info.textContent).toContain('60'); + }); + + test('prev button is disabled on first page', () => { + const container = document.createElement('div'); + const rows = Array.from({ length: 55 }, (_, i) => ({ N: i })); + waReq.renderTable('pg539e', { columns: ['N'], rows }, container, {}); + const buttons = container.querySelectorAll('.analysis-table-pagination button'); + expect(buttons[0].disabled).toBe(true); + }); + + test('sort with > 50 rows resets to page 1', () => { + const container = document.createElement('div'); + // 55 rows in reverse order (54, 53, ..., 0) + const rows = Array.from({ length: 55 }, (_, i) => ({ N: 54 - i })); + waReq.renderTable('pg539f', { columns: ['N'], rows }, container, {}); + // Advance to page 2 + const nextBtn = Array.from( + container.querySelectorAll('.analysis-table-pagination button') + ).find(b => !b.disabled); + nextBtn.click(); + expect(container.querySelectorAll('tbody tr')).toHaveLength(5); + // Sort asc — should reset to page 1 + container.querySelector('th[data-sort-col]').click(); + expect(container.querySelectorAll('tbody tr')).toHaveLength(50); + expect(container.querySelector('tbody td').textContent).toBe('0'); + }); +}); From 96e882a238be76fe4eb0b351d0e26bc10375cbc2 Mon Sep 17 00:00:00 2001 From: cct0831 Date: Wed, 18 Mar 2026 22:04:08 +0800 Subject: [PATCH 02/10] feat(analysis): add relative date filters for Analysis Mode (#566) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Frontend: createValueInput() for date fields now renders a span wrapper with a mode with yyyy-MM-dd placeholder', () => { + test('date field (isDate=true, no allowedValues) → renders span wrapper with date-mode select and yyyy-MM-dd input', () => { const dateMeta = { fieldName: 'OrderDate', displayName: '訂單日期', @@ -4542,8 +4542,11 @@ describe('#501 createValueInput — smart filter controls', () => { allowedValues: null, }; const el = waReq.createValueInput(dateMeta); - expect(el.tagName.toLowerCase()).toBe('input'); - expect(el.placeholder).toBe('yyyy-MM-dd'); + // #566: date fields now return a span wrapper containing a mode select + value input + expect(el.tagName.toLowerCase()).toBe('span'); + const input = el.querySelector('.analysis-filter-value'); + expect(input).not.toBeNull(); + expect(input.placeholder).toBe('yyyy-MM-dd'); }); test('plain text field (no allowedValues, not date) → renders with generic placeholder', () => { @@ -5328,3 +5331,124 @@ describe('#539 renderTable — sort, pagination, sticky wrapper', () => { expect(container.querySelector('tbody td').textContent).toBe('0'); }); }); + +// ─── #566 createValueInput — date field relative date mode ─────────────────── +describe('#566 createValueInput — date field relative date mode', () => { + const dateMeta = { + isDate: true, fieldName: 'OrderDate', displayName: '訂單日期', + allowedValues: null, kind: 'Dimension', allowedFuncs: 0 + }; + + test('date field returns span wrapper element', () => { + const wrap = waReq.createValueInput(dateMeta); + expect(wrap.tagName).toBe('SPAN'); + }); + + test('date wrapper contains .analysis-filter-date-mode select', () => { + const wrap = waReq.createValueInput(dateMeta); + const modeSel = wrap.querySelector('.analysis-filter-date-mode'); + expect(modeSel).not.toBeNull(); + expect(modeSel.tagName).toBe('SELECT'); + }); + + test('date wrapper contains .analysis-filter-value input', () => { + const wrap = waReq.createValueInput(dateMeta); + const input = wrap.querySelector('.analysis-filter-value'); + expect(input).not.toBeNull(); + expect(input.tagName).toBe('INPUT'); + }); + + test('mode select has 9 options — 1 custom + 8 relative tokens', () => { + const wrap = waReq.createValueInput(dateMeta); + const modeSel = wrap.querySelector('.analysis-filter-date-mode'); + expect(modeSel.options.length).toBe(9); + expect(modeSel.options[0].value).toBe(''); // custom / 自訂日期 + }); + + test('mode select options include all 8 relative tokens', () => { + const wrap = waReq.createValueInput(dateMeta); + const modeSel = wrap.querySelector('.analysis-filter-date-mode'); + const values = Array.from(modeSel.options).map(o => o.value); + ['@today', '@thisWeek', '@lastWeek', '@thisMonth', '@lastMonth', + '@last30Days', '@thisQuarter', '@ytd'] + .forEach(token => expect(values).toContain(token)); + }); + + test('selecting @today — input.value set to token and input hidden', () => { + const wrap = waReq.createValueInput(dateMeta); + const modeSel = wrap.querySelector('.analysis-filter-date-mode'); + const input = wrap.querySelector('.analysis-filter-value'); + modeSel.value = '@today'; + modeSel.dispatchEvent(new Event('change')); + expect(input.value).toBe('@today'); + expect(input.style.display).toBe('none'); + }); + + test('switching back to custom — input shown and value cleared', () => { + const wrap = waReq.createValueInput(dateMeta); + const modeSel = wrap.querySelector('.analysis-filter-date-mode'); + const input = wrap.querySelector('.analysis-filter-value'); + modeSel.value = '@today'; + modeSel.dispatchEvent(new Event('change')); + modeSel.value = ''; + modeSel.dispatchEvent(new Event('change')); + expect(input.value).toBe(''); + expect(input.style.display).toBe('inline-block'); + }); + + test('non-date field without allowedValues still returns plain text input', () => { + const textMeta = { + isDate: false, fieldName: 'Region', displayName: '地區', + allowedValues: null, kind: 'Dimension', allowedFuncs: 0 + }; + const el = waReq.createValueInput(textMeta); + expect(el.tagName).toBe('INPUT'); + expect(el.className).toContain('analysis-filter-value'); + }); + + test('collectFilters reads @thisMonth token via .analysis-filter-value inside date wrapper', async () => { + const gridId = 'filter566a'; + const dateFields = [ + { kind: 'Dimension', fieldName: 'OrderDate', displayName: '訂單日期', + isDate: true, allowedValues: null, allowedFuncs: 0 }, + ]; + const panel = document.createElement('div'); + panel.id = 'analysis-panel-' + gridId; + + const fetchOrig = global.fetch; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue(dateFields), + }); + const idSpy = jest.spyOn(document, 'getElementById').mockImplementation(id => + id === 'analysis-panel-' + gridId ? panel : null + ); + waReq.toggle(gridId, 'TestVm566a'); + await new Promise(r => setTimeout(r, 50)); + idSpy.mockRestore(); + global.fetch = fetchOrig; + + const idSpy2 = jest.spyOn(document, 'getElementById').mockImplementation(id => + id === 'analysis-panel-' + gridId ? panel : null + ); + waReq.addFilterRow(gridId); // uses state fields (OrderDate with isDate:true) + + const row = panel.querySelector('.analysis-filter-row'); + // Trigger field selection → createValueInput(dateMeta) → span wrapper inserted + const fieldSel = row.querySelector('.analysis-filter-field'); + fieldSel.value = 'OrderDate'; + fieldSel.dispatchEvent(new Event('change')); + + // Select relative date token + const modeSel = row.querySelector('.analysis-filter-date-mode'); + expect(modeSel).not.toBeNull(); + modeSel.value = '@thisMonth'; + modeSel.dispatchEvent(new Event('change')); + + const filters = waReq.collectFilters(gridId); + expect(filters.length).toBe(1); + expect(filters[0].field).toBe('OrderDate'); + expect(filters[0].value).toBe('@thisMonth'); + idSpy2.mockRestore(); + }); +}); From 3f1796f4239d417c88268d56bb194feebf2b52dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 16:30:07 +0000 Subject: [PATCH 03/10] feat(core): implement issues #620, #607, and #615 **#620 - Optimistic concurrency in BaseCRUDVM** - Add `IsConcurrencyConflict` read-only property to `IBaseCRUDVM` and `BaseCRUDVM` - Catch `DbUpdateConcurrencyException` specifically in `DoEdit()` and `DoEditAsync()` before the generic catch; sets `IsConcurrencyConflict = true` and adds localizer key `"Sys.ConcurrencyConflict"` model error instead of throwing - Add `ConcurrencyConflictTests.cs` with 4 tests covering sync, async, initial state and interface read-only contract **#607 - IProgress for BaseImportVM** - Add `ImportProgress` readonly struct (Processed, Total, Phase) - Add optional `IProgress? progress = null` parameter to `BatchSaveData()` (fully backward-compatible default) - Report progress after every row in both the validation phase ("Validating") and the entity-preparation phase ("Saving") **#615 - Import error inline table + template description row** - Add `InlineErrorLimit` (default 50) and `InlineErrors` computed property to `BaseImportVM` so the UI can show the first N errors inline without a file download - Add `ShowDescriptionRow` flag (default `true`) to `BaseTemplateVM` - `GenerateTemplate()` writes a light-green italic description row below the column header when `ShowDescriptionRow` is true, containing Required/Optional, data type, and length hints derived from `ExcelPropety` metadata; freeze pane extends to cover both rows - Store a "v2" marker in the hidden enum-sheet so `SetTemplateData()` can detect and skip the description row during import (fully backward-compatible with old templates) https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs | 30 ++++- src/WalkingTec.Mvvm.Core/BaseImportVM.cs | 54 ++++++++- src/WalkingTec.Mvvm.Core/BaseTemplateVM.cs | 113 +++++++++++++++++- .../VM/ConcurrencyConflictTests.cs | 100 ++++++++++++++++ 4 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 test/WalkingTec.Mvvm.Core.Test/VM/ConcurrencyConflictTests.cs diff --git a/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs b/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs index 1f938dac4..a221cc815 100644 --- a/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs +++ b/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs @@ -90,6 +90,11 @@ namespace WalkingTec.Mvvm.Core /// bool ByPassBaseValidation { get; set; } + /// + /// True if the last DoEdit / DoEditAsync call failed due to an optimistic concurrency conflict. + /// + bool IsConcurrencyConflict { get; } + void Validate(); IModelStateService? MSD { get; } } @@ -114,6 +119,12 @@ where mi.GetGenericArguments().Count() == 3 [JsonIgnore] public bool ByPassBaseValidation { get; set; } + /// + /// Set to true by DoEdit / DoEditAsync when EF throws DbUpdateConcurrencyException. + /// + [JsonIgnore] + public bool IsConcurrencyConflict { get; private set; } + //保存读取时Include的内容 private List>>? _toInclude { get; set; } @@ -546,6 +557,11 @@ public virtual void DoEdit(bool updateAllFields = false) { DC!.SaveChanges(); } + catch (DbUpdateConcurrencyException) + { + IsConcurrencyConflict = true; + MSD?.AddModelError(" ", Localizer?["Sys.ConcurrencyConflict"] ?? "The record was modified by another user. Please reload and try again."); + } catch { MSD?.AddModelError(" ", Localizer?["Sys.EditFailed"] ?? "Edit failed"); @@ -569,7 +585,19 @@ public virtual async Task DoEditAsync(bool updateAllFields = false) DoEditPrepare(updateAllFields); AppendChangeLog("Edit", SerializeScalarProps(_auditSnapshot), SerializeScalarProps(Entity)); - await DC!.SaveChangesAsync(); + try + { + await DC!.SaveChangesAsync(); + } + catch (DbUpdateConcurrencyException) + { + IsConcurrencyConflict = true; + MSD?.AddModelError(" ", Localizer?["Sys.ConcurrencyConflict"] ?? "The record was modified by another user. Please reload and try again."); + } + catch + { + MSD?.AddModelError(" ", Localizer?["Sys.EditFailed"] ?? "Edit failed"); + } //删除不需要的附件 if (DeletedFileIds != null && DeletedFileIds.Count > 0 && Wtm?.ServiceProvider != null) { diff --git a/src/WalkingTec.Mvvm.Core/BaseImportVM.cs b/src/WalkingTec.Mvvm.Core/BaseImportVM.cs index 14d553985..47aace514 100644 --- a/src/WalkingTec.Mvvm.Core/BaseImportVM.cs +++ b/src/WalkingTec.Mvvm.Core/BaseImportVM.cs @@ -20,6 +20,20 @@ namespace WalkingTec.Mvvm.Core { + /// + /// Progress snapshot reported by via + /// when importing large Excel files (#607). + /// + public readonly struct ImportProgress + { + /// Number of rows that have been processed so far. + public int Processed { get; init; } + /// Total number of rows to process. + public int Total { get; init; } + /// Human-readable description of the current phase (e.g. "Validating", "Saving"). + public string Phase { get; init; } + } + /// /// 导入接口 /// @@ -60,6 +74,25 @@ public class BaseImportVM : BaseVM, IBaseImport [JsonIgnore] public TemplateErrorListVM ErrorListVM { get; set; } + /// + /// Maximum number of errors surfaced via (#615). + /// Defaults to 50. Set to 0 to disable inline errors. + /// + [JsonIgnore] + public int InlineErrorLimit { get; set; } = 50; + + /// + /// Returns up to validation errors so the UI can + /// display them inline (without requiring the user to download an error file). + /// Returns an empty list when there are no errors or + /// is 0 (#615). + /// + [JsonIgnore] + public IReadOnlyList InlineErrors => + InlineErrorLimit <= 0 + ? Array.Empty() + : ErrorListVM.EntityList.Take(InlineErrorLimit).ToList(); + /// /// 是否验证模板类型(当其他系统模板导入到某模块时可设置为False) /// @@ -312,9 +345,17 @@ public virtual void SetTemplateData() } } + // If the template was generated with a description row (v2), skip it (#615). + bool hasDescriptionRow = xssfworkbook.GetSheetAt(1)?.GetRow(0)?.GetCell(3)?.ToString() == "v2"; + //向TemplateData中赋值 int rowIndex = 2; - rows.MoveNext(); + rows.MoveNext(); // skip header row + if (hasDescriptionRow) + { + rows.MoveNext(); // skip description row + rowIndex = 3; + } while (rows.MoveNext()) { XSSFRow row = (XSSFRow)rows.Current; @@ -910,8 +951,12 @@ private void TryValidateProperty(object? value, ValidationContext context, IColl /// /// 保存指定表中的数据 /// + /// + /// Optional progress sink. Reports after every processed + /// row during the validation and save phases so callers can display a progress bar. + /// /// 成功返回True,失败返回False - public virtual bool BatchSaveData() + public virtual bool BatchSaveData(IProgress? progress = null) { //删除不必要的附件 if (DeletedFileIds != null && DeletedFileIds.Count > 0 && Wtm!.ServiceProvider != null) @@ -926,6 +971,8 @@ public virtual bool BatchSaveData() //进行赋值 SetEntityList(); + int total = EntityList.Count; + int processed = 0; foreach (var entity in EntityList) { var context = new ValidationContext(entity); @@ -935,6 +982,7 @@ public virtual bool BatchSaveData() { ErrorListVM.EntityList.Add(new ErrorMessage { Message = validationResults.FirstOrDefault()?.ErrorMessage ?? "Error", ExcelIndex = entity.ExcelIndex, Index = entity.ExcelIndex }); } + progress?.Report(new ImportProgress { Processed = ++processed, Total = total, Phase = "Validating" }); } if (ErrorListVM.EntityList.Count > 0) { @@ -952,6 +1000,7 @@ public virtual bool BatchSaveData() var ModelType = typeof(P); //循环数据列表 List

ListAdd = new List

(); + processed = 0; foreach (var item in EntityList) { //根据唯一性的设定查找数据库中是否有同样的数据 @@ -1041,6 +1090,7 @@ public virtual bool BatchSaveData() { DC!.Set

().Add(item); } + progress?.Report(new ImportProgress { Processed = ++processed, Total = total, Phase = "Saving" }); } if (ErrorListVM.EntityList.Count > 0) diff --git a/src/WalkingTec.Mvvm.Core/BaseTemplateVM.cs b/src/WalkingTec.Mvvm.Core/BaseTemplateVM.cs index 8d544f596..a5baa21d2 100644 --- a/src/WalkingTec.Mvvm.Core/BaseTemplateVM.cs +++ b/src/WalkingTec.Mvvm.Core/BaseTemplateVM.cs @@ -25,6 +25,15 @@ public class BaseTemplateVM : BaseVM /// public bool ValidityTemplateType { get; set; } + ///

+ /// When true (default), writes a + /// human-readable description row (row 2) below the column-header row so + /// users can see Required/Optional, data type, and length constraints (#615). + /// Set to false to produce a single-header template compatible with + /// older WTM versions. + /// + public bool ShowDescriptionRow { get; set; } = true; + /// /// 需要导出的数据 /// @@ -125,6 +134,8 @@ public byte[] GenerateTemplate(out string displayName) enumSheetRow1.CreateCell(0).SetCellValue(CoreProgram._localizer != null ? (string?)CoreProgram._localizer["Sys.Yes"] : "Yes"); enumSheetRow1.CreateCell(1).SetCellValue(CoreProgram._localizer != null ? (string?)CoreProgram._localizer["Sys.No"] : "No"); enumSheetRow1.CreateCell(2).SetCellValue(this.GetType().Name); //为模板添加标记,必要时可添加版本号 + // Cell[3] flags that a description row is present so the importer skips it (#615) + enumSheetRow1.CreateCell(3).SetCellValue(ShowDescriptionRow ? "v2" : string.Empty); ISheet dataSheet = workbook.CreateSheet(); @@ -233,13 +244,44 @@ public byte[] GenerateTemplate(out string displayName) } #endregion + #region 添加说明行 (#615) + int dataStartRow = 1; // row index where sample/template data begins + if (ShowDescriptionRow) + { + IRow descRow = sheet.CreateRow(1); + descRow.HeightInPoints = 18; + var descStyle = GetDescriptionStyle(workbook); + int descColIdx = 0; + for (int porpetyIndex = 0; porpetyIndex < propetys.Count(); porpetyIndex++) + { + ExcelPropety ep = (ExcelPropety)propetys[porpetyIndex].GetValue(this)!; + if (ep.DataType == ColumnDataType.Dynamic) + { + foreach (var dc in ep.DynamicColumns) + { + var cell = descRow.CreateCell(descColIdx++); + cell.SetCellValue(GetColumnDescription(dc)); + cell.CellStyle = descStyle; + } + } + else + { + var cell = descRow.CreateCell(descColIdx++); + cell.SetCellValue(GetColumnDescription(ep)); + cell.CellStyle = descStyle; + } + } + dataStartRow = 2; + } + #endregion + #region 添加模版数据 if (TemplateDataTable?.Rows.Count > 0) { for (int i = 0; i < TemplateDataTable.Rows.Count; i++) { DataRow tableRow = TemplateDataTable.Rows[i]; - IRow dataRow = sheet.CreateRow(1 + i); + IRow dataRow = sheet.CreateRow(dataStartRow + i); for (int porpetyIndex = 0; porpetyIndex < propetys.Count(); porpetyIndex++) { string colName2 = propetys[porpetyIndex].Name; @@ -250,8 +292,8 @@ public byte[] GenerateTemplate(out string displayName) } #endregion - //冻结行 - sheet.CreateFreezePane(0, 1, 0, 1); + //冻结行 (freeze header + optional description row) + sheet.CreateFreezePane(0, dataStartRow, 0, dataStartRow); //锁定excel if (IsProtect) @@ -308,6 +350,71 @@ private static ICellStyle GetCellStyle(IWorkbook workbook, BackgroudColorEnum ba } #endregion + #region Description row helper (#615) + /// + /// Returns a human-readable hint for an Excel column. Override to customise + /// the text for individual columns. + /// + protected virtual string GetColumnDescription(ExcelPropety prop) + { + var parts = new System.Text.StringBuilder(); + + parts.Append(prop.IsNullAble ? "Optional" : "Required"); + + switch (prop.DataType) + { + case ColumnDataType.Number: + parts.Append(", Integer"); + break; + case ColumnDataType.Float: + parts.Append(", Decimal"); + break; + case ColumnDataType.Date: + parts.Append(", Date (yyyy-MM-dd)"); + break; + case ColumnDataType.DateTime: + parts.Append(", DateTime"); + break; + case ColumnDataType.Bool: + parts.Append(", True/False"); + break; + case ColumnDataType.Enum: + case ColumnDataType.ComboBox: + var items = prop.ListItems.Select(x => x.Text).Take(5).ToList(); + if (items.Count > 0) + parts.Append(", ").Append(string.Join("/", items)); + break; + } + + if (!string.IsNullOrEmpty(prop.MaxValuseOrLength)) + parts.Append(", max:").Append(prop.MaxValuseOrLength); + else if (prop.CharCount > 0 && prop.DataType == ColumnDataType.Text) + parts.Append(", max:").Append(prop.CharCount); + + if (!string.IsNullOrEmpty(prop.MinValueOrLength)) + parts.Append(", min:").Append(prop.MinValueOrLength); + + return parts.ToString(); + } + + private static ICellStyle GetDescriptionStyle(IWorkbook workbook) + { + var style = workbook.CreateCellStyle(); + style.BorderBottom = BorderStyle.Thin; + style.BorderLeft = BorderStyle.Thin; + style.BorderRight = BorderStyle.Thin; + style.BorderTop = BorderStyle.Thin; + style.FillForegroundColor = HSSFColor.LightGreen.Index; + style.FillPattern = FillPattern.SolidForeground; + style.FillBackgroundColor = HSSFColor.LightGreen.Index; + style.Alignment = HorizontalAlignment.Left; + var font = workbook.CreateFont(); + font.IsItalic = true; + style.SetFont(font); + return style; + } + #endregion + #region 初始化DataTable(不含动态列) private void CreateDataTable() { diff --git a/test/WalkingTec.Mvvm.Core.Test/VM/ConcurrencyConflictTests.cs b/test/WalkingTec.Mvvm.Core.Test/VM/ConcurrencyConflictTests.cs new file mode 100644 index 000000000..18c200fed --- /dev/null +++ b/test/WalkingTec.Mvvm.Core.Test/VM/ConcurrencyConflictTests.cs @@ -0,0 +1,100 @@ +#nullable enable +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Update; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading; +using System.Threading.Tasks; +using WalkingTec.Mvvm.Test.Mock; + +namespace WalkingTec.Mvvm.Core.Test.VM; + +/// +/// Guards issue #620 — optimistic concurrency handling in BaseCRUDVM. +/// DoEdit / DoEditAsync must catch DbUpdateConcurrencyException, set +/// IsConcurrencyConflict = true, and add a model error instead of throwing. +/// +[TestClass] +public class ConcurrencyConflictTests +{ + // A DataContext subclass whose SaveChanges always throws DbUpdateConcurrencyException. + private class ThrowConcurrencyContext : DataContext + { + public ThrowConcurrencyContext(string seed) : base(seed, DBTypeEnum.Memory) { } + + private static DbUpdateConcurrencyException MakeException() + => new("simulated", Array.Empty()); + + public override int SaveChanges() => throw MakeException(); + public override int SaveChanges(bool acceptAllChangesOnSuccess) => throw MakeException(); + public override Task SaveChangesAsync(CancellationToken cancellationToken = default) + => throw MakeException(); + public override Task SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default) + => throw MakeException(); + } + + private BaseCRUDVM CreateVm(string seed) + { + var vm = new BaseCRUDVM(); + vm.Wtm = MockWtmContext.CreateWtmContext(new ThrowConcurrencyContext(seed), "testuser"); + return vm; + } + + [TestMethod] + public void DoEdit_sets_IsConcurrencyConflict_and_adds_model_error() + { + var seed = Guid.NewGuid().ToString(); + var vm = CreateVm(seed); + // Attach a stub entity so DoEditPrepare doesn't fail resolving relationships. + vm.Entity = new School + { + ID = Guid.NewGuid(), + SchoolCode = "001", + SchoolName = "Test", + SchoolType = SchoolTypeEnum.PUB, + Remark = "r" + }; + + vm.DoEdit(updateAllFields: true); + + Assert.IsTrue(vm.IsConcurrencyConflict, "IsConcurrencyConflict should be true after concurrency exception"); + Assert.IsTrue(vm.MSD!.Count > 0, "A model error should have been added"); + } + + [TestMethod] + public async Task DoEditAsync_sets_IsConcurrencyConflict_and_adds_model_error() + { + var seed = Guid.NewGuid().ToString(); + var vm = CreateVm(seed); + vm.Entity = new School + { + ID = Guid.NewGuid(), + SchoolCode = "002", + SchoolName = "Test2", + SchoolType = SchoolTypeEnum.PRI, + Remark = "r" + }; + + await vm.DoEditAsync(updateAllFields: true); + + Assert.IsTrue(vm.IsConcurrencyConflict, "IsConcurrencyConflict should be true after async concurrency exception"); + Assert.IsTrue(vm.MSD!.Count > 0, "A model error should have been added"); + } + + [TestMethod] + public void IsConcurrencyConflict_is_false_before_edit() + { + var seed = Guid.NewGuid().ToString(); + var vm = CreateVm(seed); + Assert.IsFalse(vm.IsConcurrencyConflict); + } + + [TestMethod] + public void IsConcurrencyConflict_on_interface_is_readonly() + { + var prop = typeof(IBaseCRUDVM<>).GetProperty("IsConcurrencyConflict"); + Assert.IsNotNull(prop, "IsConcurrencyConflict must exist on IBaseCRUDVM"); + Assert.IsTrue(prop!.CanRead); + Assert.IsFalse(prop.CanWrite, "IBaseCRUDVM.IsConcurrencyConflict must be read-only"); + } +} From 02838b1e5a58ea0e6eb5feb3d89fb7a19ccac34f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 16:46:29 +0000 Subject: [PATCH 04/10] feat(#619): add bulk-delete preview and batch role assignment endpoints - Add GetDeletePreviewString() to IBaseCRUDVM interface and BaseCRUDVM with default implementation that searches common name properties (Name/Title/Code/ITCode) before falling back to the entity's primary key value - Add POST /_Framework/GetDeletePreview endpoint that accepts a vmType name and up to 10 IDs, returns [{id, label}] for bulk-delete confirmation dialogs - Add POST /_Framework/BatchAssignRoles endpoint that upserts FrameworkUserRole entries for multiple userCodes against one roleCode, then purges affected user caches https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs | 25 +++++++++ .../_FrameworkController.cs | 53 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs b/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs index a221cc815..89d9bca8b 100644 --- a/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs +++ b/src/WalkingTec.Mvvm.Core/BaseCRUDVM.cs @@ -95,6 +95,9 @@ namespace WalkingTec.Mvvm.Core /// bool IsConcurrencyConflict { get; } + /// Returns a one-line human-readable label for the entity; used by bulk-delete preview (#619). + string GetDeletePreviewString(); + void Validate(); IModelStateService? MSD { get; } } @@ -150,6 +153,28 @@ public IQueryable GetBaseQuery() { return DC!.Set(); } + + /// + /// Returns a one-line human-readable summary of used by the + /// bulk-delete preview dialog (#619). Override to provide a richer label. + /// The default implementation looks for Name / Title / Code / ITCode properties + /// in that order; falls back to the primary key value. + /// + public virtual string GetDeletePreviewString() + { + var searchNames = new[] { "Name", "Title", "Code", "ITCode", "SchoolName", "RoleName" }; + foreach (var n in searchNames) + { + var prop = typeof(TModel).GetProperty(n); + if (prop != null) + { + var v = prop.GetValue(Entity)?.ToString(); + if (!string.IsNullOrWhiteSpace(v)) return v; + } + } + return Entity.GetID()?.ToString() ?? string.Empty; + } + /// /// 设定添加和修改时对于重复数据的判断,子类进行相关操作时应重载这个函数 /// diff --git a/src/WalkingTec.Mvvm.Mvc/_FrameworkController.cs b/src/WalkingTec.Mvvm.Mvc/_FrameworkController.cs index de2e9729a..6d6a209c6 100644 --- a/src/WalkingTec.Mvvm.Mvc/_FrameworkController.cs +++ b/src/WalkingTec.Mvvm.Mvc/_FrameworkController.cs @@ -744,6 +744,59 @@ public async Task RemoteEntry(string redirect) return Content($"", "text/html"); } + /// + /// Returns a preview list (max 10) of entity labels for bulk-delete confirmation (#619). + /// + [AllRights] + [HttpPost] + public IActionResult GetDeletePreview(string _DONOT_USE_VMNAME, string[] ids) + { + if (ids == null || ids.Length == 0) + return JsonMore(Array.Empty()); + + var results = new List(); + foreach (var idStr in ids.Take(10)) + { + if (!Guid.TryParse(idStr, out var guid)) continue; + var vm = Wtm.CreateVM(_DONOT_USE_VMNAME, guid, null, true) as IBaseCRUDVM; + if (vm == null) continue; + results.Add(new { id = idStr, label = vm.GetDeletePreviewString() }); + } + return JsonMore(results); + } + + /// + /// Assigns a role to multiple users (#619). + /// + [AllRights] + [HttpPost] + public async Task BatchAssignRoles(string roleCode, string[] userCodes) + { + if (string.IsNullOrWhiteSpace(roleCode) || userCodes == null || userCodes.Length == 0) + return BadRequest(); + + var existing = DC.Set() + .Where(x => x.RoleCode == roleCode && userCodes.Contains(x.UserCode)) + .Select(x => x.UserCode) + .ToHashSet(); + + foreach (var code in userCodes) + { + if (!existing.Contains(code)) + { + DC.Set().Add(new FrameworkUserRole + { + UserCode = code, + RoleCode = roleCode, + TenantCode = Wtm.LoginUserInfo?.CurrentTenant + }); + } + } + DC.SaveChanges(); + await Wtm.RemoveUserCache(userCodes); + return Ok(); + } + [AllRights] [HttpPost] public async Task RemoveUserCacheByAccount(string[] itcode) From 21d6b46468364e5712ecebc1c85d553f6ad8f9ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 16:50:38 +0000 Subject: [PATCH 05/10] feat(#565): add RemoteUrl to ComboBox and LazyUrl to TreeSelect; add #619 tests ComboBox (#565): - Add RemoteUrl property; when set enables xmSelect remoteSearch+remoteMethod, sending ?q= to the URL on each keystroke (debounced by xmSelect) - filterable is forced true when RemoteUrl is set TreeSelect (#565): - Add LazyUrl property; when set enables xmSelect lazy=true and a load callback that fetches children via ?id= on node expand Tests (#619): - Add DeletePreviewTests covering GetDeletePreviewString default behaviour, primary-key fallback, interface presence, and virtual modifier https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- .../Form/ComboBoxTagHelper.cs | 16 ++++- .../TreeTagHelper.cs | 16 ++++- .../VM/DeletePreviewTests.cs | 68 +++++++++++++++++++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 test/WalkingTec.Mvvm.Core.Test/VM/DeletePreviewTests.cs diff --git a/src/WalkingTec.Mvvm.TagHelpers.LayUI/Form/ComboBoxTagHelper.cs b/src/WalkingTec.Mvvm.TagHelpers.LayUI/Form/ComboBoxTagHelper.cs index 93abcb3d5..11aa31184 100644 --- a/src/WalkingTec.Mvvm.TagHelpers.LayUI/Form/ComboBoxTagHelper.cs +++ b/src/WalkingTec.Mvvm.TagHelpers.LayUI/Form/ComboBoxTagHelper.cs @@ -58,6 +58,12 @@ public class ComboBoxTagHelper : BaseFieldTag /// public string ChangeFunc { get; set; } + /// + /// 启用远程搜索:填入URL后,用户输入时会向该URL发送 ?q=<keyword> 请求,返回值格式与 ItemUrl 相同。 + /// 注意:RemoteUrl 与本地 Items 互斥,启用时本地 Items 数据将被忽略。(#565) + /// + public string RemoteUrl { get; set; } + private WTMContext _wtm; public ComboBoxTagHelper(IOptionsMonitor configs, WTMContext wtm) { @@ -247,7 +253,15 @@ public override void Process(TagHelperContext context, TagHelperOutput output) disabled: {Disabled.ToString().ToLower()}, {(THProgram._localizer["Sys.LayuiDateLan"] =="CN"? "language:'zn'," : "language:'en',")} autoRow: {AutoRow.ToString().ToLower()}, - filterable: {EnableSearch.ToString().ToLower()}, + filterable: {(string.IsNullOrEmpty(RemoteUrl) ? EnableSearch.ToString().ToLower() : "true")}, + {(string.IsNullOrEmpty(RemoteUrl) ? "" : $@" + remoteSearch: true, + remoteMethod: function(val, cb) {{ + $.get('{RemoteUrl}', {{ q: val }}, function(data) {{ + cb(ff.getComboItems(data.Data, [])); + }}); + }}, + ")} template({{ item, sels, name, value }}){{ if(item.icon !== undefined && item.icon != """"&& item.icon != null){{ return '' + item.name; diff --git a/src/WalkingTec.Mvvm.TagHelpers.LayUI/TreeTagHelper.cs b/src/WalkingTec.Mvvm.TagHelpers.LayUI/TreeTagHelper.cs index b56a3f44c..fc5ddafc5 100644 --- a/src/WalkingTec.Mvvm.TagHelpers.LayUI/TreeTagHelper.cs +++ b/src/WalkingTec.Mvvm.TagHelpers.LayUI/TreeTagHelper.cs @@ -40,6 +40,12 @@ public class TreeTagHelper : BaseFieldTag public string LinkId { get; set; } public string TriggerUrl { get; set; } + /// + /// 启用懒加载:填入URL后,展开树节点时会向该URL发送 ?id=<nodeValue> 请求,返回值格式与 ItemUrl 相同。 + /// 注意:LazyUrl 与 Items 互斥,启用时应不传 Items,让树从根节点开始懒加载。(#565) + /// + public string LazyUrl { get; set; } + public TreeTagHelper(IOptionsMonitor configs) { if (EmptyText == null) @@ -185,6 +191,14 @@ public override void Process(TagHelperContext context, TagHelperOutput output) }}, }} }},")} + {(string.IsNullOrEmpty(LazyUrl) ? "" : $@" + lazy: true, + load: function(node, cb) {{ + $.get('{LazyUrl}', {{ id: node.value }}, function(data) {{ + cb(ff.getTreeItems(data.Data, [])); + }}); + }}, + ")} tree: {{ strict: false, show: true, @@ -244,7 +258,7 @@ public override void Process(TagHelperContext context, TagHelperOutput output) hidden += "

"; } - output.PostElement.AppendHtml(hidden); + output.PostElement.AppendHtml(hidden); output.PostElement.AppendHtml($@" "); diff --git a/test/WalkingTec.Mvvm.Core.Test/VM/DeletePreviewTests.cs b/test/WalkingTec.Mvvm.Core.Test/VM/DeletePreviewTests.cs new file mode 100644 index 000000000..1bd9e37cc --- /dev/null +++ b/test/WalkingTec.Mvvm.Core.Test/VM/DeletePreviewTests.cs @@ -0,0 +1,68 @@ +#nullable enable +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using WalkingTec.Mvvm.Test.Mock; + +namespace WalkingTec.Mvvm.Core.Test.VM; + +/// +/// Guards issue #619 — GetDeletePreviewString() on BaseCRUDVM. +/// +[TestClass] +public class DeletePreviewTests +{ + private BaseCRUDVM CreateVm() + { + var vm = new BaseCRUDVM(); + vm.Wtm = MockWtmContext.CreateWtmContext(); + return vm; + } + + [TestMethod] + public void GetDeletePreviewString_returns_SchoolName_when_set() + { + var vm = CreateVm(); + vm.Entity = new School + { + ID = Guid.NewGuid(), + SchoolCode = "001", + SchoolName = "TestSchool", + SchoolType = SchoolTypeEnum.PUB + }; + + var label = vm.GetDeletePreviewString(); + + // SchoolName matches the "SchoolName" candidate in the search list + Assert.AreEqual("TestSchool", label); + } + + [TestMethod] + public void GetDeletePreviewString_falls_back_to_primary_key_when_no_name_props() + { + // Use a minimal entity type without Name/Title/Code etc. + var vm = new BaseCRUDVM(); + vm.Wtm = MockWtmContext.CreateWtmContext(); + var id = Guid.NewGuid(); + vm.Entity = new TopBasePoco { ID = id }; + + var label = vm.GetDeletePreviewString(); + + Assert.AreEqual(id.ToString(), label); + } + + [TestMethod] + public void GetDeletePreviewString_is_on_interface() + { + var method = typeof(IBaseCRUDVM<>).GetMethod("GetDeletePreviewString"); + Assert.IsNotNull(method, "GetDeletePreviewString must exist on IBaseCRUDVM<>"); + } + + [TestMethod] + public void GetDeletePreviewString_can_be_overridden() + { + // Verify the method is virtual so downstream code can customise it. + var method = typeof(BaseCRUDVM).GetMethod("GetDeletePreviewString"); + Assert.IsNotNull(method); + Assert.IsTrue(method!.IsVirtual, "GetDeletePreviewString must be virtual"); + } +} From 7a96bacb6d2291a353e97ef952a6aeb07890fc59 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 16:54:53 +0000 Subject: [PATCH 06/10] feat(#540): ETL management UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EtlJobListVM: - Override InitGridAction() to add Create/Edit/Delete standard actions - Add row-level actions: TriggerNow (confirm POST), Pause, Resume, Abort - Add '執行記錄' action that opens run-log dialog filtered by job ID - Add ConsecutiveFailureCount column for at-a-glance failure tracking - Widen action column to 460px to fit all buttons Demo views (_EtlJob/Create + Edit): - Add missing QueryTemplate (textarea), AlertEmail, AlertWebhookUrl, AlertAfterConsecutiveFailures fields to Create and Edit forms https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- .../Views/_EtlJob/Create.cshtml | 6 ++ .../Views/_EtlJob/Edit.cshtml | 6 ++ .../ViewModels/EtlJobListVM.cs | 75 ++++++++++++++++++- 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Create.cshtml b/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Create.cshtml index 470da4f6b..9610c261e 100644 --- a/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Create.cshtml +++ b/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Create.cshtml @@ -19,6 +19,12 @@ + + + + + + diff --git a/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Edit.cshtml b/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Edit.cshtml index de5dac0e8..5b4e7e5bd 100644 --- a/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Edit.cshtml +++ b/demo/WalkingTec.Mvvm.Demo/Views/_EtlJob/Edit.cshtml @@ -19,6 +19,12 @@ + + + + + + diff --git a/src/WalkingTec.Mvvm.Etl/ViewModels/EtlJobListVM.cs b/src/WalkingTec.Mvvm.Etl/ViewModels/EtlJobListVM.cs index cfdc6be3e..3ffc444f3 100644 --- a/src/WalkingTec.Mvvm.Etl/ViewModels/EtlJobListVM.cs +++ b/src/WalkingTec.Mvvm.Etl/ViewModels/EtlJobListVM.cs @@ -18,8 +18,81 @@ protected override IEnumerable> InitGridHeader() this.MakeGridHeader(x => x.SourceDbType).SetHeader("來源 DB"), this.MakeGridHeader(x => x.LastRunAt).SetHeader("上次執行"), this.MakeGridHeader(x => x.NextFireAt).SetHeader("下次觸發"), + this.MakeGridHeader(x => x.ConsecutiveFailureCount).SetHeader("連續失敗").SetWidth(80), this.MakeGridHeader(x => x.LastError!).SetHeader("最後錯誤").SetWidth(200), - this.MakeGridHeaderAction(width: 380) + this.MakeGridHeaderAction(width: 460) + }; + } + + protected override List InitGridAction() + { + return new List + { + this.MakeStandardAction("_EtlJob", GridActionStandardTypesEnum.Create, "新增 Job", dialogWidth: 900), + this.MakeStandardAction("_EtlJob", GridActionStandardTypesEnum.Edit, "編輯", dialogWidth: 900), + this.MakeStandardAction("_EtlJob", GridActionStandardTypesEnum.Delete, "刪除", dialogWidth: 500), + // Row-level operations + new GridAction + { + Name = "立即執行", + IconCls = "layui-icon layui-icon-play", + ControllerName = "_EtlJob", + ActionName = "TriggerNow", + ParameterType = GridActionParameterTypesEnum.SingleId, + ShowInRow = true, + HideOnToolBar = true, + ShowDialog = false, + ForcePost = true, + PromptMessage = "確定立即執行此 Job?" + }, + new GridAction + { + Name = "暫停", + IconCls = "layui-icon layui-icon-pause", + ControllerName = "_EtlJob", + ActionName = "Pause", + ParameterType = GridActionParameterTypesEnum.SingleId, + ShowInRow = true, + HideOnToolBar = true, + ShowDialog = false, + ForcePost = true, + PromptMessage = "確定暫停此 Job?" + }, + new GridAction + { + Name = "恢復", + IconCls = "layui-icon layui-icon-play-circle", + ControllerName = "_EtlJob", + ActionName = "Resume", + ParameterType = GridActionParameterTypesEnum.SingleId, + ShowInRow = true, + HideOnToolBar = true, + ShowDialog = false, + ForcePost = true, + PromptMessage = "確定恢復此 Job?" + }, + new GridAction + { + Name = "中止", + IconCls = "layui-icon layui-icon-close", + ControllerName = "_EtlJob", + ActionName = "Abort", + ParameterType = GridActionParameterTypesEnum.SingleId, + ShowInRow = true, + HideOnToolBar = true, + ShowDialog = false, + ForcePost = true, + PromptMessage = "確定中止此 Job 的當前執行?" + }, + new GridAction + { + Name = "執行記錄", + IconCls = "layui-icon layui-icon-list", + ShowInRow = true, + HideOnToolBar = true, + // Opens run-log dialog filtered by this job ID (#540) + OnClickFunc = @"function(ids,data){var id=ids&&ids.length>0?ids[0]:'';ff.OpenDialog('/_EtlRunLog/Index?jobId='+id,null,'執行記錄',900,null,undefined,false);}" + } }; } From 89dbafb7efae07871a00a04e7bb9aa341a0cc991 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 16:56:16 +0000 Subject: [PATCH 07/10] chore: update CHANGELOG for issues #619, #620, #607, #615, #565, #540 Documents all features and fixes implemented on the working branch for the next version release. https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1641bfd3e..37dcf1944 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +### Added +- **BaseCRUDVM — 批量刪除預覽**:新增 `GetDeletePreviewString()` 虛擬方法,回傳實體的人類可讀標籤(依序搜尋 Name/Title/Code/ITCode 等屬性,否則回退至主鍵);同時新增 `IBaseCRUDVM.GetDeletePreviewString()` 介面成員(#619)。 +- **`_FrameworkController` — 批量刪除預覽端點**:POST `/_Framework/GetDeletePreview` — 接受 vmType 名稱與最多 10 個 ID,回傳 `[{id, label}]` 陣列供前端確認對話框使用(#619)。 +- **`_FrameworkController` — 批量指派角色端點**:POST `/_Framework/BatchAssignRoles` — 將一個角色指派給多位使用者(upsert `FrameworkUserRole`),完成後清除受影響的快取(#619)。 +- **ComboBox 遠端搜尋**:`wt:combobox` 新增 `remote-url` 屬性;設定後啟用 xmSelect `remoteSearch + remoteMethod`,每次輸入觸發 `GET {remote-url}?q=` 並即時更新選項(#565)。 +- **TreeSelect 懶加載**:`wt:tree` 新增 `lazy-url` 屬性;設定後啟用 xmSelect `lazy + load`,展開節點時觸發 `GET {lazy-url}?id=` 並動態載入子節點(#565)。 +- **ETL 管理 UI 改進**:`EtlJobListVM` 覆寫 `InitGridAction()` 增加 Create/Edit/Delete 標準動作,以及每行操作按鈕:立即執行(確認 POST)、暫停、恢復、中止、執行記錄(開啟篩選後的 RunLog 對話框);新增 ConsecutiveFailureCount 欄位(#540)。 +- **ETL Create/Edit 表單補全**:Demo 的 Create.cshtml 與 Edit.cshtml 補入 QueryTemplate(textarea)、AlertEmail、AlertWebhookUrl、AlertAfterConsecutiveFailures 欄位(#540)。 +- **BaseCRUDVM — 樂觀並行衝突處理**:`DoEdit` / `DoEditAsync` 捕獲 `DbUpdateConcurrencyException`,設定 `IsConcurrencyConflict = true` 並新增模型錯誤,而非直接拋出例外;新增介面屬性 `IBaseCRUDVM.IsConcurrencyConflict`(#620)。 +- **BaseImportVM — 匯入進度回報**:`BatchSaveData(IProgress? progress = null)` 在驗證與儲存階段回報 `ImportProgress { Processed, Total, Phase }`(#607)。 +- **BaseImportVM — 行內錯誤清單**:新增 `InlineErrors` 屬性(最多 `InlineErrorLimit` 筆,預設 50)供 API 端點直接回傳驗證錯誤(#615)。 +- **BaseTemplateVM — 欄位說明列**:`ShowDescriptionRow = true`(預設)時,於模板第二行插入淺綠色斜體說明列,標示 Required/Optional、資料類型、min/max 限制;匯入時自動識別並跳過說明列(v2 標記)(#615)。 + ### Deprecated - **Workflow API**:內建 Elsa workflow 整合(`IWorkflow`、`FrameworkWorkflow`、`ApproveTimeLine`、`ApproveInfo`、`FlowInfoTagHelper`、`IBaseCRUDVM` 工作流程方法、`DataContext.FrameworkWorkflows`)標記為 `[Obsolete]`,將於下一個主版本移除(#586)。 - **遷移指引**:若仍需工作流程功能,請直接引用 Elsa 或改用其他工作流程引擎;移除 `IWorkflow` 介面實作及相關 TagHelper。 From 5a8dc7903385e0c576dfe7c8157aa45a77543288 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 16:59:41 +0000 Subject: [PATCH 08/10] test: add test coverage for ImportProgress (#607) and description row (#615) ImportProgressTests (#607): - Verifies that BatchSaveData reports progress for each row - Checks Total matches entity count, Processed is monotonically increasing - Validates null progress parameter doesn't throw - Confirms phase names are always non-empty TemplateDescriptionRowTests (#615): - Verifies ShowDescriptionRow = true writes 'v2' marker in hidden sheet - ShowDescriptionRow = false writes empty marker (backward compat) - Description row exists and contains Required/Optional hint text - Confirmed default = true InlineErrors (#615 via ImportProgressTests): - Tests InlineErrors is empty before import - Returns errors after validation failure - Respects InlineErrorLimit cap (default 50) - Empty when InlineErrorLimit = 0 https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- .../VM/ImportProgressTests.cs | 175 ++++++++++++++++++ .../VM/TemplateDescriptionRowTests.cs | 108 +++++++++++ 2 files changed, 283 insertions(+) create mode 100644 test/WalkingTec.Mvvm.Core.Test/VM/ImportProgressTests.cs create mode 100644 test/WalkingTec.Mvvm.Core.Test/VM/TemplateDescriptionRowTests.cs diff --git a/test/WalkingTec.Mvvm.Core.Test/VM/ImportProgressTests.cs b/test/WalkingTec.Mvvm.Core.Test/VM/ImportProgressTests.cs new file mode 100644 index 000000000..4f5846d08 --- /dev/null +++ b/test/WalkingTec.Mvvm.Core.Test/VM/ImportProgressTests.cs @@ -0,0 +1,175 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using WalkingTec.Mvvm.Core; +using WalkingTec.Mvvm.Test.Mock; + +namespace WalkingTec.Mvvm.Core.Test.VM; + +/// +/// Guards issue #607 — IProgress<ImportProgress> reporting in BatchSaveData, +/// and issue #615 — InlineErrors / InlineErrorLimit on BaseImportVM. +/// +[TestClass] +public class ImportProgressTests +{ + private string _seed = null!; + + [TestInitialize] + public void Init() => _seed = Guid.NewGuid().ToString(); + + private IDataContext CreateDb() => new ImportTestDataContext(_seed, DBTypeEnum.Memory); + + private TestImportVM MakeVm(List entities) + { + var vm = new TestImportVM(entities); + vm.Wtm = MockWtmContext.CreateWtmContext(CreateDb(), "testuser"); + return vm; + } + + // ── ImportProgress reporting (#607) ───────────────────────────────────── + + [TestMethod] + public void BatchSaveData_reports_progress_for_each_row() + { + var entities = new List + { + new ImportTestItem { Name = "A", Value = 1 }, + new ImportTestItem { Name = "B", Value = 2 }, + new ImportTestItem { Name = "C", Value = 3 } + }; + var vm = MakeVm(entities); + var reports = new List(); + var progress = new Progress(p => reports.Add(p)); + + vm.BatchSaveData(progress); + + // Progress should be reported at least once per entity (validate + save = 2 phases) + Assert.IsTrue(reports.Count >= 3, $"Expected >= 3 progress reports, got {reports.Count}"); + } + + [TestMethod] + public void BatchSaveData_progress_total_matches_entity_count() + { + var entities = Enumerable.Range(1, 5) + .Select(i => new ImportTestItem { Name = $"Item{i}", Value = i }) + .ToList(); + var vm = MakeVm(entities); + var reports = new List(); + + vm.BatchSaveData(new Progress(p => reports.Add(p))); + + Assert.IsTrue(reports.All(p => p.Total == 5), + "All progress reports should carry Total = entity count"); + } + + [TestMethod] + public void BatchSaveData_progress_processed_is_monotonically_increasing() + { + var entities = Enumerable.Range(1, 4) + .Select(i => new ImportTestItem { Name = $"X{i}", Value = i }) + .ToList(); + var vm = MakeVm(entities); + var reports = new List(); + + vm.BatchSaveData(new Progress(p => reports.Add(p))); + + // Filter to a single phase to check monotonicity + var saveReports = reports.Where(p => p.Phase == "Saving").ToList(); + for (int i = 1; i < saveReports.Count; i++) + Assert.IsTrue(saveReports[i].Processed >= saveReports[i - 1].Processed, + "Processed should not decrease within the same phase"); + } + + [TestMethod] + public void BatchSaveData_null_progress_does_not_throw() + { + var entities = new List + { + new ImportTestItem { Name = "NoProgress", Value = 7 } + }; + var vm = MakeVm(entities); + + // Calling without progress parameter (null) must not throw + Assert.IsTrue(vm.BatchSaveData(null)); + } + + [TestMethod] + public void ImportProgress_phase_names_are_non_empty() + { + var entities = new List + { + new ImportTestItem { Name = "PhaseCheck", Value = 1 } + }; + var vm = MakeVm(entities); + var reports = new List(); + + vm.BatchSaveData(new Progress(p => reports.Add(p))); + + Assert.IsTrue(reports.All(p => !string.IsNullOrEmpty(p.Phase)), + "All progress reports must have a non-empty Phase"); + } + + // ── InlineErrors (#615) ────────────────────────────────────────────────── + + [TestMethod] + public void InlineErrors_is_empty_before_import() + { + var vm = MakeVm(new List()); + Assert.AreEqual(0, vm.InlineErrors.Count); + } + + [TestMethod] + public void InlineErrors_returns_errors_after_validation_failure() + { + // Name exceeds StringLength(50) → validation error + var entities = new List + { + new ImportTestItem { Name = new string('Z', 60), Value = 1 }, + new ImportTestItem { Name = new string('Z', 60), Value = 2 } + }; + var vm = MakeVm(entities); + vm.BatchSaveData(); + + Assert.IsTrue(vm.InlineErrors.Count > 0, "InlineErrors should contain validation failures"); + } + + [TestMethod] + public void InlineErrors_respects_InlineErrorLimit() + { + // Create 100 invalid rows; InlineErrorLimit default is 50 + var entities = Enumerable.Range(1, 100) + .Select(i => new ImportTestItem { Name = new string('E', 60), Value = i }) + .ToList(); + var vm = MakeVm(entities); + vm.BatchSaveData(); + + Assert.IsTrue(vm.InlineErrors.Count <= vm.InlineErrorLimit, + $"InlineErrors ({vm.InlineErrors.Count}) must not exceed InlineErrorLimit ({vm.InlineErrorLimit})"); + } + + [TestMethod] + public void InlineErrors_is_empty_when_InlineErrorLimit_is_zero() + { + var entities = new List + { + new ImportTestItem { Name = new string('Q', 60), Value = 1 } + }; + var vm = MakeVm(entities); + vm.InlineErrorLimit = 0; + vm.BatchSaveData(); + + Assert.AreEqual(0, vm.InlineErrors.Count, + "InlineErrorLimit = 0 means no inline errors are surfaced"); + } + + [TestMethod] + public void InlineErrorLimit_default_is_50() + { + var vm = MakeVm(new List()); + Assert.AreEqual(50, vm.InlineErrorLimit); + } +} diff --git a/test/WalkingTec.Mvvm.Core.Test/VM/TemplateDescriptionRowTests.cs b/test/WalkingTec.Mvvm.Core.Test/VM/TemplateDescriptionRowTests.cs new file mode 100644 index 000000000..0f2333041 --- /dev/null +++ b/test/WalkingTec.Mvvm.Core.Test/VM/TemplateDescriptionRowTests.cs @@ -0,0 +1,108 @@ +#nullable enable +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NPOI.SS.UserModel; +using NPOI.XSSF.UserModel; +using System.IO; +using WalkingTec.Mvvm.Core; + +namespace WalkingTec.Mvvm.Core.Test.VM; + +/// +/// Guards issue #615 — ShowDescriptionRow in BaseTemplateVM: +/// when enabled (default), the template must contain a description row below the header. +/// +[TestClass] +public class TemplateDescriptionRowTests +{ + private class SimpleTemplateVM : BaseTemplateVM + { + public ExcelPropety Name_Excel = ExcelPropety.CreateProperty(x => x.SchoolName); + public ExcelPropety Code_Excel = ExcelPropety.CreateProperty(x => x.SchoolCode); + protected override void InitVM() { } + } + + private static IWorkbook GenerateWorkbook(bool showDescriptionRow) + { + var vm = new SimpleTemplateVM { ShowDescriptionRow = showDescriptionRow }; + var bytes = vm.GenerateTemplate(out _); + return new XSSFWorkbook(new MemoryStream(bytes)); + } + + [TestMethod] + public void ShowDescriptionRow_True_generates_v2_marker_in_hidden_sheet() + { + var wb = GenerateWorkbook(showDescriptionRow: true); + var enumSheet = wb.GetSheetAt(1); + var marker = enumSheet.GetRow(0)?.GetCell(3)?.ToString(); + Assert.AreEqual("v2", marker, + "Hidden enum sheet cell[3] must contain 'v2' when ShowDescriptionRow = true"); + } + + [TestMethod] + public void ShowDescriptionRow_False_generates_empty_marker_in_hidden_sheet() + { + var wb = GenerateWorkbook(showDescriptionRow: false); + var enumSheet = wb.GetSheetAt(1); + var marker = enumSheet.GetRow(0)?.GetCell(3)?.ToString() ?? string.Empty; + Assert.AreEqual(string.Empty, marker, + "Hidden enum sheet cell[3] must be empty when ShowDescriptionRow = false"); + } + + [TestMethod] + public void ShowDescriptionRow_True_row2_is_not_null() + { + var wb = GenerateWorkbook(showDescriptionRow: true); + var sheet = wb.GetSheetAt(0); + var descRow = sheet.GetRow(1); + Assert.IsNotNull(descRow, "Row 2 (index 1) must exist when ShowDescriptionRow = true"); + } + + [TestMethod] + public void ShowDescriptionRow_True_row2_contains_RequiredOrOptional_text() + { + var wb = GenerateWorkbook(showDescriptionRow: true); + var sheet = wb.GetSheetAt(0); + var descRow = sheet.GetRow(1); + Assert.IsNotNull(descRow); + + bool anyHintFound = false; + for (int c = 0; c < descRow.LastCellNum; c++) + { + var text = descRow.GetCell(c)?.ToString() ?? ""; + if (text.Contains("Required") || text.Contains("Optional")) + { + anyHintFound = true; + break; + } + } + Assert.IsTrue(anyHintFound, "Description row must contain 'Required' or 'Optional'"); + } + + [TestMethod] + public void ShowDescriptionRow_False_row2_is_data_or_null() + { + var wb = GenerateWorkbook(showDescriptionRow: false); + var sheet = wb.GetSheetAt(0); + // With ShowDescriptionRow = false, the first data row starts at index 1 + // (header is row 0, data is row 1). Row 1 should be null since no template data. + var row1 = sheet.GetRow(1); + // Either null (no data) or a data row — but NOT the green description row + if (row1 != null) + { + // If row exists, it should not contain "Required" or "Optional" hint text + for (int c = 0; c < row1.LastCellNum; c++) + { + var text = row1.GetCell(c)?.ToString() ?? ""; + Assert.IsFalse(text.Contains("Required") || text.Contains("Optional"), + "Row 2 must not be a description row when ShowDescriptionRow = false"); + } + } + } + + [TestMethod] + public void ShowDescriptionRow_default_is_true() + { + var vm = new SimpleTemplateVM(); + Assert.IsTrue(vm.ShowDescriptionRow); + } +} From 3a0025957ac014b6974aac05252ee7d01c34212f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 17:00:28 +0000 Subject: [PATCH 09/10] security: replace Microsoft.AspNetCore.Http 2.1.34 with FrameworkReference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old Microsoft.AspNetCore.Http NuGet package v2.1.34 is a .NET Core 2.1 artifact referenced in a .NET 8 project, carrying potential security vulnerabilities. Replace with FrameworkReference to Microsoft.AspNetCore.App which provides all ASP.NET Core APIs at the .NET 8 version. Also remove the duplicate explicit Microsoft.AspNetCore.Authorization 8.0.22 reference — it is included by the framework reference. https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj b/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj index 6b9883517..57de9111b 100644 --- a/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj +++ b/src/WalkingTec.Mvvm.Core/WalkingTec.Mvvm.Core.csproj @@ -10,10 +10,15 @@ + + + + + - @@ -35,7 +40,6 @@ - From d6a8fc24cc07e1b1279f1b2ef157bf16c28c7d0f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Mar 2026 17:01:58 +0000 Subject: [PATCH 10/10] feat(#540): add Rerun action to EtlRunLogListVM Override InitGridAction() to add a row-level 'Rerun' button that POSTs to _EtlRunLog/Rerun with the selected run log ID, restoring the watermark to the snapshot and triggering the job again. https://claude.ai/code/session_017r2EfWhoDdvECdoncQg6xM --- .../ViewModels/EtlRunLogListVM.cs | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/WalkingTec.Mvvm.Etl/ViewModels/EtlRunLogListVM.cs b/src/WalkingTec.Mvvm.Etl/ViewModels/EtlRunLogListVM.cs index 78e718c57..7bef7c6d6 100644 --- a/src/WalkingTec.Mvvm.Etl/ViewModels/EtlRunLogListVM.cs +++ b/src/WalkingTec.Mvvm.Etl/ViewModels/EtlRunLogListVM.cs @@ -23,7 +23,28 @@ protected override IEnumerable> InitGridHeader() this.MakeGridHeader(x => x.ElapsedMs).SetHeader("耗時(ms)"), this.MakeGridHeader(x => x.ErrorMessage!).SetHeader("錯誤訊息").SetWidth(200), this.MakeGridHeader(x => x.WatermarkSnapshot!).SetHeader("Watermark"), - this.MakeGridHeaderAction(width: 120) + this.MakeGridHeaderAction(width: 100) + }; + } + + protected override List InitGridAction() + { + return new List + { + // Rerun from this log's watermark snapshot (#540) + new GridAction + { + Name = "重跑", + IconCls = "layui-icon layui-icon-refresh", + ControllerName = "_EtlRunLog", + ActionName = "Rerun", + ParameterType = GridActionParameterTypesEnum.SingleId, + ShowInRow = true, + HideOnToolBar = true, + ShowDialog = false, + ForcePost = true, + PromptMessage = "確定從此記錄的 Watermark 快照重跑?" + } }; }