- editor.js: suppress edit entry for cells whose schema is readOnly (folder-bound originator, server-managed audit fields) — mirrors the $-prefixed synthesized-column guard. The server overwrites these, so inline-editing them was misleading and the value was silently lost. - save.js createRow: on 201, re-fetch the written row so server-derived fields (originator from the party folder, the composed tracking number's components, audit stamps) surface immediately instead of staying blank until reload. Falls back to the local merge if the GET fails. - save.js createRow: handle 409 (duplicate composed tracking number) with a clear message on the sequence cell instead of the generic errored state. Test: tables.spec.js — a readOnly column doesn't mount an inline editor while a normal sibling still edits. The 409 + re-fetch paths go through the in-dir create POST (formCreateUrl), which the file:// Playwright harness can't intercept; both are covered by the server e2e. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1129 lines
47 KiB
JavaScript
1129 lines
47 KiB
JavaScript
import { test, expect } from '@playwright/test';
|
||
import * as fs from 'fs';
|
||
import * as os from 'os';
|
||
import * as path from 'path';
|
||
|
||
const HTML_PATH = path.resolve('tables/dist/tables.html');
|
||
const HTML_RAW = fs.readFileSync(HTML_PATH, 'utf8');
|
||
|
||
const MDL_COLUMNS = [
|
||
{ field: 'id', title: 'ID', width: '6em' },
|
||
{ field: 'title', title: 'Deliverable' },
|
||
{ field: 'party', title: 'Party', enum: ['Acme', 'Beta', 'Gamma'] },
|
||
{ field: 'dueDate', title: 'Due', format: 'date' },
|
||
{ field: 'status', title: 'Status', enum: ['pending', 'submitted', 'accepted'] },
|
||
];
|
||
|
||
function makeRow(id, title, party, dueDate, status, editable = true) {
|
||
return {
|
||
url: `/Working/MDL/${id}.yaml.html`,
|
||
data: { id, title, party, dueDate, status },
|
||
editable,
|
||
};
|
||
}
|
||
|
||
const ROWS = [
|
||
makeRow('D-001', 'Site survey report', 'Acme', '2026-05-12', 'pending'),
|
||
makeRow('D-002', 'Foundation drawings A', 'Beta', '2026-05-20', 'submitted'),
|
||
makeRow('D-003', 'Procurement schedule', 'Acme', '2026-05-08', 'accepted'),
|
||
makeRow('D-004', 'Safety plan', 'Gamma', '2026-05-15', 'pending'),
|
||
makeRow('D-005', 'Geotechnical report', 'Beta', '2026-05-30', 'submitted'),
|
||
];
|
||
|
||
// Inject a complete table context into the page. Same pattern as
|
||
// form-safety.spec.js: write a patched copy of tables.html to a temp
|
||
// file and navigate via file://.
|
||
async function loadTableWithContext(page, context) {
|
||
const ctxJson = JSON.stringify(context).replace(/<\//g, '<\\/');
|
||
const replacement = `<script id="table-context" type="application/json">${ctxJson}</script>`;
|
||
const patched = HTML_RAW.replace(
|
||
/<script id="table-context" type="application\/json">[\s\S]*?<\/script>/,
|
||
replacement,
|
||
);
|
||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tables-spec-'));
|
||
const tmpPath = path.join(tmpDir, 'tables.html');
|
||
fs.writeFileSync(tmpPath, patched);
|
||
await page.goto(`file://${tmpPath}`, { waitUntil: 'load' });
|
||
}
|
||
|
||
test.describe('tables/ — directory-of-YAML table view', () => {
|
||
test('renders header with column titles and rows from context', async ({ page }) => {
|
||
page.on('pageerror', e => console.log('[pageerror]', e.message));
|
||
await loadTableWithContext(page, {
|
||
title: 'Master Deliverables List',
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
|
||
await page.waitForFunction(
|
||
() => document.querySelector('#table-root tbody').children.length > 0,
|
||
null,
|
||
{ timeout: 5000 },
|
||
);
|
||
|
||
// Header cells.
|
||
const headers = page.locator('.zddc-table__title-row .zddc-table__th');
|
||
await expect(headers).toHaveCount(MDL_COLUMNS.length);
|
||
await expect(headers.nth(0)).toContainText('ID');
|
||
await expect(headers.nth(1)).toContainText('Deliverable');
|
||
|
||
// Title in the page header.
|
||
await expect(page.locator('#table-title')).toContainText('Master Deliverables List');
|
||
|
||
// Row count.
|
||
await expect(page.locator('#table-root tbody tr')).toHaveCount(ROWS.length);
|
||
await expect(page.locator('#table-rowcount')).toContainText(`${ROWS.length} rows`);
|
||
});
|
||
|
||
test('default sort puts dueDate ascending when configured', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
defaults: { sort: [{ field: 'dueDate', dir: 'asc' }] },
|
||
});
|
||
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
const ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
|
||
// Sorted ascending by dueDate: D-003 (5/8), D-001 (5/12), D-004 (5/15), D-002 (5/20), D-005 (5/30).
|
||
expect(ids).toEqual(['D-003', 'D-001', 'D-004', 'D-002', 'D-005']);
|
||
});
|
||
|
||
test('clicking a column header sorts by that column and toggles direction', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Click the ID header → sort ascending.
|
||
await page.locator('.zddc-table__th[data-field="id"]').click();
|
||
let ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
|
||
expect(ids).toEqual(['D-001', 'D-002', 'D-003', 'D-004', 'D-005']);
|
||
|
||
// Click again → descending.
|
||
await page.locator('.zddc-table__th[data-field="id"]').click();
|
||
ids = await page.locator('#table-root tbody tr td:first-child').allTextContents();
|
||
expect(ids).toEqual(['D-005', 'D-004', 'D-003', 'D-002', 'D-001']);
|
||
});
|
||
|
||
test('free-text filter narrows visible rows', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Type "report" in the title column's filter — should match the
|
||
// two rows whose title contains "report" (Site survey report,
|
||
// Geotechnical report).
|
||
const titleFilter = page.locator('.zddc-table__th[data-field="title"]')
|
||
.locator('..')
|
||
.locator('xpath=following-sibling::tr[1]')
|
||
.locator('input[type="text"]')
|
||
.nth(0);
|
||
// Simpler selector: nth filter input under the filter row.
|
||
const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
|
||
await filterInputs.nth(1).fill('report'); // index 1 = title column
|
||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||
});
|
||
|
||
test('text filter on an enum column does substring match', async ({ page }) => {
|
||
// Filter row is uniformly text-contains across all columns —
|
||
// even for columns declared with `enum:` in the spec. Enum
|
||
// metadata still informs validation/sort but not filter UI.
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const filterInputs = page.locator('.zddc-table__filter-row input[type="text"]');
|
||
// Spec columns: 0=id, 1=title, 2=party, 3=dueDate, 4=status.
|
||
const statusInput = filterInputs.nth(4);
|
||
await statusInput.fill('pending');
|
||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||
});
|
||
|
||
test('clicking a cell selects it (Phase 1 — replaces row-click navigation)', async ({ page }) => {
|
||
// Single click → cell selection. Row navigation moves to a
|
||
// dedicated affordance in Phase 2 (open-in-form button) so the
|
||
// primary click action can be the spreadsheet-native one.
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Stub navigate seam — verifies single-click does NOT navigate.
|
||
await page.evaluate(() => {
|
||
window.__navTarget = null;
|
||
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
|
||
});
|
||
|
||
// Click a specific cell.
|
||
const firstCell = page.locator('#table-root tbody tr').first().locator('[role="gridcell"]').first();
|
||
await firstCell.click();
|
||
|
||
await expect(firstCell).toHaveClass(/zddc-table__cell--selected/);
|
||
await expect(firstCell).toHaveAttribute('tabindex', '0');
|
||
await expect(page.evaluate(() => window.__navTarget)).resolves.toBeNull();
|
||
});
|
||
|
||
test('arrow keys move cell selection (ARIA grid)', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Click to seed selection at (0,0), then arrow around.
|
||
const r0c0 = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(0);
|
||
await r0c0.click();
|
||
await expect(r0c0).toHaveClass(/zddc-table__cell--selected/);
|
||
|
||
await page.keyboard.press('ArrowDown');
|
||
const r1c0 = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(0);
|
||
await expect(r1c0).toHaveClass(/zddc-table__cell--selected/);
|
||
|
||
await page.keyboard.press('ArrowRight');
|
||
const r1c1 = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(1);
|
||
await expect(r1c1).toHaveClass(/zddc-table__cell--selected/);
|
||
|
||
await page.keyboard.press('ArrowUp');
|
||
const r0c1 = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
|
||
await expect(r0c1).toHaveClass(/zddc-table__cell--selected/);
|
||
|
||
await page.keyboard.press('ArrowLeft');
|
||
await expect(r0c0).toHaveClass(/zddc-table__cell--selected/);
|
||
});
|
||
|
||
test('Tab and Shift-Tab traverse cells with row-wrap', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const numCols = MDL_COLUMNS.length;
|
||
// Start at last column of row 0.
|
||
const r0Last = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(numCols - 1);
|
||
await r0Last.click();
|
||
await expect(r0Last).toHaveClass(/zddc-table__cell--selected/);
|
||
|
||
// Tab → first cell of row 1 (wrap).
|
||
await page.keyboard.press('Tab');
|
||
const r1First = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(0);
|
||
await expect(r1First).toHaveClass(/zddc-table__cell--selected/);
|
||
|
||
// Shift+Tab → back to last cell of row 0.
|
||
await page.keyboard.press('Shift+Tab');
|
||
await expect(r0Last).toHaveClass(/zddc-table__cell--selected/);
|
||
});
|
||
|
||
test('Enter enters edit mode; Enter commits and moves down', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Edit the title cell (column index 1) of row 0.
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
|
||
await titleCell.click();
|
||
await page.keyboard.press('Enter');
|
||
|
||
// Editor input mounted inside the cell.
|
||
const input = titleCell.locator('input.zddc-table__cell-input');
|
||
await expect(input).toBeVisible();
|
||
await expect(input).toBeFocused();
|
||
|
||
// Type new value, press Enter to commit + move down.
|
||
await page.keyboard.press('Control+a');
|
||
await page.keyboard.type('New title via cell editor');
|
||
await page.keyboard.press('Enter');
|
||
|
||
// Cell shows new value, input gone.
|
||
await expect(titleCell).toContainText('New title via cell editor');
|
||
await expect(titleCell.locator('input')).toHaveCount(0);
|
||
|
||
// Selection moved down one row, same column.
|
||
const r1Title = page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(1);
|
||
await expect(r1Title).toHaveClass(/zddc-table__cell--selected/);
|
||
});
|
||
|
||
test('Escape cancels edit, restoring prior value', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
|
||
const originalText = await titleCell.textContent();
|
||
|
||
await titleCell.click();
|
||
await page.keyboard.press('Enter');
|
||
await page.keyboard.press('Control+a');
|
||
await page.keyboard.type('Should not stick');
|
||
await page.keyboard.press('Escape');
|
||
|
||
// Value restored to original; no draft entry.
|
||
await expect(titleCell).toHaveText(originalText.trim());
|
||
const draftCount = await page.evaluate(() =>
|
||
Object.keys(window.tablesApp.state.drafts).length);
|
||
expect(draftCount).toBe(0);
|
||
});
|
||
|
||
test('typing a printable char enters edit and replaces value', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
|
||
await titleCell.click();
|
||
// Press a printable character — should enter edit mode with
|
||
// that char as the new value.
|
||
await page.keyboard.press('X');
|
||
const input = titleCell.locator('input.zddc-table__cell-input');
|
||
await expect(input).toBeVisible();
|
||
await expect(input).toHaveValue('X');
|
||
});
|
||
|
||
test('double-click also enters edit mode', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(1);
|
||
await titleCell.dblclick();
|
||
await expect(titleCell.locator('input.zddc-table__cell-input')).toBeVisible();
|
||
});
|
||
|
||
test('non-editable rows still get the readonly class', async ({ page }) => {
|
||
// Cosmetic guard for an existing convention: rows where the
|
||
// server says editable=false get a visual treatment. Cell
|
||
// selection still works in Phase 1; Phase 3 will gate writes
|
||
// on the editable flag at save time.
|
||
const readOnlyRows = ROWS.map(r => ({ ...r, editable: false }));
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: readOnlyRows,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
await expect(page.locator('#table-root tbody tr.zddc-table__row--editable')).toHaveCount(0);
|
||
await expect(page.locator('#table-root tbody tr.zddc-table__row--readonly')).toHaveCount(ROWS.length);
|
||
});
|
||
|
||
test('default filters seed the visible row count from defaults.filter', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: ROWS,
|
||
defaults: { filter: { status: ['pending'] } },
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
await expect(page.locator('#table-root tbody tr')).toHaveCount(2);
|
||
});
|
||
|
||
test('empty rows list shows the empty-state notice', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: MDL_COLUMNS,
|
||
rows: [],
|
||
});
|
||
// Wait briefly for init.
|
||
await page.waitForTimeout(50);
|
||
await expect(page.locator('#table-root tbody tr')).toHaveCount(0);
|
||
await expect(page.locator('#table-empty')).toBeHidden();
|
||
});
|
||
|
||
// --- Phase 2: schema-driven cell editor widgets -----------------------
|
||
|
||
// A small JSON Schema covering the inline-editable types the
|
||
// factory recognises: string, integer with min/max, boolean,
|
||
// string-enum, format:date, array<enum>+uniqueItems.
|
||
const ROW_SCHEMA = {
|
||
type: 'object',
|
||
properties: {
|
||
id: { type: 'string' },
|
||
title: { type: 'string' },
|
||
party: { type: 'string', enum: ['Acme', 'Beta', 'Gamma'] },
|
||
dueDate: { type: 'string', format: 'date' },
|
||
status: { type: 'string', enum: ['pending', 'submitted', 'accepted'] },
|
||
priority: { type: 'integer', minimum: 1, maximum: 5 },
|
||
done: { type: 'boolean' },
|
||
tags: {
|
||
type: 'array',
|
||
uniqueItems: true,
|
||
items: { type: 'string', enum: ['blue', 'green', 'red'] },
|
||
},
|
||
// A nested-object cell — should punt to navigation rather
|
||
// than mount an inline editor.
|
||
owner: {
|
||
type: 'object',
|
||
properties: {
|
||
name: { type: 'string' },
|
||
email: { type: 'string', format: 'email' },
|
||
},
|
||
},
|
||
},
|
||
};
|
||
|
||
const SCHEMA_COLUMNS = [
|
||
{ field: 'id', title: 'ID', width: '6em' },
|
||
{ field: 'title', title: 'Title' },
|
||
{ field: 'party', title: 'Party' },
|
||
{ field: 'dueDate', title: 'Due', format: 'date' },
|
||
{ field: 'status', title: 'Status' },
|
||
{ field: 'priority', title: 'Priority' },
|
||
{ field: 'done', title: 'Done' },
|
||
{ field: 'tags', title: 'Tags' },
|
||
{ field: 'owner', title: 'Owner' },
|
||
];
|
||
|
||
function makeSchemaRow(over) {
|
||
return {
|
||
url: `/Working/MDL/${over.id || 'D-001'}.yaml.html`,
|
||
data: Object.assign({
|
||
id: 'D-001', title: 'Sample', party: 'Acme', dueDate: '2026-05-12',
|
||
status: 'pending', priority: 3, done: false, tags: ['blue'],
|
||
owner: { name: 'Casey', email: 'c@example.com' },
|
||
}, over.data || {}),
|
||
editable: true,
|
||
};
|
||
}
|
||
|
||
const SCHEMA_ROWS = [makeSchemaRow({ id: 'D-001' }), makeSchemaRow({ id: 'D-002' })];
|
||
|
||
function colIdx(field) {
|
||
return SCHEMA_COLUMNS.findIndex(c => c.field === field);
|
||
}
|
||
|
||
test('Phase 2: enum column edits via select dropdown', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: SCHEMA_ROWS,
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const partyCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('party'));
|
||
await partyCell.dblclick();
|
||
const select = partyCell.locator('select.zddc-table__cell-input');
|
||
await expect(select).toBeVisible();
|
||
// Empty placeholder + 3 enum options.
|
||
await expect(select.locator('option')).toHaveCount(4);
|
||
await select.selectOption('Beta');
|
||
await page.keyboard.press('Enter');
|
||
await expect(partyCell).toContainText('Beta');
|
||
});
|
||
|
||
test('Phase 2: integer column gives a number input with min/max', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: SCHEMA_ROWS,
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const cell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('priority'));
|
||
await cell.dblclick();
|
||
const input = cell.locator('input.zddc-table__cell-input');
|
||
await expect(input).toHaveAttribute('type', 'number');
|
||
await expect(input).toHaveAttribute('min', '1');
|
||
await expect(input).toHaveAttribute('max', '5');
|
||
await expect(input).toHaveAttribute('step', '1');
|
||
|
||
await input.fill('4');
|
||
await page.keyboard.press('Enter');
|
||
await expect(cell).toContainText('4');
|
||
|
||
// Draft holds a Number, not a string.
|
||
const draftType = await page.evaluate(() => {
|
||
const drafts = window.tablesApp.state.drafts;
|
||
const rowId = Object.keys(drafts)[0];
|
||
return typeof drafts[rowId].priority;
|
||
});
|
||
expect(draftType).toBe('number');
|
||
});
|
||
|
||
test('Phase 2: boolean column gives a checkbox', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: SCHEMA_ROWS,
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const cell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('done'));
|
||
await cell.dblclick();
|
||
const cb = cell.locator('input.zddc-table__cell-input');
|
||
await expect(cb).toHaveAttribute('type', 'checkbox');
|
||
// Toggle via Space (the keyboard contract a screen-reader user
|
||
// would use). Avoids the click+blur race that Playwright's
|
||
// .check() helper hits on a focused-checkbox-inside-grid-cell.
|
||
await page.keyboard.press('Space');
|
||
await page.keyboard.press('Enter');
|
||
|
||
const draftValue = await page.evaluate(() => {
|
||
const drafts = window.tablesApp.state.drafts;
|
||
const rowId = Object.keys(drafts)[0];
|
||
return drafts[rowId].done;
|
||
});
|
||
expect(draftValue).toBe(true);
|
||
});
|
||
|
||
test('Phase 2: format:date column gives a date input', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: SCHEMA_ROWS,
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const cell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('dueDate'));
|
||
await cell.dblclick();
|
||
const input = cell.locator('input.zddc-table__cell-input');
|
||
await expect(input).toHaveAttribute('type', 'date');
|
||
await expect(input).toHaveValue('2026-05-12');
|
||
});
|
||
|
||
test('Phase 2: multi-select enum-array column gives a multi-select', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: SCHEMA_ROWS,
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const cell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('tags'));
|
||
await cell.dblclick();
|
||
const select = cell.locator('select.zddc-table__cell-input');
|
||
await expect(select).toBeVisible();
|
||
await expect(select).toHaveAttribute('multiple', '');
|
||
});
|
||
|
||
test('Phase 2: complex (object) column navigates to the row form on edit', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: SCHEMA_ROWS,
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Stub navigation seam — see how the editor punts to the
|
||
// form for inline-uneditable types.
|
||
await page.evaluate(() => {
|
||
window.__navTarget = null;
|
||
window.tablesApp.navigateTo = url => { window.__navTarget = url; };
|
||
});
|
||
|
||
const cell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('owner'));
|
||
await cell.dblclick();
|
||
// No inline editor mounted.
|
||
await expect(cell.locator('.zddc-table__cell-input')).toHaveCount(0);
|
||
const target = await page.evaluate(() => window.__navTarget);
|
||
expect(target).toContain('.yaml.html');
|
||
});
|
||
|
||
test('Phase 2: readOnly column suppresses the inline editor', async ({ page }) => {
|
||
// Self-contained fixture: a folder-bound / server-managed field
|
||
// (schema readOnly:true) must not become an editable cell, while
|
||
// a normal sibling column still edits.
|
||
await loadTableWithContext(page, {
|
||
columns: [
|
||
{ field: 'originator', title: 'Originator' },
|
||
{ field: 'title', title: 'Title' },
|
||
],
|
||
rows: [{
|
||
url: '/Working/MDL/ACM-PRJ-EL-SPC-0001.yaml.html',
|
||
data: { originator: 'ACM', title: 'Spec' },
|
||
editable: true,
|
||
}],
|
||
rowSchema: {
|
||
type: 'object',
|
||
properties: {
|
||
originator: { type: 'string', readOnly: true },
|
||
title: { type: 'string' },
|
||
},
|
||
},
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const ro = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(0);
|
||
await ro.dblclick();
|
||
// No inline editor mounts; the displayed value is untouched.
|
||
await expect(ro.locator('.zddc-table__cell-input')).toHaveCount(0);
|
||
await expect(ro).toHaveText('ACM');
|
||
|
||
// A normal column in the same table still edits.
|
||
const editable = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(1);
|
||
await editable.dblclick();
|
||
await expect(editable.locator('input.zddc-table__cell-input')).toBeVisible();
|
||
});
|
||
|
||
test('Phase 2: no rowSchema → falls back to plain text editor', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
// No rowSchema in the context — same as a directory with
|
||
// table.yaml but no form.yaml.
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: SCHEMA_ROWS,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const cell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('party'));
|
||
await cell.dblclick();
|
||
// Default text input — even for a column that COULD have been
|
||
// an enum dropdown if the schema had been provided.
|
||
const input = cell.locator('input.zddc-table__cell-input');
|
||
await expect(input).toHaveAttribute('type', 'text');
|
||
});
|
||
|
||
// --- Phase 3: row-level save + ETag conflict UX -----------------------
|
||
|
||
// Loading the page via file:// means PUTs go to file:// URLs — no
|
||
// network. We intercept via page.route on the parent http://* URL
|
||
// by hosting the test fixture rows with absolute http URLs in their
|
||
// yamlUrl field.
|
||
function makeNetRow(over) {
|
||
const id = over.id || 'D-001';
|
||
return {
|
||
url: `http://test.local/Working/MDL/${id}.yaml.html`,
|
||
yamlUrl: `http://test.local/Working/MDL/${id}.yaml`,
|
||
data: Object.assign({
|
||
id, title: 'Sample', party: 'Acme', dueDate: '2026-05-12',
|
||
status: 'pending', priority: 3, done: false, tags: ['blue'],
|
||
owner: { name: 'Casey', email: 'c@example.com' },
|
||
}, over.data || {}),
|
||
etag: over.etag || 'v1',
|
||
editable: true,
|
||
};
|
||
}
|
||
|
||
async function setupSaveCapture(page) {
|
||
// Intercept PUTs on test.local. Tests configure responses by
|
||
// pushing into window.__saveResponses; tests inspect requests
|
||
// via window.__savePuts.
|
||
await page.route('http://test.local/**', async (route) => {
|
||
const req = route.request();
|
||
const method = req.method();
|
||
if (method !== 'PUT' && method !== 'GET') {
|
||
await route.continue();
|
||
return;
|
||
}
|
||
await page.evaluate(({ url, method, body, headers }) => {
|
||
window.__capturedRequests = window.__capturedRequests || [];
|
||
window.__capturedRequests.push({ url, method, body, headers });
|
||
}, {
|
||
url: req.url(),
|
||
method,
|
||
body: req.postData(),
|
||
headers: req.headers(),
|
||
});
|
||
const queued = await page.evaluate(() => window.__nextResponse || null);
|
||
if (queued) {
|
||
await page.evaluate(() => { window.__nextResponse = null; });
|
||
await route.fulfill({
|
||
status: queued.status,
|
||
headers: queued.headers || {},
|
||
body: queued.body || '',
|
||
});
|
||
return;
|
||
}
|
||
// Default success.
|
||
await route.fulfill({
|
||
status: 200,
|
||
headers: { 'ETag': '"v2"' },
|
||
body: '',
|
||
});
|
||
});
|
||
}
|
||
|
||
test('Phase 3: row-blur fires PUT with merged drafts + If-Match', async ({ page }) => {
|
||
await setupSaveCapture(page);
|
||
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
await page.evaluate(() => { window.__capturedRequests = []; });
|
||
|
||
// Edit a cell in row 0, then click row 1 to move selection
|
||
// (row-blur trigger).
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleCell.dblclick();
|
||
await page.keyboard.press('Control+a');
|
||
await page.keyboard.type('Edited title');
|
||
await page.keyboard.press('Enter');
|
||
// Enter committed + moved selection down to row 1 — that IS
|
||
// the row-blur. Wait briefly for the async fetch to fire.
|
||
await page.waitForFunction(() =>
|
||
(window.__capturedRequests || []).some(r => r.method === 'PUT'),
|
||
null, { timeout: 3000 });
|
||
|
||
const puts = await page.evaluate(() =>
|
||
(window.__capturedRequests || []).filter(r => r.method === 'PUT'));
|
||
expect(puts).toHaveLength(1);
|
||
expect(puts[0].url).toBe('http://test.local/Working/MDL/D-001.yaml');
|
||
expect(puts[0].body).toContain('Edited title');
|
||
expect(puts[0].body).toContain('id: D-001');
|
||
// If-Match present.
|
||
expect(puts[0].headers['if-match']).toBe('"v1"');
|
||
});
|
||
|
||
test('Phase 3: 412 conflict marks row stale + shows status prompt', async ({ page }) => {
|
||
await setupSaveCapture(page);
|
||
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
await page.evaluate(() => { window.__nextResponse = { status: 412 }; });
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleCell.dblclick();
|
||
await page.keyboard.press('Control+a');
|
||
await page.keyboard.type('My change');
|
||
await page.keyboard.press('Enter');
|
||
|
||
// Wait for the row to gain the stale class.
|
||
const row0 = page.locator('#table-root tbody tr').nth(0);
|
||
await expect(row0).toHaveClass(/zddc-table__row--stale/);
|
||
|
||
// Status prompt visible with both buttons.
|
||
const status = page.locator('#table-status');
|
||
await expect(status).toBeVisible();
|
||
await expect(status).toContainText('changed by someone else');
|
||
await expect(status.locator('button', { hasText: 'Use mine' })).toBeVisible();
|
||
await expect(status.locator('button', { hasText: 'Reload' })).toBeVisible();
|
||
|
||
// Drafts must STILL exist — never silently discard.
|
||
const draftCount = await page.evaluate(() =>
|
||
Object.keys(window.tablesApp.state.drafts).length);
|
||
expect(draftCount).toBe(1);
|
||
});
|
||
|
||
test('Phase 3: 422 validation errors mark cells invalid', async ({ page }) => {
|
||
await setupSaveCapture(page);
|
||
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
await page.evaluate(() => {
|
||
window.__nextResponse = {
|
||
status: 422,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
errors: [
|
||
{ path: '/priority', message: 'must be ≤ 5' },
|
||
{ path: '/title', message: 'required' },
|
||
],
|
||
}),
|
||
};
|
||
});
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleCell.dblclick();
|
||
await page.keyboard.press('Control+a');
|
||
await page.keyboard.type('x');
|
||
await page.keyboard.press('Enter');
|
||
// Enter committed + moved selection to row 1 → row-blur on row 0
|
||
// → PUT fires → 422 → cells marked.
|
||
await page.waitForFunction(() =>
|
||
document.querySelector('.zddc-table__cell--invalid'),
|
||
null, { timeout: 3000 });
|
||
|
||
const invalidCells = await page.locator('.zddc-table__cell--invalid').count();
|
||
expect(invalidCells).toBe(2);
|
||
});
|
||
|
||
// --- Phase 4: copy/paste from Excel/Sheets (TSV) ---------------------
|
||
|
||
test('Phase 4: parseTSV handles tabs, newlines, and quoted fields', async ({ page }) => {
|
||
// Unit-style test of the parser via the exposed clipboard module.
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
const result = await page.evaluate(() => {
|
||
const p = window.tablesApp.modules.clipboard.parseTSV;
|
||
return [
|
||
p('a\tb\tc'),
|
||
p('a\tb\nc\td'),
|
||
p('a\t"line1\nline2"\tc'),
|
||
p('"with ""quotes"""\tplain'),
|
||
p('a\r\nb\r\nc'),
|
||
p(''),
|
||
];
|
||
});
|
||
expect(result[0]).toEqual([['a', 'b', 'c']]);
|
||
expect(result[1]).toEqual([['a', 'b'], ['c', 'd']]);
|
||
expect(result[2]).toEqual([['a', 'line1\nline2', 'c']]);
|
||
expect(result[3]).toEqual([['with "quotes"', 'plain']]);
|
||
expect(result[4]).toEqual([['a'], ['b'], ['c']]);
|
||
expect(result[5]).toEqual([]);
|
||
});
|
||
|
||
test('Phase 4: paste single value into selected cell', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleCell.click();
|
||
|
||
// Drive applyPaste directly (Playwright's clipboard event
|
||
// simulation is browser-flag-gated; calling the module is the
|
||
// honest unit-test path).
|
||
await page.evaluate(() => {
|
||
const sel = window.tablesApp.state.selected;
|
||
window.tablesApp.modules.clipboard.applyPaste(
|
||
sel.row, sel.col,
|
||
[['Pasted single value']],
|
||
);
|
||
window.tablesApp.repaint();
|
||
});
|
||
|
||
await expect(titleCell).toContainText('Pasted single value');
|
||
|
||
// Draft buffer holds it under the right field.
|
||
const draftValue = await page.evaluate(() => {
|
||
const drafts = window.tablesApp.state.drafts;
|
||
const rowId = Object.keys(drafts)[0];
|
||
return drafts[rowId].title;
|
||
});
|
||
expect(draftValue).toBe('Pasted single value');
|
||
});
|
||
|
||
test('Phase 4: paste 2x2 grid spills from anchor', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Anchor at row 0, title column.
|
||
const anchor = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await anchor.click();
|
||
|
||
await page.evaluate(() => {
|
||
const sel = window.tablesApp.state.selected;
|
||
window.tablesApp.modules.clipboard.applyPaste(
|
||
sel.row, sel.col,
|
||
[
|
||
['t-r0', 'Acme'],
|
||
['t-r1', 'Beta'],
|
||
],
|
||
);
|
||
window.tablesApp.repaint();
|
||
});
|
||
|
||
// Row 0: title=t-r0, party=Acme.
|
||
await expect(
|
||
page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(colIdx('title'))
|
||
).toContainText('t-r0');
|
||
await expect(
|
||
page.locator('#table-root tbody tr').nth(0).locator('[role="gridcell"]').nth(colIdx('party'))
|
||
).toContainText('Acme');
|
||
// Row 1: title=t-r1, party=Beta.
|
||
await expect(
|
||
page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(colIdx('title'))
|
||
).toContainText('t-r1');
|
||
await expect(
|
||
page.locator('#table-root tbody tr').nth(1).locator('[role="gridcell"]').nth(colIdx('party'))
|
||
).toContainText('Beta');
|
||
});
|
||
|
||
test('Phase 4: paste coerces numeric/boolean values via row schema', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const priorityCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('priority'));
|
||
await priorityCell.click();
|
||
await page.evaluate(() => {
|
||
const sel = window.tablesApp.state.selected;
|
||
window.tablesApp.modules.clipboard.applyPaste(
|
||
sel.row, sel.col,
|
||
[['4', 'true']], // priority + done
|
||
);
|
||
window.tablesApp.repaint();
|
||
});
|
||
|
||
const drafts = await page.evaluate(() => {
|
||
const d = window.tablesApp.state.drafts;
|
||
const rowId = Object.keys(d)[0];
|
||
return d[rowId];
|
||
});
|
||
expect(drafts.priority).toBe(4);
|
||
expect(typeof drafts.priority).toBe('number');
|
||
expect(drafts.done).toBe(true);
|
||
});
|
||
|
||
test('Phase 4: paste out-of-bounds drops cells silently with toast', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const lastRowLastCol = page.locator('#table-root tbody tr').nth(1)
|
||
.locator('[role="gridcell"]').last();
|
||
await lastRowLastCol.click();
|
||
|
||
// Dispatch a real paste event so onPaste runs (which writes
|
||
// the toast). 3-row × 2-col TSV anchored at the last cell —
|
||
// every cell spills past the end of either rows or columns.
|
||
await page.evaluate(() => {
|
||
const dt = new DataTransfer();
|
||
dt.setData('text/plain', 'x1\ty1\nx2\ty2\nx3\ty3');
|
||
const ev = new ClipboardEvent('paste', {
|
||
clipboardData: dt,
|
||
bubbles: true,
|
||
cancelable: true,
|
||
});
|
||
document.dispatchEvent(ev);
|
||
});
|
||
await expect(page.locator('#table-status')).toContainText('dropped');
|
||
});
|
||
|
||
test('Phase 4: copy single cell writes value to clipboard', async ({ page, context }) => {
|
||
// Granting clipboard-read makes Chromium permit synthetic copy.
|
||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleCell.click();
|
||
|
||
// Synthesize a copy event with a writable DataTransfer-like
|
||
// payload; assert the handler wrote the cell value into it.
|
||
const written = await page.evaluate(() => {
|
||
const ev = new ClipboardEvent('copy', {
|
||
clipboardData: new DataTransfer(),
|
||
bubbles: true,
|
||
cancelable: true,
|
||
});
|
||
document.dispatchEvent(ev);
|
||
return ev.clipboardData.getData('text/plain');
|
||
});
|
||
expect(written).toBe('Sample');
|
||
});
|
||
|
||
// --- Phase 5: undo + multi-cell ops ----------------------------------
|
||
|
||
test('Phase 5: Ctrl+Z reverts a single cell edit to prior value', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
const originalText = await titleCell.textContent();
|
||
|
||
await titleCell.dblclick();
|
||
await page.keyboard.press('Control+a');
|
||
await page.keyboard.type('Edited via undo test');
|
||
await page.keyboard.press('Enter');
|
||
await expect(titleCell).toContainText('Edited via undo test');
|
||
|
||
// Ctrl+Z (focus has moved to next cell after Enter — undo's
|
||
// hotkey is bound at the document, so it works from any
|
||
// active cell).
|
||
await page.keyboard.press('Control+z');
|
||
await expect(titleCell).toHaveText(originalText.trim());
|
||
|
||
// Draft cleared because we returned to the stored value.
|
||
const draftCount = await page.evaluate(() =>
|
||
Object.keys(window.tablesApp.state.drafts).length);
|
||
expect(draftCount).toBe(0);
|
||
});
|
||
|
||
test('Phase 5: Shift+ArrowDown extends range selection', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const r0c1 = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(1);
|
||
await r0c1.click();
|
||
await page.keyboard.press('Shift+ArrowDown');
|
||
|
||
// Both cells in the column should be in the range.
|
||
const inRange = page.locator('.zddc-table__cell--in-range');
|
||
await expect(inRange).toHaveCount(2);
|
||
});
|
||
|
||
test('Phase 5: Shift+click extends range from anchor to clicked cell', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const r0c1 = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(1);
|
||
const r1c3 = page.locator('#table-root tbody tr').nth(1)
|
||
.locator('[role="gridcell"]').nth(3);
|
||
|
||
await r0c1.click();
|
||
await r1c3.click({ modifiers: ['Shift'] });
|
||
|
||
// 2 rows × 3 cols = 6 cells in the range.
|
||
await expect(page.locator('.zddc-table__cell--in-range')).toHaveCount(6);
|
||
});
|
||
|
||
test('Phase 5: Delete clears every selected cell', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Select a 2x2 range starting at the title column.
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleCell.click();
|
||
await page.keyboard.press('Shift+ArrowRight');
|
||
await page.keyboard.press('Shift+ArrowDown');
|
||
|
||
await page.keyboard.press('Delete');
|
||
|
||
// 4 drafts created, each set to null.
|
||
const drafts = await page.evaluate(() => window.tablesApp.state.drafts);
|
||
const totalDraftFields = Object.values(drafts)
|
||
.reduce((acc, r) => acc + Object.keys(r).length, 0);
|
||
expect(totalDraftFields).toBe(4);
|
||
});
|
||
|
||
test('Phase 5: Ctrl+D fills the top row down through the range', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: [
|
||
makeSchemaRow({ id: 'D-001', data: { title: 'Top' } }),
|
||
makeSchemaRow({ id: 'D-002', data: { title: 'Bottom' } }),
|
||
],
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Select the title column across both rows.
|
||
const titleR0 = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleR0.click();
|
||
await page.keyboard.press('Shift+ArrowDown');
|
||
|
||
await page.keyboard.press('Control+d');
|
||
|
||
const titleR1 = page.locator('#table-root tbody tr').nth(1)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await expect(titleR1).toContainText('Top');
|
||
});
|
||
|
||
test('Phase 5: Ctrl+Z reverts a bulk fill in one step', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS,
|
||
rows: [
|
||
makeSchemaRow({ id: 'D-001', data: { title: 'Top' } }),
|
||
makeSchemaRow({ id: 'D-002', data: { title: 'Bottom' } }),
|
||
],
|
||
rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
const titleR0 = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
const titleR1 = page.locator('#table-root tbody tr').nth(1)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
|
||
await titleR0.click();
|
||
await page.keyboard.press('Shift+ArrowDown');
|
||
await page.keyboard.press('Control+d');
|
||
await expect(titleR1).toContainText('Top');
|
||
|
||
await page.keyboard.press('Control+z');
|
||
await expect(titleR1).toContainText('Bottom');
|
||
|
||
// Drafts cleared (returning to stored values).
|
||
const draftCount = await page.evaluate(() =>
|
||
Object.keys(window.tablesApp.state.drafts).length);
|
||
expect(draftCount).toBe(0);
|
||
});
|
||
|
||
test('Phase 5: undo stack depth caps at 50', async ({ page }) => {
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows: SCHEMA_ROWS, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
|
||
// Push 60 commands directly via the module API.
|
||
await page.evaluate(() => {
|
||
const u = window.tablesApp.modules.undo;
|
||
for (let i = 0; i < 60; i++) {
|
||
u.push({ cells: [{ rowId: 'fake', field: 'title', oldValue: 'a', newValue: 'b' }] });
|
||
}
|
||
});
|
||
const depth = await page.evaluate(() => window.tablesApp.modules.undo.depth());
|
||
expect(depth).toBe(50);
|
||
});
|
||
|
||
test('Phase 3: Reload button drops drafts and refreshes', async ({ page }) => {
|
||
await setupSaveCapture(page);
|
||
const rows = [makeNetRow({ id: 'D-001' }), makeNetRow({ id: 'D-002' })];
|
||
await loadTableWithContext(page, {
|
||
columns: SCHEMA_COLUMNS, rows, rowSchema: ROW_SCHEMA,
|
||
});
|
||
await page.waitForSelector('#table-root tbody tr');
|
||
await page.evaluate(() => { window.__nextResponse = { status: 412 }; });
|
||
|
||
const titleCell = page.locator('#table-root tbody tr').nth(0)
|
||
.locator('[role="gridcell"]').nth(colIdx('title'));
|
||
await titleCell.dblclick();
|
||
await page.keyboard.press('Control+a');
|
||
await page.keyboard.type('My change');
|
||
await page.keyboard.press('Enter');
|
||
|
||
const status = page.locator('#table-status');
|
||
await expect(status).toBeVisible();
|
||
|
||
// Queue a fresh GET response with new ETag.
|
||
await page.evaluate(() => {
|
||
window.__nextResponse = {
|
||
status: 200,
|
||
headers: { 'ETag': '"v3"', 'Content-Type': 'application/yaml' },
|
||
body: 'id: D-001\ntitle: Server-side new title\nparty: Acme\ndueDate: 2026-05-12\nstatus: pending\npriority: 3\ndone: false\ntags:\n - blue\nowner:\n name: Casey\n email: c@example.com\n',
|
||
};
|
||
});
|
||
|
||
await status.locator('button', { hasText: 'Reload' }).click();
|
||
await expect(status).toBeHidden();
|
||
|
||
// Drafts cleared.
|
||
const draftCount = await page.evaluate(() =>
|
||
Object.keys(window.tablesApp.state.drafts).length);
|
||
expect(draftCount).toBe(0);
|
||
});
|
||
});
|