Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/10625-inline-date-impossible-day.md
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 6 additions & 4 deletions .changeset/9786-detail-timestamps-display-locale.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
`<input type="date">`, 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
`<input type="date">`, 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
Expand Down
11 changes: 1 addition & 10 deletions packages/i18n/src/__tests__/machineLocaleCensus-9909.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<input type="date">` 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 `<input type="date">` value (double-click and ' +
'an ISO-8601 formatter, not a display locale: it feeds the inline editor\'s `<input type="date">` value (double-click and ' +
'context-menu edit both seed it).',
},
{
Expand Down
75 changes: 41 additions & 34 deletions packages/plugin-detail/src/InlineFieldInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
LookupField,
UserField,
NumberField,
DateField,
CurrencyField,
PercentField,
ImageField,
Expand Down Expand Up @@ -494,8 +495,8 @@ export const InlineFieldInput: React.FC<InlineFieldInputProps> = ({
// 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.
//
Expand All @@ -515,48 +516,54 @@ export const InlineFieldInput: React.FC<InlineFieldInputProps> = ({
/>
);
}
const inputType = isDate ? 'date' : 'text';
// <input type="date"> 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 `<input type="date">` 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 (
<DateField
field={field as any}
value={value}
onChange={(v: any) => 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 (
<input
type={inputType}
// Marks the RAW TEXT fallback only. The `date`/`datetime` branch shares
// this element but is a routed editor (a native date picker with ISO
// coercion on both sides), not the lossy fallback the guard hunts for.
data-testid={isDate ? undefined : INLINE_PLAIN_TEXT_INPUT_TESTID}
type="text"
// Marks the RAW TEXT fallback, the lossy path the guard hunts for.
data-testid={INLINE_PLAIN_TEXT_INPUT_TESTID}
autoFocus={autoFocus}
// No widget sits behind this branch to honour the #3222 slot, so the
// refusal marking is applied to the element itself.
aria-invalid={error ? true : undefined}
className="w-full px-2 py-1.5 text-sm border rounded-md bg-background focus:outline-none focus:ring-2 focus:ring-ring"
value={inputValue}
onChange={(e) => {
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)}
/>
);
};
Original file line number Diff line number Diff line change
@@ -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
* `<input type="date">`. 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(
<InlineFieldInput field={{ name: 'due', type }} value={value} onChange={onChange} />,
);
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();
});
}
});
Loading