From 9e4ffe5cee674446fb4e927577c95121b64a00a0 Mon Sep 17 00:00:00 2001 From: xarmian Date: Fri, 25 Sep 2026 23:30:49 -0400 Subject: [PATCH] fix(web): a field value whose shape does not match its declared type shows its raw text with a note and is replaced explicitly, never coerced (BUG-3052 unit 2) (#1556) --- web/e2e/bug-3052-field-shape-mismatch.spec.ts | 97 ++++++++++++ .../TableView.listValue.svelte.test.ts | 27 ++++ .../components/collections/TableView.svelte | 23 ++- .../FieldEditor.shapeMismatch.svelte.test.ts | 144 ++++++++++++++++++ .../lib/components/fields/FieldEditor.svelte | 86 ++++++++++- .../components/items/CopyItemDialog.svelte | 6 +- web/src/lib/fields/fieldShape.test.ts | 63 +++++++- web/src/lib/fields/fieldShape.ts | 74 ++++++++- web/src/lib/items/copyNeedsValue.ts | 6 +- .../[workspace]/[collection]/+page.svelte | 9 +- 10 files changed, 518 insertions(+), 17 deletions(-) create mode 100644 web/e2e/bug-3052-field-shape-mismatch.spec.ts create mode 100644 web/src/lib/components/fields/FieldEditor.shapeMismatch.svelte.test.ts diff --git a/web/e2e/bug-3052-field-shape-mismatch.spec.ts b/web/e2e/bug-3052-field-shape-mismatch.spec.ts new file mode 100644 index 000000000..61542dd02 --- /dev/null +++ b/web/e2e/bug-3052-field-shape-mismatch.spec.ts @@ -0,0 +1,97 @@ +import { test, expect } from './fixtures'; +import { browserLogin } from './lib/collab-helpers'; +import { authJson, createCollection, deleteCollection } from './lib/attachment-viewer'; +import type { APIRequestContext } from '@playwright/test'; +import type { SuiteFixture } from './fixtures'; + +/** + * BUG-3052 unit 2: a stored value whose shape does not match its field's + * declared type is shown in the item page as its raw text with a note, and an + * edit replaces it explicitly. + * + * The component legs mount FieldEditor with the value they choose. This leg is + * about what the PAGE hands it, which the component legs cannot see: ItemDetail + * passes an unset field as `''`, and the first version of the shape check read + * that as "not a number" and marked every empty number field on every item. + * So the proving pair is one item that must carry the marker and one that must + * not. + */ + +async function createItem( + fixture: SuiteFixture, + request: APIRequestContext, + collSlug: string, + title: string, + fields: Record, +): Promise<{ slug: string }> { + const resp = await request.post( + `/api/v1/workspaces/${fixture.workspaceSlug}/collections/${collSlug}/items`, + { headers: authJson(fixture), data: { title, fields: JSON.stringify(fields), content: '' } }, + ); + if (!resp.ok()) throw new Error(`item create failed (${resp.status()}): ${await resp.text()}`); + return (await resp.json()) as { slug: string }; +} + +async function setSchema(fixture: SuiteFixture, request: APIRequestContext, slug: string, schema: unknown) { + const resp = await request.patch(`/api/v1/workspaces/${fixture.workspaceSlug}/collections/${slug}`, { + headers: authJson(fixture), + data: { schema: JSON.stringify(schema) }, + }); + if (!resp.ok()) throw new Error(`collection patch failed (${resp.status()}): ${await resp.text()}`); +} + +async function storedEffort(fixture: SuiteFixture, request: APIRequestContext, slug: string): Promise { + const resp = await request.get(`/api/v1/workspaces/${fixture.workspaceSlug}/items/${slug}`, { + headers: authJson(fixture), + }); + const body = (await resp.json()) as { fields: string | Record }; + const fields = typeof body.fields === 'string' ? JSON.parse(body.fields) : body.fields; + return (fields as Record).effort; +} + +test.describe('a field value whose shape does not match its type (BUG-3052 unit 2)', () => { + test.setTimeout(90_000); + test.beforeEach(({}, testInfo) => { + test.skip(testInfo.project.name !== 'desktop-chromium', 'one project is enough for a data-shape concern'); + }); + + test('is marked on the item that holds it, not on an item where the field is unset, and Replace writes a number', async ({ + page, + fixture, + request, + }) => { + const stamp = Date.now(); + const coll = await createCollection(fixture, request, `Efforts ${stamp}`); + try { + await setSchema(fixture, request, coll.slug, { fields: [{ key: 'effort', label: 'Effort', type: 'text' }] }); + const texty = await createItem(fixture, request, coll.slug, `Texty ${stamp}`, { effort: '5' }); + const unset = await createItem(fixture, request, coll.slug, `Unset ${stamp}`, {}); + // The retype rewrites nothing: `effort` is still the STRING "5". + await setSchema(fixture, request, coll.slug, { fields: [{ key: 'effort', label: 'Effort', type: 'number' }] }); + + await browserLogin(page); + const base = `/${fixture.adminUsername}/${fixture.workspaceSlug}/${coll.slug}`; + + // Unset first: an empty number field is NOT a mismatch. + await page.goto(`${base}/${unset.slug}`); + await expect(page.locator('.number-input').first()).toBeVisible(); + await expect(page.locator('.field-mismatch')).toHaveCount(0); + + await page.goto(`${base}/${texty.slug}`); + const marker = page.locator('.field-mismatch'); + await expect(marker).toBeVisible(); + await expect(marker).toContainText('"5"'); + await expect(marker).toContainText("Doesn't match the field type (number)"); + + await marker.getByRole('button', { name: 'Replace' }).click(); + const input = page.locator('.number-input').first(); + await expect(input).toHaveValue(''); + await input.fill('3'); + await input.blur(); + await expect.poll(() => storedEffort(fixture, request, texty.slug)).toBe(3); + await expect(page.locator('.field-mismatch')).toHaveCount(0); + } finally { + await deleteCollection(fixture, request, coll.slug); + } + }); +}); diff --git a/web/src/lib/components/collections/TableView.listValue.svelte.test.ts b/web/src/lib/components/collections/TableView.listValue.svelte.test.ts index 3e82e84f3..e94f744ed 100644 --- a/web/src/lib/components/collections/TableView.listValue.svelte.test.ts +++ b/web/src/lib/components/collections/TableView.listValue.svelte.test.ts @@ -377,3 +377,30 @@ describe('a text cell holding a value String() cannot convert (BUG-3052)', () => expect(screen.container.textContent).toContain('plain'); }); }); + +// BUG-3052 unit 2: a cell whose value does not match its field's declared type +// shows the raw stored text, marked, instead of text that reads as well-typed. +describe('a cell whose value does not match its field type (BUG-3052 unit 2)', () => { + it('shows the raw text, marked, and leaves well-typed cells alone', () => { + const coll = collection([ + { key: 'effort', label: 'Effort', type: 'number' }, + { key: 'note', label: 'Note', type: 'text' }, + ]); + const screen = render(TableView, { + props: { + items: [item('car-1', { effort: '5', note: { a: 1 } }), item('car-2', { effort: 3, note: 'plain' })], + collection: coll, + } as never, + }); + const marked = [...screen.container.querySelectorAll('.cell-mismatch')]; + expect(marked.map((el) => el.getAttribute('title'))).toEqual([ + "Doesn't match the field type (number)", + "Doesn't match the field type (text)", + ]); + // `"5"` is visibly not the number 5, and an object is not `[object Object]`. + expect(marked[0].textContent).toContain('"5"'); + expect(marked[1].textContent).toContain('{"a":1}'); + expect(screen.container.textContent).not.toContain('[object Object]'); + expect(screen.container.textContent).toContain('plain'); + }); +}); diff --git a/web/src/lib/components/collections/TableView.svelte b/web/src/lib/components/collections/TableView.svelte index dcedebd98..df3460cc4 100644 --- a/web/src/lib/components/collections/TableView.svelte +++ b/web/src/lib/components/collections/TableView.svelte @@ -1,5 +1,5 @@