From dc7b9ceeecb7ad88b91a83528bf7b30264d7ad3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 14:25:15 +0000 Subject: [PATCH] fix(detail): enrich inline-edit fields with min/max/step; add #2572 live e2e (#2572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live dogfood follow-up to the #2572 polish (PR #2604): DetailSection and HeaderHighlight enrich the field they hand InlineFieldInput from an explicit whitelist of objectSchema keys, and that list dropped the numeric range/step constraints — so a currency field declaring min: 0 rendered its number input with no min/max/step. Both enrichments now pass min/max/step through. Also adds e2e/live/inline-edit-polish-2572.spec.ts, a live spec driving the whole #2572 polish set against the real showcase stack (expanded-lookup passthrough with zero hydration fetches, currency min/step, header Edit CTA disable, Esc/Ctrl+Enter, and the approval-lock preflight via the showcase_budget_approval flow). Verified green end-to-end. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01JQjNZDdqmz2QHGQndpjZrR --- ...ine-edit-numeric-constraints-enrichment.md | 12 ++ e2e/live/inline-edit-polish-2572.spec.ts | 168 ++++++++++++++++++ packages/plugin-detail/src/DetailSection.tsx | 5 + .../plugin-detail/src/HeaderHighlight.tsx | 5 + .../DetailSection.inlineEdit.test.tsx | 24 +++ 5 files changed, 214 insertions(+) create mode 100644 .changeset/inline-edit-numeric-constraints-enrichment.md create mode 100644 e2e/live/inline-edit-polish-2572.spec.ts diff --git a/.changeset/inline-edit-numeric-constraints-enrichment.md b/.changeset/inline-edit-numeric-constraints-enrichment.md new file mode 100644 index 000000000..50dea668c --- /dev/null +++ b/.changeset/inline-edit-numeric-constraints-enrichment.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-detail': patch +--- + +Inline-edit field enrichment passes `min`/`max`/`step` through to the numeric +editors (objectui#2572 live dogfood find). Both `DetailSection` and +`HeaderHighlight` copy an explicit whitelist of objectSchema keys into the +enriched field they hand `InlineFieldInput`; the numeric range/step +constraints were missing from that list, so a currency field declaring +`min: 0` rendered a number input with no range affordance. Adds a live e2e +spec (`e2e/live/inline-edit-polish-2572.spec.ts`) driving the whole #2572 +polish set against the real showcase stack. diff --git a/e2e/live/inline-edit-polish-2572.spec.ts b/e2e/live/inline-edit-polish-2572.spec.ts new file mode 100644 index 000000000..c335ede41 --- /dev/null +++ b/e2e/live/inline-edit-polish-2572.spec.ts @@ -0,0 +1,168 @@ +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; + +/** + * Live dogfood verification for the record-level inline edit polish + * (objectui#2572 / PR #2604) against the REAL showcase stack: + * + * 1. $expand-ed lookup values pass through to the picker — the Account chip + * shows "Northwind" with ZERO hydration findOne fetches; + * 2. approval-lock preflight — saving a budget change fires + * `showcase_budget_approval`, and the re-read lock state hides the inline + * edit affordances (enter() no-ops); + * 3. currency edits in a real number input carrying metadata min/step; + * 4. the header Edit CTA is disabled while the inline session is live; + * 5. Esc cancels / Ctrl+Enter saves the shared session. + * + * Prereqs: showcase backend + console dev server (see playwright.live.config.ts; + * override via LIVE_APP_URL / LIVE_API_URL). The budget mutation intentionally + * locks the record — run against a throwaway database. + */ + +const APP = process.env.SHOWCASE_APP || 'com.example.showcase'; +const API = process.env.LIVE_API_URL || 'http://localhost:3000'; + +// Remote sandbox: the pinned headless-shell build isn't installed; use the +// preinstalled full Chromium instead of downloading (per environment policy). +if (process.env.PW_CHROMIUM_PATH) { + test.use({ launchOptions: { executablePath: process.env.PW_CHROMIUM_PATH } }); +} + +const EDIT_HINT = 'Double-click to edit'; + +function authToken(): string { + const state = JSON.parse(readFileSync('e2e/live/.auth/state.json', 'utf8')); + const entry = state.origins?.[0]?.localStorage?.find((e: any) => e.name === 'auth-session-token'); + if (!entry) throw new Error('No auth-session-token in storage state'); + return entry.value; +} + +test('record inline-edit polish (#2572) — showcase Project end-to-end', async ({ page, request }) => { + test.setTimeout(240_000); + + // ── Resolve the "Website Relaunch" record id via the API (authoritative). ── + const token = authToken(); + const listRes = await request.get(`${API}/api/v1/data/showcase_project?$top=50`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(listRes.ok()).toBeTruthy(); + const listBody = await listRes.json(); + const rows: any[] = listBody?.records ?? listBody?.data ?? []; + const record = rows.find((r) => r.name === 'Website Relaunch'); + expect(record, 'seeded "Website Relaunch" project exists').toBeTruthy(); + const recordId = record.id ?? record._id; + + // ── Open the record page. ── + await page.goto(`/apps/${APP}/showcase_project/record/${recordId}`); + await expect(page.getByRole('heading', { name: /Website Relaunch/ }).first()).toBeVisible({ + timeout: 30_000, + }); + + const headerToolbar = page.getByRole('toolbar', { name: 'Page header actions' }); + const headerEdit = headerToolbar.getByRole('button', { name: 'Edit', exact: true }); + await expect(headerEdit, 'header Edit CTA enabled before any session').toBeEnabled(); + + // ── Item 1 instrumentation: count lookup hydration fetches from now on. ── + const accountHydrations: string[] = []; + page.on('request', (r) => { + if (/\/api\/v1\/data\/showcase_account\/[^/?]+/.test(r.url())) accountHydrations.push(r.url()); + }); + + // ── Enter the shared inline-edit session (double-click a details-body field). ── + const spentGroup = page + .locator('div.group') + .filter({ has: page.getByText('Spent', { exact: true }) }) + .last(); + await spentGroup.locator(`[title="${EDIT_HINT}"]`).dblclick(); + + const saveBtn = page.getByRole('button', { name: 'Save', exact: true }); + await expect(saveBtn, 'save bar appears once the session is live').toBeVisible(); + + // ── Item 3: budget edits in a real numeric input with metadata min/step. ── + const budgetInput = page.locator('input[type="number"][min="0"][step="0.01"]').first(); + await expect(budgetInput, 'currency renders as number input with min=0 step=0.01').toBeVisible(); + await expect(budgetInput).toHaveValue('150000'); + + // ── Item 1: the Account picker shows the expanded record's name with no re-fetch. ── + await expect(page.getByText('Northwind', { exact: true }).first()).toBeVisible(); + expect(accountHydrations, 'no findOne hydration fetch for the expanded account value').toEqual([]); + + // ── Item 4: header Edit CTA disabled while the session is live. ── + await expect(headerEdit, 'header Edit CTA disabled during inline session').toBeDisabled(); + + // ── Item 5a: Esc cancels the session (no popover open). ── + await page.keyboard.press('Escape'); + await expect(saveBtn, 'Esc tears the session down').toBeHidden(); + await expect(headerEdit, 'header Edit CTA re-enabled after cancel').toBeEnabled(); + + // ── Item 5b: re-enter, change the budget, Ctrl+Enter commits ONE atomic PATCH. ── + const budgetGroup = page + .locator('div.group') + .filter({ has: page.getByText('Budget', { exact: true }) }) + .last(); + await budgetGroup.locator(`[title="${EDIT_HINT}"]`).dblclick(); + await expect(saveBtn).toBeVisible(); + const budgetInput2 = page.locator('input[type="number"][min="0"][step="0.01"]').first(); + await budgetInput2.fill('160000'); + const patchPromise = page.waitForResponse( + (res) => + res.request().method() === 'PATCH' && + res.url().includes(`/data/showcase_project/`) && + res.ok(), + { timeout: 20_000 }, + ); + await page.keyboard.press('Control+Enter'); + const patchRes = await patchPromise; + const patchBody = patchRes.request().postDataJSON?.() ?? {}; + expect(Number(patchBody.budget), 'PATCH carries the edited budget').toBe(160000); + expect(patchBody, 'PATCH carries ONLY the edited key').not.toHaveProperty('spent'); + await expect(saveBtn, 'session ends after a successful save').toBeHidden({ timeout: 15_000 }); + + // ── Item 2: the budget change (>100k, changed) fires showcase_budget_approval → + // a pending request opens and the re-read approval state locks the session: + // every pencil/double-click affordance disappears. (This backend does NOT + // materialize `approval_status` on the record — the lock signal is the + // pending request from the approvals API, which is exactly the second + // branch of RecordDetailView's `approvalLocked`.) ── + const authHeader = { Authorization: `Bearer ${token}` }; + await expect + .poll( + async () => { + const res = await request.get( + `${API}/api/v1/approvals/requests?object=showcase_project&recordId=${recordId}`, + { headers: authHeader }, + ); + const body = await res.json(); + return (body?.data ?? []).some((r: any) => r.status === 'pending'); + }, + { timeout: 60_000, intervals: [2_000] }, + ) + .toBe(true); + + // Fresh mount reads the pending request → locked → zero inline-edit + // affordances anywhere on the page… + await expect + .poll( + async () => { + await page.reload(); + await page + .getByRole('heading', { name: /Website Relaunch/ }) + .first() + .waitFor({ timeout: 20_000 }); + // Editable-record pages render the hint; wait a beat for hydration + // so an empty count means "locked", not "not yet rendered". + await page.waitForTimeout(1_500); + return page.locator(`[title="${EDIT_HINT}"]`).count(); + }, + { timeout: 60_000, intervals: [2_000] }, + ) + .toBe(0); + + // …and a double-click on the (former) budget field must NOT open a session. + const lockedBudgetGroup = page + .locator('div.group') + .filter({ has: page.getByText('Budget', { exact: true }) }) + .last(); + await lockedBudgetGroup.dblclick(); + await expect(saveBtn).toBeHidden(); +}); diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index dd6c07ef9..247090eee 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -219,6 +219,11 @@ export const DetailSection: React.FC = ({ if (objectDefField.currency && !enrichedField.currency) enrichedField.currency = objectDefField.currency; if (objectDefField.precision !== undefined && enrichedField.precision === undefined) enrichedField.precision = objectDefField.precision; if ((objectDefField as any).scale !== undefined && (enrichedField as any).scale === undefined) (enrichedField as any).scale = (objectDefField as any).scale; + // Numeric range/step constraints (objectui#2572 item 3) — without these + // the metadata's `min: 0` never reaches the numeric edit widgets. + if ((objectDefField as any).min !== undefined && (enrichedField as any).min === undefined) (enrichedField as any).min = (objectDefField as any).min; + if ((objectDefField as any).max !== undefined && (enrichedField as any).max === undefined) (enrichedField as any).max = (objectDefField as any).max; + if ((objectDefField as any).step !== undefined && (enrichedField as any).step === undefined) (enrichedField as any).step = (objectDefField as any).step; if (objectDefField.format && !enrichedField.format) enrichedField.format = objectDefField.format; // Per-field widget override (ADR-0056 P2) — carry it from the object // metadata so the inline-edit widget branch (and read cell) can honor a diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index e34aa9322..f355491d0 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -99,6 +99,11 @@ export const HeaderHighlight: React.FC = ({ ...(objectDefField?.currencyConfig && { currencyConfig: objectDefField.currencyConfig }), ...(objectDefField?.precision !== undefined && { precision: objectDefField.precision }), ...((objectDefField as any)?.scale !== undefined && { scale: (objectDefField as any).scale }), + // Numeric range/step constraints (objectui#2572 item 3) — keep in + // sync with DetailSection's enrichment so both editors honor them. + ...((objectDefField as any)?.min !== undefined && { min: (objectDefField as any).min }), + ...((objectDefField as any)?.max !== undefined && { max: (objectDefField as any).max }), + ...((objectDefField as any)?.step !== undefined && { step: (objectDefField as any).step }), ...(objectDefField?.format && { format: objectDefField.format }), ...(refTarget && { reference_to: refTarget }), ...((objectDefField as any)?.reference_field && { diff --git a/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx b/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx index 35942705a..c73636a3b 100644 --- a/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx +++ b/packages/plugin-detail/src/__tests__/DetailSection.inlineEdit.test.tsx @@ -68,3 +68,27 @@ describe('DetailSection inline-edit reference fields', () => { expect(screen.getByDisplayValue('Hello')).toBeInTheDocument(); }); }); + +/** + * objectui#2572 (live dogfood find): the field enrichment copied an explicit + * whitelist that dropped `min`/`max`/`step`, so a currency field declaring + * `min: 0` rendered a numeric editor with no range constraints. The enrichment + * must pass them through to the edit widget. + */ +describe('DetailSection inline-edit numeric constraints', () => { + it('passes metadata min/max/step through to the numeric editor', () => { + render( + , + ); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('min', '0'); + expect(input).toHaveAttribute('max', '500000'); + expect(input).toHaveAttribute('step', '0.01'); + expect(input).toHaveValue(150000); + }); +});