From d3c874f95e419481e184706ee41d3894b648b55f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:22:01 +0000 Subject: [PATCH 01/11] fix(plugin-grid): an authored column label wins over the field label (objectui#12110 item 1) `ListColumnSchema.label` is the spec's "Display label override", but the grid handed it to the translated field-label lookup as that lookup's fallback, so any bundle translating the field replaced the override. The authored label is now the header as written; the field-label lookup runs only for a column with no override. Pin: ObjectGrid.authoredColumnLabel-12110 (en and zh, plus the no-override control). Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- packages/plugin-grid/src/ObjectGrid.tsx | 24 +++-- ...ectGrid.authoredColumnLabel-12110.test.tsx | 99 +++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 packages/plugin-grid/src/__tests__/ObjectGrid.authoredColumnLabel-12110.test.tsx diff --git a/packages/plugin-grid/src/ObjectGrid.tsx b/packages/plugin-grid/src/ObjectGrid.tsx index 86e2d8f944..354a18c14b 100644 --- a/packages/plugin-grid/src/ObjectGrid.tsx +++ b/packages/plugin-grid/src/ObjectGrid.tsx @@ -3579,14 +3579,24 @@ export const ObjectGrid: React.FC = ({ .filter((col) => resolvesToDataColumn(col)) .filter((col) => passesFieldGate(col)) .map((col, colIndex) => { - // Fall back to the SCHEMA FIELD's label before prettifying the machine - // name — otherwise a column declared as bare { field } shows an English - // name-derived header (e.g. "Request title") even when the field has a - // localized label (e.g. "申请标题") on a non-English app. - const rawHeader = resolveColumnLabel(col.label) - || resolveColumnLabel(objectFields?.[col.field]?.label) + // The column's own `label` is the spec's "Display label override" + // (`ListColumnSchema`), so when the author wrote one it IS the + // header, and the field-label lookup is not consulted + // (objectui#12110 item 1). It used to be handed to that lookup as + // its FALLBACK, so any bundle translating the field — which a + // deployed app's bundle does for every field — replaced the + // override with the field's label. + // + // With no override, fall back to the SCHEMA FIELD's label before + // prettifying the machine name — otherwise a column declared as bare + // { field } shows an English name-derived header (e.g. "Request + // title") even when the field has a localized label (e.g. "申请标题") + // on a non-English app. + const authoredHeader = resolveColumnLabel(col.label); + const rawHeader = resolveColumnLabel(objectFields?.[col.field]?.label) || col.field.charAt(0).toUpperCase() + col.field.slice(1).replace(/_/g, ' '); - const header = schema.objectName ? resolveFieldLabel(schema.objectName, col.field, rawHeader) : rawHeader; + const header = authoredHeader + || (schema.objectName ? resolveFieldLabel(schema.objectName, col.field, rawHeader) : rawHeader); // Build custom cell renderer based on column configuration let cellRenderer: ((value: any, row: any) => React.ReactNode) | undefined; diff --git a/packages/plugin-grid/src/__tests__/ObjectGrid.authoredColumnLabel-12110.test.tsx b/packages/plugin-grid/src/__tests__/ObjectGrid.authoredColumnLabel-12110.test.tsx new file mode 100644 index 0000000000..8a2f2417da --- /dev/null +++ b/packages/plugin-grid/src/__tests__/ObjectGrid.authoredColumnLabel-12110.test.tsx @@ -0,0 +1,99 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A view column's authored `label` wins over the field's label + * (objectui#12110 item 1, finding A-02 of the HotCRM browser pass). + * + * `ListColumnSchema.label` is, in `@objectstack/spec`'s own words, the + * "Display label override". The grid built the header as + * `col.label || field.label || prettified name` and then handed THAT to the + * translated field-label lookup as its fallback — so whenever the app bundle + * carried a translation for the field, the bundle's FIELD label replaced the + * column's override. A deployed app's bundle carries one for every field, so + * on a real deployment the override never showed. + * + * Every case renders under a REAL `I18nProvider`, the field's translation an + * app bundle key, the way a deployed app's bundle carries it. + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { registerAllFields } from '@object-ui/fields'; +import { ActionProvider } from '@object-ui/react'; +import { ObjectGrid } from '../ObjectGrid'; + +registerAllFields(); + +afterEach(() => cleanup()); + +const OBJECT = 'crm_account'; +const OBJECT_FIELDS = { + name: { name: 'name', type: 'text', label: 'Account Name' }, + industry: { name: 'industry', type: 'text', label: 'Industry' }, +}; +const ROWS = [{ id: 'a1', name: 'Fabrikam', industry: 'Retail' }]; + +const dataSource = { + find: async () => ({ data: ROWS, total: ROWS.length }), + findOne: async () => null, + create: async () => ({}), + update: async () => ({}), + delete: async () => true, + getObjectSchema: async () => ({ name: OBJECT, fields: OBJECT_FIELDS }), +}; + +/** The app bundle translates BOTH fields, in both languages. */ +const RESOURCES = { + en: { testapp: { fields: { [OBJECT]: { name: 'Account Name', industry: 'Industry' } }, fieldOptions: {} } }, + zh: { testapp: { fields: { [OBJECT]: { name: '客户名称', industry: '行业' } }, fieldOptions: {} } }, +}; + +function mount(language: string) { + return render( + + + + + , + ); +} + +const headerTexts = () => screen.getAllByRole('columnheader').map((h) => (h.textContent ?? '').trim()); + +describe('an authored column label wins over the field label (objectui#12110 item 1)', () => { + for (const language of ['en', 'zh']) { + it(`${language}: the override "Company" heads the column, not the bundle's field label`, async () => { + mount(language); + await waitFor(() => expect(screen.getByText('Fabrikam')).toBeInTheDocument()); + const headers = headerTexts(); + expect(headers.some((h) => h.includes('Company')), `headers: ${JSON.stringify(headers)}`).toBe(true); + const fieldLabel = RESOURCES[language as 'en' | 'zh'].testapp.fields[OBJECT].name; + expect(headers.some((h) => h.includes(fieldLabel)), `headers: ${JSON.stringify(headers)}`).toBe(false); + }); + } + + it('CONTROL: a column with no override still takes the translated field label', async () => { + mount('zh'); + await waitFor(() => expect(screen.getByText('Fabrikam')).toBeInTheDocument()); + expect(headerTexts().some((h) => h.includes('行业'))).toBe(true); + }); +}); From 060df233185a11868ede056244839e97243d887a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:00 +0000 Subject: [PATCH 02/11] docs(plugin-grid): a column's authored label is the header as written (objectui#12110 item 1) Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- content/docs/plugins/plugin-grid.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/plugins/plugin-grid.mdx b/content/docs/plugins/plugin-grid.mdx index 16cc4268c2..df0c1d2a3f 100644 --- a/content/docs/plugins/plugin-grid.mdx +++ b/content/docs/plugins/plugin-grid.mdx @@ -181,7 +181,7 @@ re-exported from `@object-ui/types`; it is the type of `ObjectGridSchema`'s | Key | Type | Meaning | | --- | --- | --- | | `field` | `string` (**required**) | The field this column reads. There is no `accessorKey`. | -| `label` | `string \| Record` | Header text, or an inline locale map. There is no `header`. | +| `label` | `string \| Record` | Header text, or an inline locale map. There is no `header`. An authored label is the header as written; a column without one takes the field's (translated) label. | | `width` | `number` | Column width in pixels. | | `align` | `'left' \| 'center' \| 'right'` | Cell alignment. | | `hidden` | `boolean` | Hidden by default, revealable in the column chooser. | From 7ca5520b6f712b2f5a1c8297c2d412fe970f4c90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:00 +0000 Subject: [PATCH 03/11] fix(components,fields): an unregistered widget degrades to the type renderer, and a rating cell draws stars (objectui#12110 item 2) The spec's FieldSchema.widget promises an unregistered widget "Degrades to the `type` renderer when unregistered". The form renderer took the hint whether or not anything could draw it, so a `rating` field with `widget: 'star_rating'` fell to the default text box. Each hint (form config, then field metadata) is now taken only when it can render: a registered field widget, a built-in control, or one of the default arm's own native email/password inputs and the secret refusal (kept so a secret-declaring hint never degrades to clear text). Otherwise `type`. The cascade-clear effect reads the same resolution. The `rating` cell drew a bare number; it now draws the star row RatingField's read-only branch draws, out of the field's `max` (5 by default). Module-private, reached through getCellRenderer('rating'). Two plugin-form tests reached the old fallback through a widget nothing registers; they now reach the same props spread through the built-in `input` control, and ObjectForm's comment on that route says so. Pins: form-unregistered-widget-type-fallback-12110, objectFormUnregisteredWidget-12110, cellRenderers.ratingCell-12110. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- content/docs/plugins/plugin-form.mdx | 2 +- ...stered-widget-type-fallback-12110.test.tsx | 81 +++++++++++++++++++ .../components/src/renderers/form/form.tsx | 61 +++++++++++++- .../cellRenderers.ratingCell-12110.test.tsx | 60 ++++++++++++++ packages/fields/src/index.tsx | 46 ++++++++++- packages/plugin-form/src/ObjectForm.tsx | 14 ++-- .../objectFormNumericStep-9574.test.tsx | 27 ++++--- ...bjectFormUnregisteredWidget-12110.test.tsx | 72 +++++++++++++++++ .../sectionEntryOverrides-10475.test.tsx | 10 ++- 9 files changed, 344 insertions(+), 29 deletions(-) create mode 100644 packages/components/src/renderers/form/__tests__/form-unregistered-widget-type-fallback-12110.test.tsx create mode 100644 packages/fields/src/__tests__/cellRenderers.ratingCell-12110.test.tsx create mode 100644 packages/plugin-form/src/__tests__/objectFormUnregisteredWidget-12110.test.tsx diff --git a/content/docs/plugins/plugin-form.mdx b/content/docs/plugins/plugin-form.mdx index 06a605e19b..e79463fe7a 100644 --- a/content/docs/plugins/plugin-form.mdx +++ b/content/docs/plugins/plugin-form.mdx @@ -84,7 +84,7 @@ has **23 declared keys**, and `name` is the only **required** one: `type` and | `description` | `string` | help text rendered under the control | | `type` | `string` | optional, defaults to `'input'`. Built-ins: `input`, `textarea`, `checkbox`, `switch`, `select`; any other value resolves through the registry (`field:` first, then the bare name) | | `inputType` | `string` | HTML input type for `type: 'input'` — `'email'`, `'password'`, `'tel'`, … | -| `widget` | `string` | widget override; wins over `type` (spec `FormField.widget`) | +| `widget` | `string` | widget override; wins over `type` when it can render — a registered `field:`, a built-in control, or a native email / password input. Any other name falls back to `type`, as the spec's `FieldSchema.widget` says ("Degrades to the `type` renderer when unregistered") | | `placeholder` | `string` | placeholder text | | `required` | `boolean` | the presence rule. `validation.required` does **not** make a field required — it only supplies the message | | `disabled` | `boolean` | not interactive, muted | diff --git a/packages/components/src/renderers/form/__tests__/form-unregistered-widget-type-fallback-12110.test.tsx b/packages/components/src/renderers/form/__tests__/form-unregistered-widget-type-fallback-12110.test.tsx new file mode 100644 index 0000000000..6270a21f56 --- /dev/null +++ b/packages/components/src/renderers/form/__tests__/form-unregistered-widget-type-fallback-12110.test.tsx @@ -0,0 +1,81 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * An unregistered `widget` degrades to the `type` renderer (objectui#12110 + * item 2, finding A-03 of the HotCRM browser pass). + * + * `@objectstack/spec`'s `FieldSchema.widget` promises it in its own + * description: "names a registered field component (resolved as + * `field:`) … Degrades to the `type` renderer when unregistered." The + * form renderer resolved `widget || type` and stopped there, so a `rating` + * field declaring `widget: 'star_rating'` — a name nothing registers — fell + * past the registered `field:rating` widget to the default text input. + * + * Probes stand in for the field widgets (the fields package owns the real ones; + * components tests never load it), registered under the keys the renderer + * resolves. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import '../../../renderers'; + +function RatingProbe(props: any) { + return
{String(props.value ?? '')}
; +} +function StarsProbe() { + return
; +} + +beforeAll(() => { + ComponentRegistry.register('field:rating', RatingProbe, { namespace: 'test' }); + ComponentRegistry.register('field:registered_stars', StarsProbe, { namespace: 'test' }); +}); + +function renderForm(fields: any[]) { + const Form = ComponentRegistry.get('form')!; + return render( +
, + ); +} + +describe('form renderer — an unregistered widget degrades to the type renderer (objectui#12110 item 2)', () => { + it('THE DEFECT — `widget: star_rating` on a rating field renders the rating widget, not a text box', () => { + const { container } = renderForm([ + { name: 'score', label: 'Score', type: 'field:rating', widget: 'star_rating' }, + ]); + expect(screen.getByTestId('rating-probe')).toBeTruthy(); + expect(container.querySelector('input[name="score"]')).toBeNull(); + }); + + it('the same degrade when the hint rides on the field metadata, not the form config', () => { + const { container } = renderForm([ + { name: 'score', label: 'Score', type: 'field:rating', field: { type: 'rating', widget: 'star_rating' } }, + ]); + expect(screen.getByTestId('rating-probe')).toBeTruthy(); + expect(container.querySelector('input[name="score"]')).toBeNull(); + }); + + it('CONTROL: a REGISTERED widget still wins over the type', () => { + renderForm([ + { name: 'score', label: 'Score', type: 'field:rating', widget: 'registered_stars' }, + ]); + expect(screen.getByTestId('stars-probe')).toBeTruthy(); + expect(screen.queryByTestId('rating-probe')).toBeNull(); + }); + + it('CONTROL: a built-in control name is a renderable widget and still wins', () => { + const { container } = renderForm([ + { name: 'score', label: 'Score', type: 'field:rating', widget: 'textarea' }, + ]); + expect(container.querySelector('textarea[name="score"]')).not.toBeNull(); + expect(screen.queryByTestId('rating-probe')).toBeNull(); + }); +}); diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 00d96b1236..2ea627ccb0 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -427,9 +427,57 @@ const BOOLEAN_WIDGET_TYPES = new Set([ /** * Which control a field row will render as — the same precedence * `resolvedType` applies further down (explicit form-config `widget`, then the - * resolved field metadata's own widget hint, then the bare `type`). + * resolved field metadata's own widget hint, then the bare `type`), each + * widget hint taken only when it can render (see {@link resolveRenderableWidget}). */ -const resolveWidgetType = (f: any): string => f?.widget || f?.field?.widget || f?.type; +const resolveWidgetType = (f: any): string => + resolveRenderableWidget(f?.type, f?.widget, f?.field?.widget); + +/** + * The first widget hint that can render, else `type` — objectui#12110 item 2. + * + * `@objectstack/spec`'s `FieldSchema.widget` names "a registered field + * component (resolved as `field:`)" and promises that it "Degrades to + * the `type` renderer when unregistered". This renderer used to take the hint + * whether or not anything could draw it, so a `rating` field declaring + * `widget: 'star_rating'` — a name nothing registers — fell past the registered + * `field:rating` widget to the default branch's plain text box (finding A-03 + * of the HotCRM browser pass). + * + * The hints are tried in precedence order (the form config's, then the field + * metadata's), so an unrenderable form-level override falls back to the + * object's own hint before it falls back to the type. + */ +function resolveRenderableWidget(type: string, ...hints: unknown[]): string { + for (const hint of hints) { + if (typeof hint === 'string' && hint !== '' && widgetRendersAsItself(hint)) return hint; + } + return type; +} + +/** + * Does `renderFieldComponent` have a deliberate rendering for this widget NAME? + * + * - a built-in control (`input`, `textarea`, …) — a `switch` arm; + * - a registered field widget, resolved exactly as `renderFieldComponent` + * resolves it ({@link resolvesToRegisteredFieldWidget}); + * - one of the `default` arm's own declared inputs: a native email / password + * input ({@link NATIVE_INPUT_FIELD_TYPES}) or the secret refusal + * ({@link SECRET_FIELD_TYPES}). These are kept on purpose: degrading a + * secret-declaring hint to its field's `type` could put the value on screen + * as clear text, the one degrade objectui#5322 rules out. + * + * Anything else names a component nothing draws, and the spec's degrade + * applies. + */ +function widgetRendersAsItself(widget: string): boolean { + if (BUILTIN_FIELD_TYPES.has(widget)) return true; + if (resolvesToRegisteredFieldWidget(widget)) return true; + const declared = normalizeFieldType(widget); + return hasOwn(NATIVE_INPUT_FIELD_TYPES, widget) + || hasOwn(NATIVE_INPUT_FIELD_TYPES, declared) + || SECRET_FIELD_TYPES.has(declared); +} const BUILTIN_FIELD_TYPES = new Set(['input', 'textarea', 'checkbox', 'switch', 'select']); @@ -2154,7 +2202,9 @@ ComponentRegistry.register('form', for (const f of fields as FormFieldConfig[]) { const name = f?.name; if (!name) continue; - const resolvedType = (f as any).widget || f.type; + // The render path's own resolution (objectui#12110 item 2), so a hint + // that degrades to its `type` there is judged as that type here too. + const resolvedType = resolveWidgetType(f); // `field:select` and `select` name the SAME field kind, so this clear // must recognise both spellings — the object-form path // (`mapFieldTypeToFormType`) emits the prefixed id, hand-written SDUI @@ -3184,8 +3234,11 @@ ComponentRegistry.register('form', // A built-in type declared `multiple` is routed to its multi-value // widget HERE, before any reader of `resolvedType` runs // (objectui#11116) — see `applyDeclaredArity`. + // + // A hint nothing can draw degrades to `type`, as the spec promises + // (objectui#12110 item 2) — see `resolveRenderableWidget`. const resolvedType = applyDeclaredArity( - widget || fieldProps.field?.widget || type, + resolveRenderableWidget(type, widget, fieldProps.field?.widget), fieldProps.multiple, ); diff --git a/packages/fields/src/__tests__/cellRenderers.ratingCell-12110.test.tsx b/packages/fields/src/__tests__/cellRenderers.ratingCell-12110.test.tsx new file mode 100644 index 0000000000..33fc10ff5d --- /dev/null +++ b/packages/fields/src/__tests__/cellRenderers.ratingCell-12110.test.tsx @@ -0,0 +1,60 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A `rating` cell draws stars (objectui#12110 item 2, finding A-03 of the + * HotCRM browser pass). + * + * The standard cell table mapped `rating` to `NumberCellRenderer`, so a list + * column, a detail row and a related list showed a bare `4` for a value the + * form draws as four of five stars. The cell now draws the same star row as + * `RatingField`'s read-only branch, out of the field's declared `max` (5 when + * none is declared — `Field.rating`'s own default), with the value and the + * scale as the accessible text. + */ +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getCellRenderer } from '../index'; + +afterEach(() => cleanup()); + +function renderRating(value: unknown, field: Record = {}) { + const Cell = getCellRenderer('rating'); + return render(); +} + +const stars = (c: HTMLElement) => Array.from(c.querySelectorAll('svg.lucide-star')); +const filled = (c: HTMLElement) => stars(c).filter((s) => s.getAttribute('class')?.includes('fill-')); + +describe('the `rating` cell draws stars (objectui#12110 item 2)', () => { + it('THE DEFECT — 4 of a declared 5 is four filled stars of five, not the digit 4', () => { + const { container } = renderRating(4, { max: 5 }); + expect(stars(container)).toHaveLength(5); + expect(filled(container)).toHaveLength(4); + // The value and the scale stay readable to assistive tech. + expect(container.textContent).toContain('4 / 5'); + }); + + it('reads the declared `max`, and 5 when none is declared', () => { + const ten = renderRating(7, { max: 10 }); + expect(stars(ten.container)).toHaveLength(10); + expect(filled(ten.container)).toHaveLength(7); + cleanup(); + const undeclared = renderRating(2); + expect(stars(undeclared.container)).toHaveLength(5); + expect(filled(undeclared.container)).toHaveLength(2); + }); + + it('CONTROL: a value that is not a number keeps the text face, and no star', () => { + const { container } = renderRating('n/a'); + expect(stars(container)).toHaveLength(0); + expect(container.textContent).toBe('n/a'); + }); +}); diff --git a/packages/fields/src/index.tsx b/packages/fields/src/index.tsx index 2d040cfcd9..e9a1bbb32c 100644 --- a/packages/fields/src/index.tsx +++ b/packages/fields/src/index.tsx @@ -17,7 +17,7 @@ import { ComponentRegistry, percentDisplayValue, type PercentScale, getRecordDis import { valueSchemaFor, resolveFieldScale } from '@objectstack/spec/data'; import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n'; import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components'; -import { Check, Copy, Phone as PhoneIcon, MapPin, CircleQuestionMark } from 'lucide-react'; +import { Check, Copy, Phone as PhoneIcon, MapPin, CircleQuestionMark, Star } from 'lucide-react'; import { useObjectTranslation } from '@object-ui/react'; import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react'; import { useRelatedRecordActions } from '@object-ui/react'; @@ -710,6 +710,48 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R return {formatted}; } +/** + * Rating field cell renderer — the star row `RatingField`'s read-only branch + * draws (objectui#12110 item 2, finding A-03 of the HotCRM browser pass). + * + * `rating` used to share `NumberCellRenderer`, so a list column, a detail row + * and a related list printed a bare `4` for a value the form draws as four of + * five stars. The scale is the field's declared `max`, read the way + * `RatingField` reads it, with `Field.rating`'s own default of 5 when none is + * declared. The value and the scale are the cell's accessible text. + * + * Module-private on purpose: reached through `getCellRenderer('rating')`, the + * one door every cell host already uses, so the package's export surface does + * not grow. + */ +function RatingCellRenderer({ value, field }: CellRendererProps): React.ReactElement { + const safe = coerceToSafeValue(value); + // The same floor as `NumberCellRenderer` (objectui#8490): `[]` and `''` + // coerce to `''`, and `Number('')` is a zero the record never held. + if (isBlankCellText(safe)) return ; + const num = Number(safe); + // Not a number: the text face the number cell gives it, and no star. + if (!Number.isFinite(num)) return {String(safe)}; + const declaredMax = (field as { max?: unknown } | undefined)?.max; + const max = typeof declaredMax === 'number' && declaredMax > 0 ? Math.floor(declaredMax) : 5; + const filled = Math.max(0, Math.min(max, Math.round(num))); + return ( + + {Array.from({ length: max }, (_, i) => ( + + ); +} + /** * Currency field cell renderer */ @@ -3766,7 +3808,7 @@ function buildStandardCellRendererMap(): Record`. Both halves are pinned by - // `objectFormNumericStep-9574.test.tsx`, which measures the two - // routes side by side. + // this key does reach is the form renderer's built-in `input` + // control — a field whose declared `widget` is `input` — where the + // leftover props are spread onto the ``. (A `widget` naming a + // component the app never registered used to reach a second such + // input; since objectui#12110 it degrades to the `type` renderer, + // as the spec's `widget` describes, i.e. to the registered widget.) + // Both halves are pinned by `objectFormNumericStep-9574.test.tsx`, + // which measures the two routes side by side. formField.step = typeof field.scale === 'number' ? Math.pow(10, -field.scale) : 'any'; } diff --git a/packages/plugin-form/src/__tests__/objectFormNumericStep-9574.test.tsx b/packages/plugin-form/src/__tests__/objectFormNumericStep-9574.test.tsx index b2eb53c250..c09110e724 100644 --- a/packages/plugin-form/src/__tests__/objectFormNumericStep-9574.test.tsx +++ b/packages/plugin-form/src/__tests__/objectFormNumericStep-9574.test.tsx @@ -27,10 +27,13 @@ * `toDomProps` whitelist does not forward `step`, so `NumberField` derives * its own from `scale`. Deleting the producer's assignment outright left * every rendered `` on this route byte-identical. - * - **Unregistered widget** (a field declaring a `widget` the app never - * registered): the renderer's fallback branch spreads the leftover field - * props straight onto the ``, so the producer's `step` IS the - * attribute the browser enforces. + * - **Built-in `input` control** (a field declaring `widget: 'input'`): the + * renderer's `input` branch spreads the leftover field props straight onto + * the ``, so the producer's `step` IS the attribute the browser + * enforces. Until objectui#12110 this block reached the same spread through + * a `widget` naming a component nothing registers; that name now degrades + * to the `type` renderer, as `@objectstack/spec`'s `FieldSchema.widget` + * describes, which is the registered route above. * * ⇒ the first block below is the load-bearing pin — it fails on the * `precision`-derived expression, where these three inputs all read @@ -49,17 +52,17 @@ import { ObjectForm } from '../ObjectForm'; * `decimal(10, 0)` and `decimal(10, 2)` — the two cases the card names — plus * a field that declares neither digit member. * - * The `widget` key on the first three names a component nothing registers, - * which is what routes them to the renderer's fallback branch (see the header). + * The `widget` key names the built-in `input` control, which is what routes + * them to the branch that spreads the producer's props (see the header). */ const FALLBACK_OBJECT = { name: 'invoice_line', fields: { - qty: { type: 'number', label: 'Qty', precision: 10, scale: 0, widget: 'nothing-registers-this' }, - amount: { type: 'number', label: 'Amount', precision: 10, scale: 2, widget: 'nothing-registers-this' }, - fee: { type: 'currency', label: 'Fee', precision: 10, scale: 2, widget: 'nothing-registers-this' }, - rate: { type: 'percent', label: 'Rate', precision: 10, scale: 2, widget: 'nothing-registers-this' }, - unscaled: { type: 'number', label: 'Unscaled', precision: 10, widget: 'nothing-registers-this' }, + qty: { type: 'number', label: 'Qty', precision: 10, scale: 0, widget: 'input' }, + amount: { type: 'number', label: 'Amount', precision: 10, scale: 2, widget: 'input' }, + fee: { type: 'currency', label: 'Fee', precision: 10, scale: 2, widget: 'input' }, + rate: { type: 'percent', label: 'Rate', precision: 10, scale: 2, widget: 'input' }, + unscaled: { type: 'number', label: 'Unscaled', precision: 10, widget: 'input' }, }, }; @@ -97,7 +100,7 @@ async function renderFields(objectSchema: unknown) { } describe('ObjectForm numeric step follows `scale` (objectui#9574)', () => { - it('hands the fallback input a step of 10^-scale — 1 for scale 0, 0.01 for scale 2', async () => { + it('hands the built-in input a step of 10^-scale — 1 for scale 0, 0.01 for scale 2', async () => { const input = await renderFields(FALLBACK_OBJECT); // `decimal(10, 0)`: ten total digits, ZERO decimal places ⇒ steps by 1. diff --git a/packages/plugin-form/src/__tests__/objectFormUnregisteredWidget-12110.test.tsx b/packages/plugin-form/src/__tests__/objectFormUnregisteredWidget-12110.test.tsx new file mode 100644 index 0000000000..abddc24677 --- /dev/null +++ b/packages/plugin-form/src/__tests__/objectFormUnregisteredWidget-12110.test.tsx @@ -0,0 +1,72 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The real door for objectui#12110 item 2 (finding A-03): an object field + * `{ type: 'rating', widget: 'star_rating' }` rendered by `ObjectForm`. + * + * `star_rating` is a name nothing registers. The spec's `FieldSchema.widget` + * says such a name "Degrades to the `type` renderer when unregistered", and + * the `type` renderer of a `rating` is the star widget the fields package + * registers as `field:rating`. The form drew a plain text box instead. + * + * Importing `ObjectForm` pulls `@object-ui/fields` in, whose module body + * registers every `field:*` widget — the ordinary route. + */ +import { describe, it, expect, vi } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { ObjectForm } from '../ObjectForm'; + +const makeDataSource = (objectSchema: unknown) => ({ + getObjectSchema: vi.fn().mockResolvedValue(objectSchema), + create: vi.fn(async (_o: string, d: Record) => ({ id: 'r1', ...d })), + update: vi.fn(), + findOne: vi.fn(), +}); + +function mount(objectSchema: unknown) { + return render( + , + ); +} + +describe('ObjectForm — `widget: star_rating` degrades to the rating widget (objectui#12110 item 2)', () => { + it('THE DEFECT — draws five stars, not a text box', async () => { + const { container } = mount({ + name: 'crm_survey', + fields: { + title: { type: 'text', label: 'Title' }, + score: { type: 'rating', label: 'Score', max: 5, widget: 'star_rating' }, + }, + }); + await waitFor(() => { + if (!container.querySelector('input[name="title"]')) throw new Error('form not ready'); + }); + await waitFor(() => expect(container.querySelectorAll('svg.lucide-star')).toHaveLength(5)); + expect(container.querySelector('input[name="score"]')).toBeNull(); + }); + + it('CONTROL: the same field without a widget hint draws the same five stars', async () => { + const { container } = mount({ + name: 'crm_survey', + fields: { + title: { type: 'text', label: 'Title' }, + score: { type: 'rating', label: 'Score', max: 5 }, + }, + }); + await waitFor(() => { + if (!container.querySelector('input[name="title"]')) throw new Error('form not ready'); + }); + await waitFor(() => expect(container.querySelectorAll('svg.lucide-star')).toHaveLength(5)); + }); +}); diff --git a/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx b/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx index af4e6dd287..31a872a188 100644 --- a/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx +++ b/packages/plugin-form/src/__tests__/sectionEntryOverrides-10475.test.tsx @@ -179,8 +179,10 @@ describe('`object-form` default arm — what stays pool-driven after the switch it('the POOLED field is the base: an entry override keeps a per-field fact only the pool carries', async () => { // The default arm's field generator writes facts the shared // `fromObjectSchema` never produces — here the numeric `step` derived from - // `scale`, which the form renderer's unregistered-widget fallback spreads - // onto its input (the route `objectFormNumericStep-9574` pins). So the step + // `scale`, which the form renderer's built-in `input` control spreads + // onto its input (the route `objectFormNumericStep-9574` pins; until + // objectui#12110 an unregistered `widget` name reached the same spread, + // and now degrades to the registered widget instead). So the step // survives an entry override only if the override is written onto the // pooled field. This row used the ADR-0092 D4 managed-object lock until // objectui#10612 moved that lock out of the generator into @@ -190,7 +192,7 @@ describe('`object-form` default arm — what stays pool-driven after the switch ...OBJECT_SCHEMA, fields: { ...OBJECT_SCHEMA.fields, - qty: { type: 'number', label: 'Qty', scale: 2, widget: 'nothing-registers-this' }, + qty: { type: 'number', label: 'Qty', scale: 2, widget: 'input' }, }, }; const sections = MAIN_SECTION([{ field: 'qty', label: 'SECTION LABEL' }, 'customer']); @@ -200,7 +202,7 @@ describe('`object-form` default arm — what stays pool-driven after the switch expect(controlOf('qty')?.getAttribute('step'), '…and the pooled step stays').toBe('0.01'); // The control: the same section on an arm with no pool, whose base is - // `fromObjectSchema`, draws the same fallback input with no step — so the + // `fromObjectSchema`, draws the same built-in input with no step — so the // `0.01` above is the pool's fact and not the renderer's. cleanup(); await mount('drawer', { sections }, { objectSchema: STEPPED }); From 7f9a952dbb268601fc6ca895b9d8e3f1cc43bcc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:12 +0000 Subject: [PATCH 04/11] fix(plugin-detail): the reference rail names rows by nameField and refetches on the invalidation bus (objectui#12110 items 3 and 4) Item 3: the rail named each related row through a fixed ladder of key names and kept only `fields` of the target schema. The same schema read now keeps the object's declared name field (core's declaredNameField), and a row is named by it, through recordDisplayValueAt, before the ladder. Item 4: the rail latched its fetch on (parentId + entries) and never subscribed to the data-invalidation bus, so `refreshAfter: true` (which posts notifyDataChanged for the parent record) left it stale. It now subscribes, and a change to the parent record, to an entry's object or to everything bumps a nonce the fetch signature carries. A refetch of the scope already on screen keeps its rows up while it reads: refresh in place, no skeleton, no remount (AGENTS.md #8). RecordDetailView is not touched: its existing post is what the rail now hears. Pin: record-reference-rail.nameFieldRefresh-12110. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- ...rence-rail.nameFieldRefresh-12110.test.tsx | 165 ++++++++++++++++++ .../src/renderers/record-reference-rail.tsx | 86 +++++++-- 2 files changed, 241 insertions(+), 10 deletions(-) create mode 100644 packages/plugin-detail/src/renderers/__tests__/record-reference-rail.nameFieldRefresh-12110.test.tsx diff --git a/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.nameFieldRefresh-12110.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.nameFieldRefresh-12110.test.tsx new file mode 100644 index 0000000000..070c7ec9a2 --- /dev/null +++ b/packages/plugin-detail/src/renderers/__tests__/record-reference-rail.nameFieldRefresh-12110.test.tsx @@ -0,0 +1,165 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * `record:reference_rail` — two findings of the HotCRM browser pass + * (objectui#12110 items 3 and 4). + * + * ## Item 3 (B-13): a row is named by the target object's `nameField` + * + * The rail named each related row through a fixed ladder of key names + * (`name`, `title`, `subject`, …) and kept only the `fields` of the target + * object's schema, so an object that declares its record name — ADR-0079's + * `nameField` — was named by whichever ladder key it happened to carry. A case + * object titled by `case_number` read as its `subject`. + * + * ## Item 4 (B-14): the rail refetches on the data-invalidation bus + * + * The rail fetched once per (parent id + entries) and never subscribed to the + * bus, so a record action with `refreshAfter: true` — which posts + * `notifyDataChanged({ objectName: , recordId })` — refreshed the + * record and its related lists and left the rail's counts and rows stale. It + * now refetches IN PLACE (AGENTS.md #8: refresh data, don't rebuild UI) for a + * change to the parent record, to any entry's object, or to everything (`'*'`). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, screen, act, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MemoryRouter } from 'react-router-dom'; +import { RecordContextProvider, notifyDataChanged } from '@object-ui/react'; + +import { RecordReferenceRailRenderer } from '../record-reference-rail'; + +/** Fires every observed entry as visible at once, so each card fetches. */ +class ImmediateIO { + constructor(private cb: (records: { isIntersecting: boolean }[]) => void) {} + observe() { this.cb([{ isIntersecting: true }]); } + disconnect() {} + unobserve() {} +} + +const SCHEMAS: Record = { + // Declares its record name: the case number, not the subject. + crm_case: { + name: 'crm_case', + nameField: 'case_number', + fields: { + case_number: { type: 'text' }, + subject: { type: 'text' }, + account: { type: 'lookup', reference: 'crm_account' }, + }, + }, + // Declares none: the ladder still applies. + crm_note: { + name: 'crm_note', + fields: { subject: { type: 'text' }, account: { type: 'lookup', reference: 'crm_account' } }, + }, +}; + +function makeDataSource() { + const caseRows = [{ id: 'c1', case_number: 'CS-0001', subject: 'Printer jam' }]; + let noteRows = [{ id: 'n1', subject: 'Kickoff notes' }]; + const find = vi.fn(async (objectName: string) => { + if (objectName === 'crm_case') return { data: caseRows, total: caseRows.length }; + return { data: noteRows, total: noteRows.length }; + }); + return { + find, + getObjectSchema: vi.fn(async (name: string) => SCHEMAS[name]), + /** + * What a server-side action does behind the rail's back. Notes, not cases, + * so the item 4 legs read through the unchanged ladder and stand on their + * own, independent of item 3's `nameField` rung. + */ + addNote(row: { id: string; subject: string }) { + noteRows = [...noteRows, row]; + }, + }; +} + +const RAIL = { + entries: [ + { objectName: 'crm_case', relationshipField: 'account', title: 'Cases' }, + { objectName: 'crm_note', relationshipField: 'account', title: 'Notes' }, + ], +}; + +function mount(ds: ReturnType) { + return render( + + + + + , + ); +} + +const notesFetches = (ds: ReturnType) => + ds.find.mock.calls.filter(([o]) => o === 'crm_note').length; + +beforeEach(() => { + vi.stubGlobal('IntersectionObserver', ImmediateIO as unknown as typeof IntersectionObserver); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('record:reference_rail names a row by the target object\'s `nameField` (objectui#12110 item 3)', () => { + it('THE DEFECT — a case titled by `case_number` reads "CS-0001", not its subject', async () => { + mount(makeDataSource()); + expect(await screen.findByText('CS-0001')).toBeInTheDocument(); + expect(screen.queryByText('Printer jam')).toBeNull(); + }); + + it('CONTROL: an object declaring no `nameField` keeps the ladder', async () => { + mount(makeDataSource()); + expect(await screen.findByText('Kickoff notes')).toBeInTheDocument(); + }); +}); + +describe('record:reference_rail refetches on the data-invalidation bus (objectui#12110 item 4)', () => { + it('THE DEFECT — a parent-record change (what `refreshAfter: true` posts) refetches the rail in place', async () => { + const ds = makeDataSource(); + mount(ds); + const first = await screen.findByText('Kickoff notes'); + const before = notesFetches(ds); + + ds.addNote({ id: 'n2', subject: 'Follow-up notes' }); + act(() => notifyDataChanged({ objectName: 'crm_account', recordId: 'A-1' })); + + expect(await screen.findByText('Follow-up notes')).toBeInTheDocument(); + expect(notesFetches(ds)).toBeGreaterThan(before); + // Refreshed in place, not rebuilt: the row already on screen is the same node. + expect(screen.getByText('Kickoff notes')).toBe(first); + }); + + it('a change to an entry\'s own object refetches it too', async () => { + const ds = makeDataSource(); + mount(ds); + expect(await screen.findByText('Kickoff notes')).toBeInTheDocument(); + + ds.addNote({ id: 'n3', subject: 'Call summary' }); + act(() => notifyDataChanged({ objectName: 'crm_note', recordId: 'n3' })); + + expect(await screen.findByText('Call summary')).toBeInTheDocument(); + }); + + it('CONTROL: a change to an unrelated object, or to another parent record, does not refetch', async () => { + const ds = makeDataSource(); + mount(ds); + expect(await screen.findByText('Kickoff notes')).toBeInTheDocument(); + await waitFor(() => expect(notesFetches(ds)).toBe(1)); + + act(() => notifyDataChanged({ objectName: 'crm_invoice' })); + act(() => notifyDataChanged({ objectName: 'crm_account', recordId: 'A-2' })); + // Give any (wrongly) dispatched refetch a chance to land. + await new Promise((r) => setTimeout(r, 30)); + expect(notesFetches(ds)).toBe(1); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-reference-rail.tsx b/packages/plugin-detail/src/renderers/record-reference-rail.tsx index 572e77d6b7..2e3a58f02e 100644 --- a/packages/plugin-detail/src/renderers/record-reference-rail.tsx +++ b/packages/plugin-detail/src/renderers/record-reference-rail.tsx @@ -65,11 +65,13 @@ import React from 'react'; import { Link, useParams } from 'react-router-dom'; -import { useRecordContext, useSafeFieldLabel } from '@object-ui/react'; +import { useRecordContext, useSafeFieldLabel, subscribeDataChanges, dataChangeMatches } from '@object-ui/react'; import { cn, Card, CardHeader, CardTitle, CardContent, Badge, Skeleton } from '@object-ui/components'; import { composeParentScopeFilter, + declaredNameField, isMultiValueRelationship, + recordDisplayValueAt, type FieldContainerLike, } from '@object-ui/core'; import { ChevronRight } from 'lucide-react'; @@ -223,10 +225,25 @@ const humanize = (s: string) => .replace(/\b\w/g, (c) => c.toUpperCase()) .trim(); -const pickDisplayName = (row: any, displayField?: string): string => { +/** + * A related row's name: the entry's own `displayField`, then the target + * object's DECLARED name field, then the fixed ladder below. + * + * The middle rung is objectui#12110 item 3 (finding B-13 of the HotCRM browser + * pass). The ladder alone named a row by whichever of its keys the row + * happened to carry, so an object that declares its record name (ADR-0079's + * `nameField`) was named by its `subject` or its `code` instead. The pointer is + * read through `@object-ui/core`'s `declaredNameField` and the value through + * `recordDisplayValueAt`, the two halves `getRecordDisplayName` reads its own + * declared rung with, so the rail and every other title surface agree on what + * a declared name field is and when its value is empty. + */ +const pickDisplayName = (row: any, displayField?: string, nameField?: string): string => { if (displayField && row?.[displayField] != null && row[displayField] !== '') { return String(row[displayField]); } + const declared = recordDisplayValueAt(row, nameField); + if (declared) return declared; return ( row?.name || row?.title || @@ -302,6 +319,10 @@ export const RecordReferenceRailRenderer: React.FC(''); + // The (parentId + entries) half of that signature, kept apart so a run can + // tell a REFRESH of the scope already on screen from a new scope — see the + // invalidation subscription below (objectui#12110 item 4). + const fetchedScopeRef = React.useRef(''); React.useEffect(() => { if (railVisible) return; // latch: once visible, stop observing const el = railRef.current; @@ -328,6 +349,11 @@ export const RecordReferenceRailRenderer: React.FC>({}); + // The CHILD objects' declared name fields (ADR-0079 `nameField`), keyed by + // object name, from the same schema read — what names each related row + // (objectui#12110 item 3). Absent for an object that declares none, and for + // an adapter that cannot serve the schema: the row ladder applies then. + const [entryNameFields, setEntryNameFields] = React.useState>({}); // One warning per (object, field) per mounted rail — the link suppression // below is silent on screen by construction, so the developer channel is the // only place it can be said at all. Fired from the effect, never from render. @@ -366,15 +392,47 @@ export const RecordReferenceRailRenderer: React.FC, + // recordId })`; any dataSource write reaches the bus through the console's + // mutation bridge; the header's manual refresh posts `'*'`. The rail + // latched its fetch on (parentId + entries) and heard none of them, so its + // counts and rows went stale beside a record and related lists that + // refreshed. A change to the PARENT record (its action may have written + // children), to any ENTRY's object, or to everything now bumps a nonce the + // fetch effect's signature carries — a refetch in place, never a remount + // (AGENTS.md #8). Keyed on primitives only: the entry objects by content. + const parentObjectName = ctx?.objectName; + const entryObjectsSig = JSON.stringify(entries.map((e) => e.objectName)); + const [invalidationNonce, setInvalidationNonce] = React.useState(0); + React.useEffect(() => { + const objects = JSON.parse(entryObjectsSig) as string[]; + return subscribeDataChanges((change) => { + const parentStale = + !!parentObjectName && !!parentId && dataChangeMatches(change, parentObjectName, String(parentId)); + if (parentStale || objects.some((objectName) => dataChangeMatches(change, objectName))) { + setInvalidationNonce((n) => n + 1); + } + }); + }, [parentObjectName, parentId, entryObjectsSig]); + React.useEffect(() => { if (!railVisible) return; if (!dataSource?.find || !parentId || entries.length === 0) return; - // Fetch once per (parentId + entries). Re-runs triggered purely by a new - // `dataSource` identity (drawer mount churn) are no-ops; navigating to a - // different record changes the signature and refetches. - const sig = `${parentId}::${entriesSig}`; + // Fetch once per (parentId + entries) and once per bus invalidation. + // Re-runs triggered purely by a new `dataSource` identity (drawer mount + // churn) are no-ops; navigating to a different record changes the + // signature and refetches. + const scope = `${parentId}::${entriesSig}`; + const sig = `${scope}::${invalidationNonce}`; if (fetchedSigRef.current === sig) return; + // A bus-driven refetch of the scope already on screen keeps its rows and + // counts up while it reads (objectui#12110 item 4): the skeleton is for a + // scope with nothing to show yet, not for a refresh. + const refreshing = fetchedScopeRef.current === scope; fetchedSigRef.current = sig; + fetchedScopeRef.current = scope; // objectui#10684 — only the run whose signature is still the dispatched // one commits. Navigating to another record (or changing the entries) // while this run's reads are in flight dispatches a new signature, and an @@ -384,7 +442,7 @@ export const RecordReferenceRailRenderer: React.FC mountedRef.current && fetchedSigRef.current === sig; // Mark every entry as loading up front so the skeletons render. - setStates((prev) => { + if (!refreshing) setStates((prev) => { const next = { ...prev }; for (const entry of entries) { next[entry.objectName] = { @@ -446,11 +504,18 @@ export const RecordReferenceRailRenderer: React.FC(); + const nameFieldFor = new Map(); if (typeof dataSource.getObjectSchema === 'function') { await Promise.all( Array.from(new Set(entries.map((e) => e.objectName))).map(async (name) => { try { - fieldsFor.set(name, (await dataSource.getObjectSchema(name))?.fields); + const schema = await dataSource.getObjectSchema(name); + fieldsFor.set(name, schema?.fields); + // The same read names the rows (objectui#12110 item 3): the + // schema's declared name field, which this rail used to drop + // with everything else but `fields`. + const nameField = declaredNameField(schema); + if (typeof nameField === 'string' && nameField !== '') nameFieldFor.set(name, nameField); } catch { // Equality it is — the wire this rail has always sent. } @@ -459,6 +524,7 @@ export const RecordReferenceRailRenderer: React.FC runWorker()), ); })(); - }, [railVisible, dataSource, parentId, entriesSig]); + }, [railVisible, dataSource, parentId, entriesSig, invalidationNonce]); // useState must run unconditionally — declared above the empty-entries early // return so hook order stays stable across renders. @@ -586,7 +652,7 @@ export const RecordReferenceRailRenderer: React.FC {state.items.map((item) => { const id = item.id || item._id; - const name = pickDisplayName(item, entry.displayField); + const name = pickDisplayName(item, entry.displayField, entryNameFields[key]); const href = id ? `../${entry.objectName}/record/${encodeURIComponent(String(id))}` : undefined; From 7b8b4402617b18ec59090ed8b20e5f842e52c34b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:12 +0000 Subject: [PATCH 05/11] fix(plugin-gantt): a task's date names its year when it is not the current one (objectui#12110 item 5) The Start / End cells, the row date line, the bar tooltip and the two drag previews formatted { month, day } only. One helper, formatTaskDay, adds the year when the date's year differs from the current one (the view zone's `now`, as the today line reads it): two digits on the compact numeric faces, in full on the short-month tooltip. Existing pins with 2020 / 2024 fixtures now read the year; the 2026 fixture of objectGanttInputs-11168 is pinned to a 2026 clock (Date only) so it cannot change meaning on New Year's Day. Pin: GanttView.dateYear-12110 (clock pinned). Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- content/docs/plugins/plugin-gantt.mdx | 4 + .../GanttView.displayLocale-10668.test.tsx | 9 +- packages/plugin-gantt/src/GanttView.tsx | 33 ++++-- .../GanttView.dateYear-12110.test.tsx | 109 ++++++++++++++++++ .../GanttView.inclusiveEnd-11141.test.tsx | 17 +-- .../objectGanttInputs-11168.test.tsx | 11 ++ 6 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 packages/plugin-gantt/src/__tests__/GanttView.dateYear-12110.test.tsx diff --git a/content/docs/plugins/plugin-gantt.mdx b/content/docs/plugins/plugin-gantt.mdx index 6d9653ee83..fd885e97b2 100644 --- a/content/docs/plugins/plugin-gantt.mdx +++ b/content/docs/plugins/plugin-gantt.mdx @@ -27,6 +27,10 @@ The `@object-ui/plugin-gantt` plugin provides Gantt chart visualization for Obje - **Progress Tracking**: Display task completion percentage (0-100%) - **Dependencies**: Visualize task dependencies and relationships - **Date Ranges**: Automatic date range calculation +- **Dates name their year**: the task list's Start / End cells and date line, + the bar tooltip and the drag previews add the year to a date outside the + current year (two digits on the compact faces), so a plan crossing a year + boundary never shows two different days alike - **Interactive**: Click handling for tasks - **Drag-and-drop rescheduling**: Drag a bar to move it; drag either edge to resize start/end. Snaps to whole days, keeps each date's time of day across diff --git a/packages/plugin-gantt/src/GanttView.displayLocale-10668.test.tsx b/packages/plugin-gantt/src/GanttView.displayLocale-10668.test.tsx index 33eeb9a0a5..d80890a076 100644 --- a/packages/plugin-gantt/src/GanttView.displayLocale-10668.test.tsx +++ b/packages/plugin-gantt/src/GanttView.displayLocale-10668.test.tsx @@ -101,8 +101,9 @@ describe('GanttView — the timeline follows the display locale (objectui#10668) expect(faces.units).toContain('9.3.'); expect(faces.groups, `got: ${JSON.stringify(faces.groups)}`).toContain('März 2020'); expect(faces.period).toBe('März 2020'); - expect(faces.start).toBe('2.3.'); - expect(faces.end).toBe('13.3.'); + // 2020 is not the current year, so the task list names it (objectui#12110 item 5). + expect(faces.start).toBe('2.3.20'); + expect(faces.end).toBe('13.3.20'); }); it('control: renders them as en-US under an en-US display locale', () => { @@ -111,8 +112,8 @@ describe('GanttView — the timeline follows the display locale (objectui#10668) expect(faces.units).toContain('3/9'); expect(faces.groups, `got: ${JSON.stringify(faces.groups)}`).toContain('Mar 2020'); expect(faces.period).toBe('March 2020'); - expect(faces.start).toBe('3/2'); - expect(faces.end).toBe('3/13'); + expect(faces.start).toBe('3/2/20'); + expect(faces.end).toBe('3/13/20'); }); /** ⭐ THE PIN: no runner locale can make both readings equal. */ diff --git a/packages/plugin-gantt/src/GanttView.tsx b/packages/plugin-gantt/src/GanttView.tsx index 2b2d573248..47b266d0f4 100644 --- a/packages/plugin-gantt/src/GanttView.tsx +++ b/packages/plugin-gantt/src/GanttView.tsx @@ -929,6 +929,23 @@ export function GanttView({ // the last resort, so no fallback belongs here. `ObjectGantt`'s tooltips and // `ResourceWorkload` read the same hook, so one gantt shows one locale. const dateLocale = useDisplayLocale(); + // A task's DAY as the task list, the tooltip and the drag previews print it + // (objectui#12110 item 5, finding B-20 of the HotCRM browser pass). These + // faces printed `{ month, day }` only, so a plan reaching into another year + // read `3/2` for two days a year apart. The year is added whenever the date's + // year is not the current one — "now" in the view's zone, the same `now` the + // today line reads — as two digits on the compact numeric faces (the Start / + // End cells are narrow) and in full on the short-month face; a day in the + // current year reads as it always did. The header bands keep their own + // formats and are not this helper's readers. + const currentYear = tzShift.now().getFullYear(); + const formatTaskDay = (date: Date, month: 'numeric' | 'short'): string => + date.toLocaleDateString( + dateLocale, + date.getFullYear() === currentYear + ? { month, day: 'numeric' } + : { month, day: 'numeric', year: month === 'numeric' ? '2-digit' : 'numeric' }, + ); const containerRef = React.useRef(null); // Observed container width — the source every auto-sized dimension below // derives from: row height, base column width, and the task-list pane width. @@ -3975,7 +3992,7 @@ export function GanttView({ className="text-[10px] text-muted-foreground" data-testid={`gantt-row-dates-${task.id}`} > - {row.start.toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' })} → {endDayOf(row.start, row.end).toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' })} + {formatTaskDay(row.start, 'numeric')} → {formatTaskDay(endDayOf(row.start, row.end), 'numeric')} )} @@ -3997,7 +4014,7 @@ export function GanttView({ onClick={(e) => e.stopPropagation()} /> ) : ( - row.start.toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' }) + formatTaskDay(row.start, 'numeric') )}
@@ -4010,7 +4027,7 @@ export function GanttView({ onClick={(e) => e.stopPropagation()} /> ) : ( - endDayOf(row.start, row.end).toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' }) + formatTaskDay(endDayOf(row.start, row.end), 'numeric') )}
@@ -4299,9 +4316,9 @@ export function GanttView({ ) : (
- {row.start.toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })} + {formatTaskDay(row.start, 'short')} {' → '} - {endDayOf(row.start, row.end).toLocaleDateString(dateLocale, { month: 'short', day: 'numeric' })} + {formatTaskDay(endDayOf(row.start, row.end), 'short')} {' · '}{durationDays}{t('gantt.tooltip.days')} {' · '}{Math.round(row.progress)}%
@@ -4519,9 +4536,9 @@ export function GanttView({ style={{ left: Math.max(liveStyle.left, 4), top: summaryBarTop + summaryBarHeight + 2 }} data-testid={`gantt-summary-drag-chip-${task.id}`} > - {computeDragChanges(dragState).start.toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' })} + {formatTaskDay(computeDragChanges(dragState).start, 'numeric')} {' → '} - {dragPreviewEndDay(dragState).toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' })} + {formatTaskDay(dragPreviewEndDay(dragState), 'numeric')} )} {tooltip} @@ -4834,7 +4851,7 @@ export function GanttView({ bar's text width never shifts on hover. */} {isDragging - ? `${computeDragChanges(dragState!).start.toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' })} → ${dragPreviewEndDay(dragState!).toLocaleDateString(dateLocale, { month: 'numeric', day: 'numeric' })}` + ? `${formatTaskDay(computeDragChanges(dragState!).start, 'numeric')} → ${formatTaskDay(dragPreviewEndDay(dragState!), 'numeric')}` : `${Math.round(liveProgress)}%`} diff --git a/packages/plugin-gantt/src/__tests__/GanttView.dateYear-12110.test.tsx b/packages/plugin-gantt/src/__tests__/GanttView.dateYear-12110.test.tsx new file mode 100644 index 0000000000..b5fb5fb92e --- /dev/null +++ b/packages/plugin-gantt/src/__tests__/GanttView.dateYear-12110.test.tsx @@ -0,0 +1,109 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12110 item 5 (finding B-20 of the HotCRM browser pass) — a task's + * dates name their year when it is not the current one. + * + * The task list's Start / End cells, the row's date sublabel, the bar tooltip + * and the two drag previews formatted `{ month, day }` only, so a plan reaching + * into last or next year read `3/2` for two different days a year apart. They + * now add the year whenever the date's year is not the current one — the + * compact numeric faces as two digits (the Start / End cells are narrow), the + * tooltip's short-month face in full — and a date in the current year reads as + * it did. + * + * The clock is pinned (Date only), because "the current year" is the test's + * subject: an unpinned run would change meaning every New Year's Day. The suite + * runs in UTC (`vitest.config.mts` pins `TZ`). + */ + +import React from 'react'; +import { act, cleanup, fireEvent, render } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { I18nProvider, LocalizationProvider } from '@object-ui/i18n'; +import { GanttView, type GanttTask } from '../GanttView'; + +beforeEach(() => { + window.localStorage.clear(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 5, 15, 12)); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +function at(width: number) { + Object.defineProperty(window, 'innerWidth', { value: width, configurable: true }); +} + +function task(id: string, start: Date, end: Date): GanttTask { + return { id, title: `Task ${id}`, start, end, progress: 0 }; +} + +/** March 2nd to 6th of LAST year (end exclusive: the 7th's midnight). */ +const LAST_YEAR = task('a', new Date(2025, 2, 2), new Date(2025, 2, 7)); +/** The same days THIS year. */ +const THIS_YEAR = task('b', new Date(2026, 2, 2), new Date(2026, 2, 7)); + +function renderView(width = 1280) { + at(width); + return render( + + +
+ +
+
+
, + ); +} + +const text = (container: HTMLElement, testId: string) => + (container.querySelector(`[data-testid="${testId}"]`)?.textContent ?? '').trim(); + +function hoverTooltip(container: HTMLElement, id: string): string { + const bar = container.querySelector(`[data-testid="gantt-task-bar-${id}"]`) as HTMLElement; + expect(bar, `no bar ${id}`).toBeTruthy(); + act(() => { + fireEvent.mouseEnter(bar); + }); + return text(container, `gantt-tooltip-${id}`); +} + +describe('GanttView names the year of a date outside the current one (objectui#12110 item 5)', () => { + it('THE DEFECT — the Start / End cells of a last-year task carry its year', () => { + const { container } = renderView(); + expect(text(container, 'gantt-row-start-a')).toBe('3/2/25'); + expect(text(container, 'gantt-row-end-a')).toBe('3/6/25'); + }); + + it('the row sublabel does too, below the Start / End threshold', () => { + const { container } = renderView(800); + expect(text(container, 'gantt-row-dates-a')).toBe('3/2/25 → 3/6/25'); + }); + + it('the tooltip names the year in full', () => { + const { container } = renderView(); + expect(hoverTooltip(container, 'a')).toContain('Mar 2, 2025 → Mar 6, 2025'); + }); + + it('CONTROL: the same days this year read as they did, with no year', () => { + const { container } = renderView(); + expect(text(container, 'gantt-row-start-b')).toBe('3/2'); + expect(text(container, 'gantt-row-end-b')).toBe('3/6'); + expect(hoverTooltip(container, 'b')).toContain('Mar 2 → Mar 6'); + }); +}); diff --git a/packages/plugin-gantt/src/__tests__/GanttView.inclusiveEnd-11141.test.tsx b/packages/plugin-gantt/src/__tests__/GanttView.inclusiveEnd-11141.test.tsx index c9b3acf7f2..ea518ba5f3 100644 --- a/packages/plugin-gantt/src/__tests__/GanttView.inclusiveEnd-11141.test.tsx +++ b/packages/plugin-gantt/src/__tests__/GanttView.inclusiveEnd-11141.test.tsx @@ -112,19 +112,20 @@ function commit(container: HTMLElement, id: string) { describe('GanttView names an end by the day the bar runs through (objectui#11141)', () => { it('the End cell names the 15th for a bar that runs through it, where it read the 16th', () => { const { container } = renderView([THROUGH_15TH]); - expect(text(container, 'gantt-row-start-a')).toBe('1/1'); - expect(text(container, 'gantt-row-end-a')).toBe('1/15'); + // 2024 is not the current year, so each face names it (objectui#12110 item 5). + expect(text(container, 'gantt-row-start-a')).toBe('1/1/24'); + expect(text(container, 'gantt-row-end-a')).toBe('1/15/24'); }); it('the row sublabel does too, below the Start / End threshold', () => { const { container } = renderView([THROUGH_15TH], {}, 800); - expect(text(container, 'gantt-row-dates-a')).toBe('1/1 → 1/15'); + expect(text(container, 'gantt-row-dates-a')).toBe('1/1/24 → 1/15/24'); }); it('the tooltip names the 15th and counts the fifteen days the bar runs through', () => { const { container } = renderView([THROUGH_15TH]); const tip = hoverTooltip(container, 'a'); - expect(tip).toContain('Jan 1 → Jan 15'); + expect(tip).toContain('Jan 1, 2024 → Jan 15, 2024'); expect(tip).toContain('· 15'); expect(tip).not.toContain('Jan 16'); }); @@ -140,7 +141,7 @@ describe('GanttView names an end by the day the bar runs through (objectui#11141 act(() => { handle.dispatchEvent(pointer('pointerdown', 500)); }); act(() => { window.dispatchEvent(pointer('pointermove', 500 + 2 * pxPerDay)); }); // Two days on: the bar runs through the 17th, its end is the 18th's midnight. - expect(bar.textContent).toContain('1/1 → 1/17'); + expect(bar.textContent).toContain('1/1/24 → 1/17/24'); act(() => { window.dispatchEvent(pointer('pointerup', 500 + 2 * pxPerDay)); }); expect(onTaskUpdate).toHaveBeenCalledTimes(1); expect(onTaskUpdate.mock.calls[0][1].end.getTime()).toBe(new Date(2024, 0, 18).getTime()); @@ -148,13 +149,13 @@ describe('GanttView names an end by the day the bar runs through (objectui#11141 it('control: an end inside a day names that day, as it did', () => { const { container } = renderView([task('b', new Date(2024, 0, 1), new Date(2024, 0, 15, 17))]); - expect(text(container, 'gantt-row-end-b')).toBe('1/15'); + expect(text(container, 'gantt-row-end-b')).toBe('1/15/24'); }); it('control: a zero-length span names its own day, not the day before', () => { const { container } = renderView([task('m', new Date(2024, 0, 15), new Date(2024, 0, 15))]); - expect(text(container, 'gantt-row-start-m')).toBe('1/15'); - expect(text(container, 'gantt-row-end-m')).toBe('1/15'); + expect(text(container, 'gantt-row-start-m')).toBe('1/15/24'); + expect(text(container, 'gantt-row-end-m')).toBe('1/15/24'); }); }); diff --git a/packages/plugin-gantt/src/__tests__/objectGanttInputs-11168.test.tsx b/packages/plugin-gantt/src/__tests__/objectGanttInputs-11168.test.tsx index 322650eda5..8c06a9b7dc 100644 --- a/packages/plugin-gantt/src/__tests__/objectGanttInputs-11168.test.tsx +++ b/packages/plugin-gantt/src/__tests__/objectGanttInputs-11168.test.tsx @@ -247,6 +247,17 @@ describe('object-gantt publishes the eleven spec keys its renderer honours (obje // ── gantt ─────────────────────────────────────────────────────────────────── describe('`object-gantt.gantt` — the members are field names, and the block is taken whole (objectui#11168)', () => { + // The Start / End faces name a date's year when it is not the current one + // (objectui#12110 item 5), so the fixtures' 2026 dates are pinned to a 2026 + // clock (Date only): these legs read which FIELDS the dates come from, and + // must not change meaning on New Year's Day. + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 2, 15, 12)); + }); + afterEach(() => { + vi.useRealTimers(); + }); const titleOf = (id: string) => screen.getByTestId(`gantt-row-title-${id}`).textContent; const startOf = (id: string) => screen.getByTestId(`gantt-row-start-${id}`).textContent; const endOf = (id: string) => screen.getByTestId(`gantt-row-end-${id}`).textContent; From 83c30340d96fe521c7b1e22743fe37c6273622ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:25 +0000 Subject: [PATCH 06/11] fix(console): the approvals drawer names a position approver and leads with the declared amount (objectui#12110 item 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approver: `formatIdentity` middle-truncated `position:sales_manager` to `positi…ager`. A position reference now reads the position's label from the console's metadata store (`ensureType('position')`, the registry read Setup's recipient picker makes, ADR-0131 D3), else the prettified name. Asked only for positions the server left unresolved. Headline: `decisionAmountEntry` led with the first name-matched key, so HotCRM's account led with "Child Account Revenue 0". A field declared `currency` now leads (its first non-zero, else its zero), and a name match leads only with a non-zero value. The 11677 lead-label pin moves to a billed invoice: its dogfood invoice's `total: 0` is the zero roll-up this change keeps out of the headline. Pin: ApprovalsInboxPage.approverHeadline-12110. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- ...sInboxPage.approverHeadline-12110.test.tsx | 240 ++++++++++++++++++ ...ovalsInboxPage.summaryFaces-11677.test.tsx | 13 +- .../src/pages/system/ApprovalsInboxPage.tsx | 118 ++++++++- 3 files changed, 358 insertions(+), 13 deletions(-) create mode 100644 apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx new file mode 100644 index 0000000000..1c11b9240c --- /dev/null +++ b/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx @@ -0,0 +1,240 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Approvals drawer — the approver chip names a position by its label, and the + * headline figure is the object's declared amount (objectui#12110 item 6, + * finding C-18 of the HotCRM browser pass). + * + * ## The approver chip + * + * A `position:` approver the server did not resolve went through the + * page's `formatIdentity`, which middle-truncates anything over 14 characters: + * `position:sales_manager` read `positi…ager`. It now reads the position's + * label from the console's metadata store — the `position` registry + * (ADR-0131 D3), the same `ensureType('position')` read Setup's sharing-rule + * picker makes, fetched once per session and shared — and the prettified + * machine name when the registry has no such item. The raw reference stays in + * the chip's `title`. + * + * ## The headline figure + * + * `decisionAmountEntry` took the first payload key matching an amount-ish + * pattern. HotCRM's account declares `annual_revenue` as a `currency` field + * and `child_account_revenue` as a `summary` roll-up of its children, and the + * drawer led with "Child Account Revenue 0". A field the object DECLARES as an + * amount (`currency`) now wins over a name match, and a name match is never + * headlined when its value is zero. + * + * Fixtures are the HotCRM account's own two declarations (`account.object.ts`). + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, within, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; + +const APP = 'com.example.crm'; + +const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA } = vi.hoisted(() => { + const SCHEMA = { + name: 'crm_account', + label: 'Account', + fields: { + name: { type: 'text', label: 'Account Name' }, + annual_revenue: { type: 'currency', label: 'Annual Revenue' }, + child_account_revenue: { + type: 'summary', + label: 'Child Account Revenue', + summaryOperations: { object: 'crm_account', field: 'annual_revenue', function: 'sum' }, + }, + industry: { type: 'text', label: 'Industry' }, + }, + }; + const getObjectSchema = vi.fn(async (): Promise => SCHEMA); + const approvalsApiStub = { + listRequests: vi.fn(async () => ({ data: [], total: 0 })), + getRequest: vi.fn(async () => ({ data: null })), + listActions: vi.fn(async () => ({ data: [] })), + approve: vi.fn(async () => ({ data: null, finalized: true })), + reject: vi.fn(async () => ({ data: null, finalized: true })), + }; + const ADAPTER = { find: vi.fn(async () => ({ data: [] })), getObjectSchema }; + const AUTH = { user: { id: 'u_1', email: 'approver@example.com' } }; + const I18N = { + t: (key: string, options?: Record) => String(options?.defaultValue ?? key), + language: 'en', + }; + return { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA }; +}); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => I18N, +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const authFetch = vi.fn(async () => new Response('{}', { status: 200 })); + return { + ...(await importOriginal>()), + useAuth: () => AUTH, + createAuthenticatedFetch: () => authFetch, + TokenStorage: { get: () => null }, + }; +}); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => ADAPTER, + DeclaredActionsBar: () => null, + isViaOverrideRow: () => false, +})); + +vi.mock('../../services/approvalsApi', async (importOriginal) => ({ + ...(await importOriginal>()), + approvalsApi: approvalsApiStub, +})); + +// Imported after the mocks so the page picks them up. +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; + +function permissionsPayload(): MePermissionsResponse { + return { + authenticated: true, + userId: 'u_1', + tenantId: 't_1', + roles: [], + permissionSets: [], + objects: {}, + fields: {}, + systemPermissions: ['setup.access'], + }; +} + +/** The `position` registry as `GET /api/v1/meta/position` serves it. */ +const POSITIONS = [ + { name: 'sales_manager', label: 'Regional Sales Manager' }, + { name: 'auditor', label: 'Auditor' }, +]; + +/** A metadata store answering the `position` registry; STABLE across renders. */ +function makeStore(positions: unknown[]): MetadataContextValue & { ensureType: ReturnType } { + return { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: vi.fn(async (type: string) => (type === 'position' ? positions : [])), + getItem: async () => null, + getItemsByType: () => [], + }; +} + +function request(over: Record): Record { + return { + id: 'req_1', + status: 'pending', + process_name: 'crm_account_credit', + process_label: 'Credit Limit Review', + object_name: 'crm_account', + object_label: 'Account', + record_id: 'acc_1', + record_title: 'Fabrikam', + submitter_id: 'u_9', + submitter_name: 'Dev Admin', + submitted_at: '2026-10-06T05:00:00.000Z', + pending_approvers: ['position:sales_manager'], + payload: { name: 'Fabrikam', industry: 'Retail' }, + ...over, + }; +} + +async function openedDrawer(row: Record, store: MetadataContextValue | null): Promise { + approvalsApiStub.listRequests.mockResolvedValue({ data: [row], total: 1 }); + approvalsApiStub.getRequest.mockResolvedValue({ data: row }); + const page = ( + + + + } /> + + + + ); + render(store ? {page} : page); + const dialog = await screen.findByRole('dialog'); + await within(dialog).findByText(String(row.process_label)); + return dialog; +} + +const chip = (dialog: HTMLElement, ref: string) => within(dialog).getByTitle(ref); + +beforeEach(() => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + getObjectSchema.mockClear(); + getObjectSchema.mockImplementation(async () => SCHEMA); + for (const fn of Object.values(approvalsApiStub)) fn.mockClear(); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('Approvals drawer — a position approver reads as the position (objectui#12110 item 6)', () => { + it('THE DEFECT — `position:sales_manager` reads the registry label, never "positi…ager"', async () => { + const store = makeStore(POSITIONS); + const dialog = await openedDrawer(request({}), store); + await waitFor(() => expect(chip(dialog, 'position:sales_manager')).toHaveTextContent('Regional Sales Manager')); + expect(within(dialog).queryByText(/positi…ager/)).toBeNull(); + expect(store.ensureType).toHaveBeenCalledWith('position'); + }); + + it('a position the registry does not carry reads as its prettified machine name', async () => { + const dialog = await openedDrawer(request({ pending_approvers: ['position:regional_controller'] }), makeStore([])); + await waitFor(() => expect(chip(dialog, 'position:regional_controller')).toHaveTextContent('Regional Controller')); + expect(within(dialog).queryByText(/positi…/)).toBeNull(); + }); + + it('CONTROL: the server\'s own name still wins, and the registry is not asked', async () => { + const store = makeStore(POSITIONS); + const dialog = await openedDrawer( + request({ pending_approver_names: { 'position:sales_manager': 'Sales Manager (served)' } }), + store, + ); + expect(chip(dialog, 'position:sales_manager')).toHaveTextContent('Sales Manager (served)'); + expect(store.ensureType).not.toHaveBeenCalledWith('position'); + }); +}); + +describe('Approvals drawer — the headline is the declared amount (objectui#12110 item 6)', () => { + const lead = (dialog: HTMLElement) => dialog.querySelector('[data-summary-lead]'); + + it('THE DEFECT — a zero roll-up named like an amount does not outrank the declared currency field', async () => { + const dialog = await openedDrawer( + request({ payload: { name: 'Fabrikam', child_account_revenue: 0, annual_revenue: 5000000, industry: 'Retail' } }), + makeStore(POSITIONS), + ); + await waitFor(() => expect(lead(dialog)?.getAttribute('data-summary-lead')).toBe('annual_revenue')); + expect(lead(dialog)?.children[0]?.textContent).toBe('Annual Revenue'); + }); + + it('a zero from a name match alone is never the headline', async () => { + const dialog = await openedDrawer( + request({ payload: { name: 'Fabrikam', child_account_revenue: 0, industry: 'Retail' } }), + makeStore(POSITIONS), + ); + await waitFor(() => expect(getObjectSchema).toHaveBeenCalled()); + // Settled on the declared labels before reading the absence. + await waitFor(() => expect(dialog.querySelector('[data-summary-field="industry"]')).not.toBeNull()); + expect(lead(dialog)).toBeNull(); + }); + + it('CONTROL: the declared amount field leads with its own zero — that zero is the record\'s amount', async () => { + const dialog = await openedDrawer( + request({ payload: { name: 'Fabrikam', annual_revenue: 0, industry: 'Retail' } }), + makeStore(POSITIONS), + ); + await waitFor(() => expect(lead(dialog)?.getAttribute('data-summary-lead')).toBe('annual_revenue')); + }); +}); diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx index 6bb1e29447..aeaf46546f 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.summaryFaces-11677.test.tsx @@ -422,8 +422,15 @@ describe('Approvals drawer summary — field metadata faces (objectui#11677)', ( }); it('labels the lead from the metadata read, as the record page does — not from `payload_labels`', async () => { - useRequest(INVOICE); - const dialog = await openedDrawer(INVOICE); + // A billed invoice: `total` is a `summary` roll-up, so it leads only with a + // non-zero value — the dogfood invoice's own `total: 0` is a zero from a + // name match, which objectui#12110 item 6 keeps out of the headline. + const billed = { + ...INVOICE, + payload: { ...(INVOICE.payload as Record), total: 1180 }, + }; + useRequest(billed); + const dialog = await openedDrawer(billed); await waitFor(() => expect(summaryRows(dialog).get('region')?.value).toBe('EMEA')); const lead = dialog.querySelector('[data-summary-lead]'); @@ -431,7 +438,7 @@ describe('Approvals drawer summary — field metadata faces (objectui#11677)', ( // The record page reads "Total" for this field; the snapshot's server // label ("Subtotal") is what the card showed before. expect(lead?.children[0]?.textContent).toBe('Total'); - expect((lead?.children[1]?.textContent ?? '').trim()).toBe(recordPageFace('showcase_invoice', 'total', 0)); + expect((lead?.children[1]?.textContent ?? '').trim()).toBe(recordPageFace('showcase_invoice', 'total', 1180)); }); it('a payload key the object does not declare takes no row once the schema answered', async () => { diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index e72c3445ba..d7743a49f7 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -77,6 +77,7 @@ import { toast } from 'sonner'; import { useAuth } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale, useObjectTranslation, useSafeFieldLabel } from '@object-ui/i18n'; +import { useMetadata } from '@object-ui/react'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; import { isExpandableFieldType } from '@object-ui/core'; import { isSystemManagedField, type FieldMetadata } from '@object-ui/types'; @@ -213,20 +214,89 @@ function formatDate(s: string | null | undefined, locale: string): string { try { return new Date(s).toLocaleString(locale); } catch { return s; } } +/** The approver prefix whose value is a position's machine NAME (ADR-0090 D3). */ +const POSITION_REF_PREFIX = 'position:'; + /** * Render an actor/approver identifier in a friendly form. * - emails → shown as-is * - `role:` → shown as "Role: name" + * - `position:` → the position's label (objectui#12110 item 6), from + * `positionLabels` when the caller has them, else the prettified name * - opaque 16+ char IDs → truncated middle (e.g. `5aF9BX3J…wTk`) + * + * The position arm is finding C-18 of the HotCRM browser pass: a position + * reference carries a machine NAME, not an opaque id, yet the truncation arm + * below cut `position:sales_manager` to `positi…ager`. A name has prose to + * recover, so it is never truncated; see {@link usePositionLabels} for where + * the label comes from. */ -function formatIdentity(id: string | null | undefined): string { +function formatIdentity( + id: string | null | undefined, + positionLabels?: ReadonlyMap, +): string { if (!id) return '—'; if (id.includes('@')) return id; if (id.startsWith('role:')) return `Role: ${id.slice(5)}`; + if (id.startsWith(POSITION_REF_PREFIX) && id.length > POSITION_REF_PREFIX.length) { + const name = id.slice(POSITION_REF_PREFIX.length); + return positionLabels?.get(name) || prettifyMachineName(name); + } if (id.length > 14) return `${id.slice(0, 6)}…${id.slice(-4)}`; return id; } +/** The empty answer, one object, so an unasked row hands back a stable value. */ +const NO_POSITION_LABELS: ReadonlyMap = new Map(); + +/** + * The labels of the `position:` approvers on `row` that the SERVER left + * unresolved (objectui#12110 item 6), keyed by position name. + * + * Read from the console's metadata store — the `position` registry, which + * ADR-0131 D3 makes positions' one home, through the same + * `useMetadata().ensureType('position')` read Setup's sharing-rule recipient + * picker makes (objectui#7611). The store fetches the type once per session + * and shares it, so this adds no request on a page that already read it, and + * it asks NOTHING when every approver is server-resolved or no approver is a + * position: `pending_approver_names` stays authoritative, as it is in + * {@link approverDisplay}. + * + * Keyed on a string of the names (AGENTS.md #10), and the store is read + * through a ref: its context value is rebuilt whenever any type it caches + * settles. + */ +function usePositionLabels(row: ApprovalRequestRow | null | undefined): ReadonlyMap { + const store = useMetadata(); + const storeRef = useRef(store); + storeRef.current = store; + const wantedKey = (row?.pending_approvers ?? []) + .filter((a): a is string => + typeof a === 'string' && a.startsWith(POSITION_REF_PREFIX) && !row?.pending_approver_names?.[a]) + .join('|'); + const [labels, setLabels] = useState>(NO_POSITION_LABELS); + useEffect(() => { + if (!wantedKey) return; + let cancelled = false; + storeRef.current.ensureType('position').then( + (items) => { + if (cancelled) return; + const next = new Map(); + for (const item of Array.isArray(items) ? items : []) { + const name = (item as { name?: unknown } | null)?.name; + const label = (item as { label?: unknown } | null)?.label; + if (typeof name === 'string' && typeof label === 'string' && label !== '') next.set(name, label); + } + setLabels(next); + }, + // A store that cannot list positions leaves the prettified names. + () => {}, + ); + return () => { cancelled = true; }; + }, [wantedKey]); + return labels; +} + /** `manager_review` → "Manager Review" (display fallback for legacy rows). */ function prettifyMachineName(raw: string | null | undefined): string { if (!raw) return '—'; @@ -246,8 +316,8 @@ function submitterDisplay(r: ApprovalRequestRow): string { return r.submitter_name || formatIdentity(r.submitter_id); } /** Approver chip text: server-resolved display name, else readable identity. */ -function approverDisplay(a: string, r: ApprovalRequestRow): string { - return r.pending_approver_names?.[a] || formatIdentity(a); +function approverDisplay(a: string, r: ApprovalRequestRow, positionLabels?: ReadonlyMap): string { + return r.pending_approver_names?.[a] || formatIdentity(a, positionLabels); } /** The group(s) a pending approver represents (会签), joined for display. */ function approverGroup(a: string, r: ApprovalRequestRow): string | undefined { @@ -265,11 +335,12 @@ function approverGroup(a: string, r: ApprovalRequestRow): string | undefined { */ function approverChips( r: ApprovalRequestRow, + positionLabels?: ReadonlyMap, ): Array<{ label: string; group?: string; count: number; title: string }> { const order: string[] = []; const byKey = new Map(); for (const a of r.pending_approvers || []) { - const label = approverDisplay(a, r); + const label = approverDisplay(a, r, positionLabels); const group = approverGroup(a, r); const key = group ? `${label} ${group}` : label; const seen = byKey.get(key); @@ -627,7 +698,23 @@ const AMOUNT_KEY_RE = /(amount|total|price|value|cost|sum|budget|salary|fee|reve * `defs` is the object's field declarations, which only the drawer has * (objectui#11677). When given, a key the object does not declare is not a * candidate, by the same rule as the drawer's field grid. The queue passes - * nothing, which keeps today's choice. + * nothing. + * + * ## Which candidate leads (objectui#12110 item 6) + * + * The first key whose NAME matched {@link AMOUNT_KEY_RE} used to lead, zero or + * not. HotCRM's account (finding C-18 of the HotCRM browser pass) declares + * `annual_revenue` as a `currency` field and `child_account_revenue` as a + * `summary` roll-up of its children, and the drawer led with "Child Account + * Revenue 0". So, in order: + * + * 1. a field the object DECLARES as an amount — `type: 'currency'`, read off + * `defs` — and among those the first non-zero one, else the first one: a + * declared amount's zero is the record's amount; + * 2. else the first NAME match whose value is not zero. A name match is a + * guess about what the key means, and a zero from a guessed key is the + * headline the finding measured, so it never leads. Without `defs` (the + * queue) every candidate is a name match. */ function decisionAmountEntry( r: ApprovalRequestRow, @@ -637,23 +724,32 @@ function decisionAmountEntry( ): { key: string; label: string; value: number; display: string } | null { const payload = r.payload; if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null; + let declaredZero: { key: string; label: string; value: number; display: string } | null = null; + let nameMatch: { key: string; label: string; value: number; display: string } | null = null; for (const [k, v] of Object.entries(payload as Record)) { if (isBookkeepingKey(k, defs?.[k])) continue; if (hiddenKeys.has(k)) continue; // author declared `hidden: true` (#5565) if (defs && !defs[k]) continue; // not a field of this object (#11677) - if (!AMOUNT_KEY_RE.test(k)) continue; + const declaredAmount = defs?.[k]?.type === 'currency'; + if (!declaredAmount && !AMOUNT_KEY_RE.test(k)) continue; const num = typeof v === 'number' ? v : (typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : null); if (num == null || !Number.isFinite(num)) continue; - return { + const entry = { key: k, label: r.payload_labels?.[k] ?? prettifyKey(k), value: num, display: r.payload_display?.[k] ?? num.toLocaleString(locale), }; + if (declaredAmount) { + if (num !== 0) return entry; + declaredZero ??= entry; + } else if (num !== 0) { + nameMatch ??= entry; + } } - return null; + return declaredZero ?? nameMatch; } /** The page's scoped translator, as the row cells receive it. */ @@ -1103,6 +1199,8 @@ export function ApprovalsInboxPage() { * before. See `useRequestFieldDefs` for the cost and the fail-open choice. */ const requestFieldDefs = useRequestFieldDefs(selected?.object_name); + // The open request's position approvers, named (objectui#12110 item 6). + const selectedPositionLabels = usePositionLabels(selected); const { fieldLabel } = useSafeFieldLabel(); // Approve/reject/reassign/send-back/… are server-declared actions rendered by // DeclaredActionsBar (objectui#2697 + framework#3300); their param dialog @@ -2465,7 +2563,7 @@ export function ApprovalsInboxPage() { {tr('waitingOn', 'Waiting on')}
- {approverChips(selected).map((chip, i) => ( + {approverChips(selected, selectedPositionLabels).map((chip, i) => ( {chip.label} {chip.group && ( @@ -2727,7 +2825,7 @@ export function ApprovalsInboxPage() { {canRecall ? tr('whyDisabledSubmitter', 'You submitted this request, so you can recall it — but only the assigned approvers can approve or reject.') : tr('whyDisabled', 'Only the assigned approvers can act on this request. It is waiting on: {{who}}.', { - who: (selected.pending_approvers || []).map(a => approverDisplay(a, selected)).join(', ') || '—', + who: (selected.pending_approvers || []).map(a => approverDisplay(a, selected, selectedPositionLabels)).join(', ') || '—', })}
)} From cc8efe64e5b59bb365337be532e14df32c35d1ca Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:25 +0000 Subject: [PATCH 07/11] fix(console): a public form with no title is headed by its object's label (objectui#12110 item 7, heading half) `loadPublicForm` fell back from the form's title straight to the object's API name. The object's label, served on the payload's `objectSchema`, now comes first; the API name stays the last resort. The objectui#8408 no-widening control pinned the API name in exactly this case; it moves one arm down (no object label either -> API name), and the no-title leg now pins the object's label. The other half of item 7 (editor hints on the public surface) is not in this commit: no signal tells a field widget it renders on the public form, and adding one is a contract change the card does not make. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .../FormPage.publicHeading.test.tsx | 28 +++++++++++++++---- apps/console/src/components/FormPage.tsx | 14 +++++++++- content/docs/guide/public-forms.md | 4 ++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/console/src/components/FormPage.publicHeading.test.tsx b/apps/console/src/components/FormPage.publicHeading.test.tsx index 86a7ddddfb..3d31c6c4f8 100644 --- a/apps/console/src/components/FormPage.publicHeading.test.tsx +++ b/apps/console/src/components/FormPage.publicHeading.test.tsx @@ -211,18 +211,34 @@ describe('objectui#8408 — public form heading and description', () => { expect(header.querySelector('h1 ~ p')?.textContent).toBe(DESCRIPTION); }); - it('NO_TITLE_STILL_FALLS_BACK — a form with neither title nor label keeps the API name', async () => { - // The no-widening control. This card adds one arm to a fallback chain; it - // does not change what happens when every arm legitimately misses, and a - // renderer that started printing something else there would be a different - // change than the one that was ruled. + it('NO_TITLE_TAKES_THE_OBJECT_LABEL — a form with neither title nor label is headed by the object\'s label (objectui#12110 item 7)', async () => { + // This leg used to pin the API name here, as the no-widening control of + // objectui#8408. The HotCRM browser pass (finding A-09) measured that + // fallback as the defect it is — an anonymous visitor greeted by the + // database table name — and objectui#12110 rules the object's label in + // ahead of it. The served payload carries that label on `objectSchema`. const { title, description, ...untitled } = APPLY_FORM; void title; void description; renderPublic({ ...SERVED, form: untitled }); const header = await liveHeader(); - expect(header.querySelector('h1')?.textContent).toBe(API_NAME); + expect(header.querySelector('h1')?.textContent).toBe(INQUIRY_SCHEMA.label); + expect(header.textContent).not.toContain(API_NAME); expect(header.querySelector('h1 ~ p')).toBeNull(); }); + + it('NO_LABEL_ANYWHERE_FALLS_BACK — with no object label either, the API name is the last resort', async () => { + // The no-widening control, moved one arm down: when every arm before it + // legitimately misses, the chain still ends where it always ended. + const { title, description, ...untitled } = APPLY_FORM; + void title; + void description; + const { label, ...unlabelledSchema } = INQUIRY_SCHEMA; + void label; + renderPublic({ ...SERVED, form: untitled, objectSchema: unlabelledSchema }); + const header = await liveHeader(); + + expect(header.querySelector('h1')?.textContent).toBe(API_NAME); + }); }); diff --git a/apps/console/src/components/FormPage.tsx b/apps/console/src/components/FormPage.tsx index 13d0256960..95bf75de28 100644 --- a/apps/console/src/components/FormPage.tsx +++ b/apps/console/src/components/FormPage.tsx @@ -1433,7 +1433,19 @@ async function loadPublicForm(slug: string): Promise { // `payload.label` keeps its precedence: when an envelope identity label // does arrive it is a deliberate per-view name, and this card changed the // fallback ORDER, not which value wins where one already did. - label: payload.label ?? payload.form?.title ?? payload.form?.label ?? payload.object, + // + // objectui#12110 item 7 (finding A-09 of the HotCRM browser pass): a form + // that names itself nowhere is headed by its OBJECT's label, which this + // endpoint serves on `objectSchema`, before the API name — the database + // table name is what an anonymous visitor must never be greeted by. The + // API name stays the last resort, for a payload that carries no label at + // all; `||` rather than `??` there so an empty label cannot blank the + // heading. + label: + payload.label + ?? payload.form?.title + ?? payload.form?.label + ?? (payload.objectSchema?.label || payload.object), object: payload.object, form: payload.form, objectSchema: payload.objectSchema, diff --git a/content/docs/guide/public-forms.md b/content/docs/guide/public-forms.md index 8bdcad49cb..702a060251 100644 --- a/content/docs/guide/public-forms.md +++ b/content/docs/guide/public-forms.md @@ -265,7 +265,9 @@ spec-merging code path: URL parameters of the form `?prefill_=` populate the matching fields on mount; the rest of the chrome (label, section columns, post-submit -behaviour) comes from the `FormView` spec itself. +behaviour) comes from the `FormView` spec itself. The public page's heading is +the form's `title`; a form with no title is headed by its object's label, and +the object's API name is used only when the payload carries no label at all. For richer public forms with anti-spam, GDPR consent, prefill whitelisting and open-redirect protection, host `EmbeddableForm` directly inside your own From 9f788844e40d16db8999bb677256d3151faa3678 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 11:42:25 +0000 Subject: [PATCH 08/11] chore(changeset): HotCRM rendering fixes (objectui#12110) Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .changeset/12110-hotcrm-rendering.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/12110-hotcrm-rendering.md diff --git a/.changeset/12110-hotcrm-rendering.md b/.changeset/12110-hotcrm-rendering.md new file mode 100644 index 0000000000..3d4ff18566 --- /dev/null +++ b/.changeset/12110-hotcrm-rendering.md @@ -0,0 +1,19 @@ +--- +'@object-ui/plugin-grid': patch +'@object-ui/components': patch +'@object-ui/fields': patch +'@object-ui/plugin-form': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-gantt': patch +'@object-ui/console': patch +--- + +Console rendering fixes from the HotCRM browser pass (objectui#12110): + +- **Grid:** a view column's authored `label` is the header. It used to lose to the field's translated label whenever the app bundle translated the field. +- **Form:** a field `widget` that names no registered component falls back to the field type's renderer, as the spec's `widget` description says. A `rating` field declaring `widget: 'star_rating'` now draws the star widget instead of a text box. A built-in control name (`input`, `textarea`, …) and the native password and email inputs still render as before. +- **Cells:** a `rating` value draws stars out of the field's `max` (5 by default) in list, detail and related-list cells, instead of a bare number. +- **Reference rail:** a related row is named by its object's declared `nameField`, and the rail refetches in place when the record, a related object or everything is invalidated. A record action with `refreshAfter: true` therefore refreshes the rail too. +- **Gantt:** the task list, the tooltip and the drag previews show a date's year when it is not the current year. +- **Approvals drawer:** a `position:` approver shows the position's label, from the console's position registry, instead of a truncated id. The headline figure prefers the object's declared `currency` field and never shows a zero from a field matched only by its name. +- **Public form:** a form with no title of its own is headed by its object's label, not the object's API name. From 798b4efe08f5b6f9ad3e5d992dd337aed3c22505 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:19:12 +0000 Subject: [PATCH 09/11] fix(console): type the 12110 approvals pin's stubs, and sync the store ref in an effect (objectui#12110 item 6) The console type-check read the pin's hoisted stubs as `never[]` / `null` answers; they now declare the row shape. `usePositionLabels` wrote its store ref during render (react-hooks/refs); it now syncs it in an effect declared ahead of the read. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .../ApprovalsInboxPage.approverHeadline-12110.test.tsx | 5 +++-- apps/console/src/pages/system/ApprovalsInboxPage.tsx | 6 +++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx index 1c11b9240c..c24f73dfd2 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.approverHeadline-12110.test.tsx @@ -53,9 +53,10 @@ const { approvalsApiStub, getObjectSchema, ADAPTER, AUTH, I18N, SCHEMA } = vi.ho }, }; const getObjectSchema = vi.fn(async (): Promise => SCHEMA); + type Row = Record; const approvalsApiStub = { - listRequests: vi.fn(async () => ({ data: [], total: 0 })), - getRequest: vi.fn(async () => ({ data: null })), + listRequests: vi.fn(async (): Promise<{ data: Row[]; total: number }> => ({ data: [], total: 0 })), + getRequest: vi.fn(async (): Promise<{ data: Row | null }> => ({ data: null })), listActions: vi.fn(async () => ({ data: [] })), approve: vi.fn(async () => ({ data: null, finalized: true })), reject: vi.fn(async () => ({ data: null, finalized: true })), diff --git a/apps/console/src/pages/system/ApprovalsInboxPage.tsx b/apps/console/src/pages/system/ApprovalsInboxPage.tsx index d7743a49f7..67532d07c2 100644 --- a/apps/console/src/pages/system/ApprovalsInboxPage.tsx +++ b/apps/console/src/pages/system/ApprovalsInboxPage.tsx @@ -269,7 +269,11 @@ const NO_POSITION_LABELS: ReadonlyMap = new Map(); function usePositionLabels(row: ApprovalRequestRow | null | undefined): ReadonlyMap { const store = useMetadata(); const storeRef = useRef(store); - storeRef.current = store; + // Synced in an effect, not during render; declared before the read below, + // so it runs first in the same commit. + useEffect(() => { + storeRef.current = store; + }); const wantedKey = (row?.pending_approvers ?? []) .filter((a): a is string => typeof a === 'string' && a.startsWith(POSITION_REF_PREFIX) && !row?.pending_approver_names?.[a]) From 50c77470a031bbf9c54cb4068b5baccdcdef25e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 12:59:05 +0000 Subject: [PATCH 10/11] test(plugin-gantt): the catalog plans' 2024 ends name their year (objectui#12110 item 5) ObjectGantt.catalogPlans-11141 derived the End cell's expected text as MONTH/DAY from the authored date; the catalog's plans are dated 2024, which is not the current year, so the cell now names the year too. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- .../src/__tests__/ObjectGantt.catalogPlans-11141.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plugin-gantt/src/__tests__/ObjectGantt.catalogPlans-11141.test.tsx b/packages/plugin-gantt/src/__tests__/ObjectGantt.catalogPlans-11141.test.tsx index 74f8c07fad..f46d5350b1 100644 --- a/packages/plugin-gantt/src/__tests__/ObjectGantt.catalogPlans-11141.test.tsx +++ b/packages/plugin-gantt/src/__tests__/ObjectGantt.catalogPlans-11141.test.tsx @@ -187,8 +187,10 @@ describe('the catalog plans draw each successor where the plan puts it (objectui const { ends } = await drawn(plan); const { endDateField: e } = plan.gantt; for (const row of plan.staticData) { - const [, month, day] = (row[e] as string).split('-').map(Number); - expect(ends.get(String(row.id)), `task ${row.id}`).toBe(`${month}/${day}`); + const [year, month, day] = (row[e] as string).split('-').map(Number); + // The catalog's plans are authored in 2024, which is not the current + // year, so the End cell names the year too (objectui#12110 item 5). + expect(ends.get(String(row.id)), `task ${row.id}`).toBe(`${month}/${day}/${String(year).slice(-2)}`); } }); }); From be73faf39cbc3bba212b10c4bc108854c8c11c7f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:27:09 +0000 Subject: [PATCH 11/11] test(schema-catalog): re-measure the gantt tiles' text after the year rule (objectui#12110 item 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectql-record-source-render-identity-6939 freezes the clock in 2026 and renders three 2023/2024 catalog plans, so item 5's year rule now writes `2/1/24 → 2/14/24` where its PRE_REPAIR literal held `2/1 → 2/14`. The three tiles' `visibleText` and `sha256` are re-measured through the file's own measure() at fd4059c; with the year segments removed the text equals the d88e20f55 reading exactly, and `elements` / `tags` are unchanged. The assertion is not loosened and no reading is dropped. Claude-Session: https://claude.ai/code/session_01TYgwmFK1q4KJ6Qq2WRLzsD Co-authored-by: Claude --- ...ecord-source-render-identity-6939.test.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx b/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx index 1faeb02756..851f666215 100644 --- a/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx +++ b/examples/schema-catalog/test/objectql-record-source-render-identity-6939.test.tsx @@ -62,6 +62,13 @@ const IDS = [ * Measured on `origin/main` @ `d88e20f55` through `measure()` below, mirror * untouched. `sha256` is over the full `textContent`; `visibleText` is the * same string with the chart's `