diff --git a/.changeset/10625-inline-date-impossible-day.md b/.changeset/10625-inline-date-impossible-day.md new file mode 100644 index 0000000000..a72c4bbda0 --- /dev/null +++ b/.changeset/10625-inline-date-impossible-day.md @@ -0,0 +1,5 @@ +--- +"@object-ui/plugin-detail": patch +--- + +The detail page's inline date editor no longer blanks a stored day that does not exist without saying so (objectui#10625). A `date` field holding `2026-02-30`, or a `datetime` field holding `2026-02-30T10:00:00Z`, used to open an empty date input with no marker and no notice. The inline editor now uses `@object-ui/fields`' `DateField` for `date` and `datetime` fields, so it behaves like the form editor: the control is empty, marked `aria-invalid`, and described by a notice that names the stored value. Nothing is written until the user picks a day. A real date opens and saves as before (the edit is re-emitted as a full ISO timestamp at local midnight). diff --git a/.changeset/9786-detail-timestamps-display-locale.md b/.changeset/9786-detail-timestamps-display-locale.md index 34659f4028..6256c09a60 100644 --- a/.changeset/9786-detail-timestamps-display-locale.md +++ b/.changeset/9786-detail-timestamps-display-locale.md @@ -60,13 +60,15 @@ variable that resolves to `undefined`, and the only way to reach `RecordMetaFooter` — whose absolute face is a `TooltipContent` child that Radix keeps out of the DOM until the tooltip opens. A census pin reddens when a new bare, `undefined`- or hard-coded-tag call site -is written; it masks comments before it counts, and it declares -`InlineFieldInput`'s `en-CA` as an exemption with its reason — that call feeds -``, whose value HTML defines as `YYYY-MM-DD`, so it is an ISO +is written; it masks comments before it counts, and when it landed it declared +`InlineFieldInput`'s `en-CA` as an exemption with its reason — that call fed +``, whose value HTML defines as `YYYY-MM-DD`, so it was an ISO formatter and not a display locale. It landed scoped to this package and, under the ruling on this card, became the one repository-wide census in `@object-ui/i18n` (`machineLocaleCensus-9909.test.ts`, objectui#9909), which -carries that exemption forward. +carried that exemption forward. objectui#10625 later removed the call: +`InlineFieldInput`'s date editor now renders `@object-ui/fields`' `DateField`, +and the exemption went with it. ⛔ Not repaired here: the same class outside this package. A mechanical scan of the whole repository's non-test sources — comments masked, the same matcher the diff --git a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts index 6fc2e2b89a..1ff933f929 100644 --- a/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts +++ b/packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts @@ -284,22 +284,13 @@ const DECLARED: Exemption[] = [ reason: 'the total line of the same dev-server-only demo page source; the console build ships `index.html` alone.', }, - { - file: 'packages/plugin-detail/src/InlineFieldInput.tsx', - expression: "d.toLocaleDateString('en-CA')", - count: 1, - verdict: 'deliberate fallback', - reason: - 'an ISO-8601 FORMATTER, not a display locale: `` takes `YYYY-MM-DD` and nothing else, so ' + - 'putting this on the display locale would blank the native date picker.', - }, { file: 'packages/plugin-gantt/src/GanttView.tsx', expression: "task.start.toLocaleDateString('en-CA')", count: 2, verdict: 'deliberate fallback', reason: - 'the same ISO-8601 formatter feeding the inline editor\'s `` value (double-click and ' + + 'an ISO-8601 formatter, not a display locale: it feeds the inline editor\'s `` value (double-click and ' + 'context-menu edit both seed it).', }, { diff --git a/packages/plugin-detail/src/InlineFieldInput.tsx b/packages/plugin-detail/src/InlineFieldInput.tsx index 8acf5c3de7..0e5314f3af 100644 --- a/packages/plugin-detail/src/InlineFieldInput.tsx +++ b/packages/plugin-detail/src/InlineFieldInput.tsx @@ -13,6 +13,7 @@ import { LookupField, UserField, NumberField, + DateField, CurrencyField, PercentField, ImageField, @@ -494,8 +495,8 @@ export const InlineFieldInput: React.FC = ({ // how a chip row renders — are `FieldEditWidget`'s to make, and it already // makes them for the grid's inline cell editor; asking it here is the point of // delegating rather than routing type by type. `date`/`datetime` are excluded - // because they ARE routed (the native date input below) and a working path - // does not get churned. Types the hosts gate out entirely (containers, + // because they ARE routed (to `DateField` below, objectui#10625) and a + // working path does not get churned. Types the hosts gate out entirely (containers, // credentials, computed — #4228 / #3355) never arrive here at all, and none of // them has a widget anyway, so this branch cannot re-open those. // @@ -515,48 +516,54 @@ export const InlineFieldInput: React.FC = ({ /> ); } - const inputType = isDate ? 'date' : 'text'; - // needs a YYYY-MM-DD string; raw ISO timestamps - // ("2026-02-14T14:46:20.862Z") leave the picker blank. Slice down to the date - // portion so existing values round-trip correctly. + if (isDate) { + // The routed date editor is `@object-ui/fields`' own `DateField`, for + // `date` AND `datetime` fields alike (objectui#10625). That is the widget + // whose answer to a stored day that does not exist (`2026-02-30`, or + // `2026-02-30T10:00:00Z` on a `datetime` field) is objectui#10026 + // direction A: an `` can only paint such a day blank + // (the browser sanitises it to `""`), so the control is handed `""`, + // marked `aria-invalid`, and described by a notice NAMING the stored + // string (`isImpossibleStoredDay` + `fields.date.impossibleDay`, + // objectui#10567). The hand-rolled input this replaces sliced the stored + // value into the control and nothing else, which is the silent blank. + // + // The read side is `toDateInputValue`: a leading `YYYY-MM-DD` passes + // through verbatim (an ISO timestamp is sliced to its date, as before), + // anything else goes through local calendar getters. The write side stays + // this editor's own: the widget hands back the control's `YYYY-MM-DD`, + // re-emitted as full ISO at local midnight so backend validation that + // expects ISO timestamps keeps working. Only a user edit emits. + return ( + onChange(v ? new Date(v + 'T00:00:00').toISOString() : v)} + autoFocus={autoFocus} + error={error} + /> + ); + } + // Only the raw text fallback remains below. + // Coerce objects (e.g. an unexpanded reference that slipped through type + // detection) to a readable label rather than leaking "[object Object]". const inputValue = value == null ? '' - : isDate - ? (() => { - const s = String(value); - if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10); - const d = new Date(s); - return isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-CA'); - })() - // Coerce objects (e.g. an unexpanded reference that slipped through type - // detection) to a readable label rather than leaking "[object Object]". - : typeof value === 'object' - ? String(coerceToSafeValue(value) ?? '') - : String(value); + : typeof value === 'object' + ? String(coerceToSafeValue(value) ?? '') + : String(value); return ( { - const v = e.target.value; - // Re-emit dates as full ISO so backend validation that expects ISO - // timestamps keeps working. - if (isDate && v) { - const iso = new Date(v + 'T00:00:00').toISOString(); - onChange(iso); - } else { - onChange(v); - } - }} + onChange={(e) => onChange(e.target.value)} /> ); }; diff --git a/packages/plugin-detail/src/__tests__/InlineFieldInput.impossibleDay-10625.test.tsx b/packages/plugin-detail/src/__tests__/InlineFieldInput.impossibleDay-10625.test.tsx new file mode 100644 index 0000000000..06df1cbd48 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/InlineFieldInput.impossibleDay-10625.test.tsx @@ -0,0 +1,78 @@ +/** + * 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 inline date editor and a stored day that does not exist (objectui#10625). + * + * `InlineFieldInput` edits `date` AND `datetime` fields with one native + * ``. A stored `2026-02-30` (or `2026-02-30T10:00:00Z` on a + * `datetime` field) is a value that control can only paint blank — the + * browser sanitises a nonexistent day to `""` — so on its own it is the silent + * blank objectui#10026 direction A rules out. The editor must answer it the way + * `@object-ui/fields`' `DateField` does since objectui#10567: an empty control, + * marked `aria-invalid`, described by a notice that NAMES the stored string, + * and nothing written until the user picks a day. + * + * Provider-free, like the other suites here: the notice falls back to the + * English default the fields package carries for provider-less rendering. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { InlineFieldInput } from '../InlineFieldInput'; + +function renderInline(type: string, value: unknown) { + const onChange = vi.fn(); + const { container } = render( + , + ); + const input = container.querySelector('input[type="date"]') as HTMLInputElement | null; + return { container, input, onChange }; +} + +describe('InlineFieldInput marks a stored impossible day (objectui#10625)', () => { + const cases: Array<[string, string]> = [ + ['date', '2026-02-30'], + ['datetime', '2026-02-30T10:00:00Z'], + ]; + + for (const [type, stored] of cases) { + it(`a \`${type}\` field holding ${stored} shows an empty, invalid control described by a notice naming it`, () => { + const { container, input, onChange } = renderInline(type, stored); + expect(input).not.toBeNull(); + expect(input!.value).toBe(''); + expect(input!.getAttribute('aria-invalid')).toBe('true'); + + const describedBy = input!.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + const notice = describedBy! + .split(' ') + .map((id) => container.ownerDocument.getElementById(id)) + .find((el) => el != null); + expect(notice).toBeTruthy(); + expect(notice!.textContent).toContain(`"${stored}"`); + + // Nothing is written on mount: only a user edit emits. + expect(onChange).not.toHaveBeenCalled(); + }); + } + + for (const [type, stored, shown] of [ + ['date', '2026-02-14', '2026-02-14'], + ['datetime', '2026-02-14T14:46:20.862Z', '2026-02-14'], + ] as const) { + it(`a real day on a \`${type}\` field is unchanged: shown, not marked, no notice, no emit`, () => { + const { input, onChange } = renderInline(type, stored); + expect(input).not.toBeNull(); + expect(input!.value).toBe(shown); + expect(input!.getAttribute('aria-invalid')).not.toBe('true'); + expect(input!.getAttribute('aria-describedby')).toBeNull(); + expect(onChange).not.toHaveBeenCalled(); + }); + } +});