@object-ui/fields 17.3.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* objectui#4272 — the lookup picker's MongoDB `$date` fallback rendered in the
|
|
11
|
+
* MACHINE's locale.
|
|
12
|
+
*
|
|
13
|
+
* `renderCellContent`'s plain-text fallback runs whenever a column has no
|
|
14
|
+
* usable field descriptor / cell renderer — the shape a string-authored
|
|
15
|
+
* `lookup_columns` entry against an object with no `fieldsMeta` produces. For
|
|
16
|
+
* an expanded Mongo value it did:
|
|
17
|
+
*
|
|
18
|
+
* if (val.$date) return new Date(val.$date).toLocaleDateString();
|
|
19
|
+
*
|
|
20
|
+
* with no tag at all. `undefined` is not "the user's locale", it is the
|
|
21
|
+
* machine's — so this cell rendered `8/11/2026` on a `zh` console while every
|
|
22
|
+
* neighbouring date cell (fixed in PR #4512) rendered `2026/8/11`.
|
|
23
|
+
*
|
|
24
|
+
* ── Directions ───────────────────────────────────────────────────────────
|
|
25
|
+
* Runner machine locale is `en-US`, so the `en` case is GREEN ON BOTH SIDES —
|
|
26
|
+
* the byte-identical pin, not evidence. The `zh` case goes red against
|
|
27
|
+
* unfixed code, and so does the precedence case: `de` (`11.8.2026`) differs
|
|
28
|
+
* from BOTH the machine form and the `zh` form, so it cannot pass by
|
|
29
|
+
* coincidence.
|
|
30
|
+
*
|
|
31
|
+
* This file mounts providers; the pure-function cases for the same card are
|
|
32
|
+
* kept in `__tests__/date-formatter-residue-4272.test.ts` (objectui#4514).
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import React from 'react';
|
|
36
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
37
|
+
import { render, screen, waitFor, cleanup } from '@testing-library/react';
|
|
38
|
+
import '@testing-library/jest-dom';
|
|
39
|
+
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
|
|
40
|
+
import { RecordPickerDialog } from './RecordPickerDialog';
|
|
41
|
+
|
|
42
|
+
/** The exact expanded-Mongo shape the picker receives from the server. */
|
|
43
|
+
const records = [
|
|
44
|
+
{ id: 'r1', name: 'Northwind', signed_on: { $date: new Date(2026, 7, 11, 0, 0, 0).toISOString() } },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
function makeDataSource() {
|
|
48
|
+
return { find: vi.fn(async () => ({ data: records, total: records.length })) } as any;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A session: the UI language the user picked plus the tenant's regional
|
|
53
|
+
* default (usually absent). Mirrors `date-locale-channel.test.tsx`'s harness
|
|
54
|
+
* so both halves of this card describe a session the same way.
|
|
55
|
+
*/
|
|
56
|
+
function renderSession(language: string, tenantLocale?: string) {
|
|
57
|
+
return render(
|
|
58
|
+
<I18nProvider
|
|
59
|
+
config={{ defaultLanguage: language, detectBrowserLanguage: false }}
|
|
60
|
+
persistLanguage={false}
|
|
61
|
+
>
|
|
62
|
+
<LocalizationProvider value={{ locale: tenantLocale }}>
|
|
63
|
+
<RecordPickerDialog
|
|
64
|
+
open
|
|
65
|
+
onOpenChange={() => {}}
|
|
66
|
+
onSelect={() => {}}
|
|
67
|
+
dataSource={makeDataSource()}
|
|
68
|
+
objectName="accounts"
|
|
69
|
+
// Deliberately no `type` and no `fieldsMeta`/`cellRenderer`: this is
|
|
70
|
+
// what drives BOTH columns through the plain-text fallback where the
|
|
71
|
+
// `$date` branch lives. `name` is carried purely as a render anchor —
|
|
72
|
+
// the picker table shows only the columns it is given, so waiting on
|
|
73
|
+
// a field that is not a column would wait forever.
|
|
74
|
+
columns={[
|
|
75
|
+
{ field: 'name', label: 'Name' },
|
|
76
|
+
{ field: 'signed_on', label: 'Signed On' },
|
|
77
|
+
]}
|
|
78
|
+
/>
|
|
79
|
+
</LocalizationProvider>
|
|
80
|
+
</I18nProvider>,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The dialog renders through a Radix portal, so its table is NOT inside the
|
|
86
|
+
* `container` `render()` returns — it is mounted at the document body. Reading
|
|
87
|
+
* `container.textContent` here would assert against an empty string, which
|
|
88
|
+
* passes every `not.toContain` for the wrong reason.
|
|
89
|
+
*/
|
|
90
|
+
function bodyText(): string {
|
|
91
|
+
return document.body.textContent ?? '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
afterEach(() => cleanup());
|
|
95
|
+
|
|
96
|
+
describe('RecordPickerDialog — the $date fallback follows the display locale (objectui#4272)', () => {
|
|
97
|
+
it('zh session renders the Chinese date form', async () => {
|
|
98
|
+
renderSession('zh');
|
|
99
|
+
await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument());
|
|
100
|
+
expect(bodyText()).toContain('2026/8/11');
|
|
101
|
+
expect(bodyText()).not.toContain('8/11/2026');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** PIN — the runner's machine locale is `en-US`, so this is green both sides. */
|
|
105
|
+
it('en session output is byte-identical (must-not-change)', async () => {
|
|
106
|
+
renderSession('en');
|
|
107
|
+
await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument());
|
|
108
|
+
expect(bodyText()).toContain('8/11/2026');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* `useDisplayLocale()` puts the TENANT's configured regional default above
|
|
113
|
+
* the active UI language. `de` is chosen because its form (`11.8.2026`)
|
|
114
|
+
* matches neither the machine's (`8/11/2026`) nor `zh`'s (`2026/8/11`), so
|
|
115
|
+
* this case is genuinely red before the fix instead of passing by accident.
|
|
116
|
+
*/
|
|
117
|
+
it('an explicit tenant locale outranks the active UI language', async () => {
|
|
118
|
+
renderSession('zh', 'de');
|
|
119
|
+
await waitFor(() => expect(screen.getByText('Northwind')).toBeInTheDocument());
|
|
120
|
+
expect(bodyText()).toContain('11.8.2026');
|
|
121
|
+
expect(bodyText()).not.toContain('2026/8/11');
|
|
122
|
+
expect(bodyText()).not.toContain('8/11/2026');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -38,7 +38,11 @@ import {
|
|
|
38
38
|
X,
|
|
39
39
|
} from 'lucide-react';
|
|
40
40
|
import type { DataSource, LookupColumnDef, LookupFilterDef } from '@object-ui/types';
|
|
41
|
-
|
|
41
|
+
// The repo's single filter sink (`packages/core/src/utils/filter-converter.ts`)
|
|
42
|
+
// — shared with plugin-list's `buildEffectiveFilter` and plugin-view's
|
|
43
|
+
// ObjectView, so a spec `ViewFilterRule[]` lowers in exactly one place.
|
|
44
|
+
import { mergeFilterNodes } from '@object-ui/core';
|
|
45
|
+
import { useSafeFieldLabel, useDisplayLocale } from '@object-ui/i18n';
|
|
42
46
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
43
47
|
import { useRecordQuery } from './useRecordQuery';
|
|
44
48
|
|
|
@@ -360,13 +364,39 @@ export interface RecordPickerDialogProps {
|
|
|
360
364
|
lookupFilters?: LookupFilterDef[];
|
|
361
365
|
|
|
362
366
|
/**
|
|
363
|
-
* Hard filter
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
367
|
+
* Hard filter constraint applied to every query. Unlike `lookupFilters`,
|
|
368
|
+
* entries here never surface in the filter bar and cannot be overridden by
|
|
369
|
+
* user filter input.
|
|
370
|
+
*
|
|
371
|
+
* Two shapes, discriminated STRUCTURALLY (`Array.isArray`), because the two
|
|
372
|
+
* callers speak two legitimate vocabularies and neither should be bent into
|
|
373
|
+
* the other:
|
|
374
|
+
*
|
|
375
|
+
* - **`QueryParams.$filter` record form** (`{ account: 'a1' }`) — the
|
|
376
|
+
* dependent (cascading) lookup chain, where the parent field's value MUST
|
|
377
|
+
* scope the candidate set (#2215). Merged by KEY OVERWRITE, so a cascaded
|
|
378
|
+
* value REPLACES a stale `lookupFilters` entry on the same field instead of
|
|
379
|
+
* intersecting with it. That precedence is load-bearing: an `and` of both
|
|
380
|
+
* would ask for `account = 'stale' AND account = 'a1'` and return nothing.
|
|
381
|
+
* - **A spec `ViewFilterRule[]`** (`[{ field, operator, value? }]`) — an
|
|
382
|
+
* author's `record:related_list.add.picker.filter`, handed over VERBATIM
|
|
383
|
+
* (#3831). Lowered by `mergeFilterNodes`, the repo's single filter sink, so
|
|
384
|
+
* all 19 `VIEW_FILTER_OPERATORS` reach the wire — including the four
|
|
385
|
+
* (`before`, `after`, `is_empty`, `is_not_empty`) the record form has no
|
|
386
|
+
* `$op` for. No second operator vocabulary is introduced here: two already
|
|
387
|
+
* exist (the spec's `AST_OPERATOR_MAP`, data-objectstack's
|
|
388
|
+
* `FILTER_OPERATOR_ALIASES`) and #3948 is what a third costs.
|
|
389
|
+
*
|
|
390
|
+
* The discriminator is exact rather than heuristic — every AST node is an
|
|
391
|
+
* ARRAY and a rule is a plain OBJECT, the same predicate `toFilterNode` uses.
|
|
392
|
+
*
|
|
393
|
+
* Typed `unknown` rather than `Record< string, any >` on purpose: that type
|
|
394
|
+
* ACCEPTED a rule array (TypeScript lets an array satisfy a string index of
|
|
395
|
+
* `any`), the old object-spread merge then flattened it to
|
|
396
|
+
* `{"0": {...}, "1": {...}}`, and the query filtered on columns literally
|
|
397
|
+
* named `0`/`1` — type-check green, wrong query, no diagnostic anywhere.
|
|
368
398
|
*/
|
|
369
|
-
baseFilter?:
|
|
399
|
+
baseFilter?: unknown;
|
|
370
400
|
|
|
371
401
|
/**
|
|
372
402
|
* Cell renderer resolver function.
|
|
@@ -466,6 +496,9 @@ export function RecordPickerDialog({
|
|
|
466
496
|
}: RecordPickerDialogProps) {
|
|
467
497
|
const { t } = useFieldTranslation();
|
|
468
498
|
const { translateOptions } = useSafeFieldLabel();
|
|
499
|
+
// The one date/number locale resolver: tenant regional default → active UI
|
|
500
|
+
// language → 'en' (objectui#4272). Read unconditionally at component level.
|
|
501
|
+
const displayLocale = useDisplayLocale();
|
|
469
502
|
|
|
470
503
|
// Query state (records/loading/error/total + page/search/sort) lives in the
|
|
471
504
|
// shared useRecordQuery kernel — instantiated after mergedFilter below.
|
|
@@ -586,18 +619,34 @@ export function RecordPickerDialog({
|
|
|
586
619
|
});
|
|
587
620
|
}, [baseFilterColumns, fieldsMeta, objectName, translateOptions]);
|
|
588
621
|
|
|
589
|
-
// Merge base lookup_filters with user filter bar values
|
|
590
|
-
// `baseFilter` constraint (
|
|
591
|
-
//
|
|
592
|
-
|
|
622
|
+
// Merge base lookup_filters with user filter bar values, then apply the hard
|
|
623
|
+
// `baseFilter` constraint BY SHAPE (see the prop's own doc for why the two
|
|
624
|
+
// shapes exist):
|
|
625
|
+
//
|
|
626
|
+
// record form → spread LAST, exactly as this merge has always done, so
|
|
627
|
+
// user filter-bar input can never widen it back out and a
|
|
628
|
+
// cascaded parent value replaces a stale same-field
|
|
629
|
+
// `lookupFilters` entry (#2215).
|
|
630
|
+
// rule array → its OWN `and` child via `mergeFilterNodes`, the shared
|
|
631
|
+
// sink, so a spec `ViewFilterRule[]` lowers losslessly
|
|
632
|
+
// (#3831) instead of being spread into `{0: rule}`.
|
|
633
|
+
//
|
|
634
|
+
// When BOTH are in play the record side is still built first and lowered as
|
|
635
|
+
// one node, so its key-overwrite precedence survives the conjunction.
|
|
636
|
+
const mergedFilter = useMemo<unknown>(() => {
|
|
593
637
|
const lookupBase = lookupFilters?.length
|
|
594
638
|
? lookupFiltersToRecord(lookupFilters)
|
|
595
639
|
: {};
|
|
596
640
|
const userFilter = effectiveFilterColumns?.length
|
|
597
641
|
? filterValuesToRecord(filterValues, effectiveFilterColumns)
|
|
598
642
|
: {};
|
|
599
|
-
const
|
|
600
|
-
|
|
643
|
+
const rules = Array.isArray(baseFilter) ? baseFilter : undefined;
|
|
644
|
+
const recordBase = rules
|
|
645
|
+
? undefined
|
|
646
|
+
: (baseFilter as Record<string, any> | undefined);
|
|
647
|
+
const combined = { ...lookupBase, ...userFilter, ...(recordBase ?? {}) };
|
|
648
|
+
const record = Object.keys(combined).length > 0 ? combined : undefined;
|
|
649
|
+
return rules ? mergeFilterNodes(record, rules) : record;
|
|
601
650
|
}, [lookupFilters, effectiveFilterColumns, filterValues, baseFilter]);
|
|
602
651
|
|
|
603
652
|
// Shared query kernel: builds params, fetches, and owns records/loading/error/
|
|
@@ -814,13 +863,13 @@ export function RecordPickerDialog({
|
|
|
814
863
|
// Handle MongoDB types / expanded references
|
|
815
864
|
if (val.$numberDecimal) return String(Number(val.$numberDecimal));
|
|
816
865
|
if (val.$oid) return String(val.$oid);
|
|
817
|
-
if (val.$date) return new Date(val.$date).toLocaleDateString();
|
|
866
|
+
if (val.$date) return new Date(val.$date).toLocaleDateString(displayLocale);
|
|
818
867
|
if (val.name || val.label) return String(val.name || val.label);
|
|
819
868
|
return JSON.stringify(val);
|
|
820
869
|
}
|
|
821
870
|
if (typeof val === 'boolean') return val ? 'Yes' : 'No';
|
|
822
871
|
return String(val);
|
|
823
|
-
}, [cellRenderer, titleFormat, displayField, columnFieldDescriptors]);
|
|
872
|
+
}, [cellRenderer, titleFormat, displayField, columnFieldDescriptors, displayLocale]);
|
|
824
873
|
|
|
825
874
|
// Render sort indicator for a column
|
|
826
875
|
const renderSortIcon = useCallback((field: string) => {
|
|
@@ -133,7 +133,7 @@ export function RichTextField({ value, onChange, field, readonly, error, ...prop
|
|
|
133
133
|
const hint = t('fields.richText.basicEditorHint', { defaultValue: 'Rich text editor (basic)' });
|
|
134
134
|
const placeholder =
|
|
135
135
|
richField?.placeholder ||
|
|
136
|
-
t('fields.richText.placeholder', { defaultValue: 'Enter text
|
|
136
|
+
t('fields.richText.placeholder', { defaultValue: 'Enter text…' });
|
|
137
137
|
|
|
138
138
|
return (
|
|
139
139
|
<RichTextEditorSurface
|
|
@@ -21,11 +21,31 @@ import { useCascadingOptions } from './useCascadingOptions';
|
|
|
21
21
|
*
|
|
22
22
|
* A field declared `multiple: true` selects zero-or-more values (spec:
|
|
23
23
|
* `multiple` is valid on `select`), so it renders the multi-value chip picker
|
|
24
|
-
* — the same widget the `multiselect` type uses.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
24
|
+
* — the same widget the `multiselect` type uses. Single-value selects keep the
|
|
25
|
+
* cascading dropdown below.
|
|
26
|
+
*
|
|
27
|
+
* The FORM no longer arrives here with `multiple` (objectui#3986):
|
|
28
|
+
* `mapFieldTypeToFormType` now resolves a `select` + `multiple: true` field to
|
|
29
|
+
* `field:multiselect`, so the object-form path renders `MultiSelectField`
|
|
30
|
+
* directly under its own registry id — which is what carries the
|
|
31
|
+
* `labelling: 'group'` declaration the host label needs. Deciding the widget at
|
|
32
|
+
* the type-resolution layer is what keeps the declaration and the render from
|
|
33
|
+
* disagreeing; a delegation invisible to the resolver could not.
|
|
34
|
+
*
|
|
35
|
+
* The branch below stays because it is NOT dead — measured entrances that reach
|
|
36
|
+
* it with `multiple` set, none of which consult that resolver:
|
|
37
|
+
*
|
|
38
|
+
* - **the inline grid editor** — `FieldEditWidget` looks `select` up in its own
|
|
39
|
+
* `EDIT_WIDGETS` table, which SHORT-CIRCUITS before the alias map is
|
|
40
|
+
* consulted, and forwards the whole metadata object as `field`;
|
|
41
|
+
* - **`ActionParamDialog`** — `paramToField` resolves through
|
|
42
|
+
* `resolveFormWidgetType`, which likewise returns `select` from
|
|
43
|
+
* `fieldWidgetMap` before reaching the alias map, and carries
|
|
44
|
+
* `multiple: param.multiple` on the field it builds;
|
|
45
|
+
* - **hand-written SDUI** — a `{ type: 'field:select' }` node whose metadata
|
|
46
|
+
* declares `multiple`, which addresses this widget by name.
|
|
47
|
+
*
|
|
48
|
+
* All three then inherit multi-select from here identically, with no drift.
|
|
29
49
|
*
|
|
30
50
|
* Both branches resolve per-option `visibleWhen` cascading / role-gating through
|
|
31
51
|
* the shared {@link useCascadingOptions} hook (#2715), so single and multi stay
|
|
@@ -86,6 +106,17 @@ function SingleSelectField({
|
|
|
86
106
|
// (parent changed / predicate flipped), drop it so no stale pair persists.
|
|
87
107
|
useEffect(() => {
|
|
88
108
|
if (readonly) return;
|
|
109
|
+
// Never configured → nothing to prune against; see `MultiSelectField`'s
|
|
110
|
+
// copy of this guard for the measured failure (objectui#4220). Kept
|
|
111
|
+
// identical across the four option widgets: an authored list is what the
|
|
112
|
+
// cascade prunes, and all four render the same `OptionsEmptyState` when
|
|
113
|
+
// there is none.
|
|
114
|
+
if (rawOptions.length === 0) return;
|
|
115
|
+
// Gated → the authored list is withheld until the `dependsOn` parent is
|
|
116
|
+
// chosen, so the empty offered set is missing information, not a verdict on
|
|
117
|
+
// the stored value; clearing here fired on mount (objectui#4247). Same
|
|
118
|
+
// reasoning, at length, in `MultiSelectField`.
|
|
119
|
+
if (gated) return;
|
|
89
120
|
if (value === undefined || value === null || (value as unknown) === '') return;
|
|
90
121
|
if (!isValueStillOffered(value, options)) onChange?.(undefined as unknown as string);
|
|
91
122
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
@@ -33,8 +33,9 @@ export function UserField(props: FieldWidgetComponentProps<any>) {
|
|
|
33
33
|
const raw = props.field as any;
|
|
34
34
|
|
|
35
35
|
// The objectSchema field metadata may live directly on `field`, or nested at
|
|
36
|
-
// `field.field`
|
|
37
|
-
// unwrap LookupField itself performs.
|
|
36
|
+
// `field.field` — the form renderer's declared metadata slot (objectui#3090).
|
|
37
|
+
// Mirror the unwrap LookupField itself performs. (Formerly attributed to the
|
|
38
|
+
// docs-demo `createFieldRenderer` wrapper, removed in objectui#3910.)
|
|
38
39
|
const metaIsNested = raw?.field && typeof raw.field === 'object'
|
|
39
40
|
&& ('reference' in raw.field || 'reference_to' in raw.field || 'type' in raw.field);
|
|
40
41
|
const meta = metaIsNested ? raw.field : raw;
|
|
@@ -93,14 +93,38 @@ describe('every spec field operator is reachable from the builder (#2942)', () =
|
|
|
93
93
|
// emits the bare `{ field: value }` shape, never an explicit `$eq`.
|
|
94
94
|
// `$between` stays covered by the `$gte`+`$lte` pair `between` emits
|
|
95
95
|
// (kvToCondition reads that pair back as `between`).
|
|
96
|
+
//
|
|
97
|
+
// `$icontains` is a GENUINE GAP, not a modelling nuance, and is excluded
|
|
98
|
+
// here rather than silently dropped from the vocabulary. `FieldOperatorsSchema`
|
|
99
|
+
// gained it between @objectstack/spec 17.0.0-rc.2 and rc.5 (it folds ASCII
|
|
100
|
+
// case, i.e. case-insensitive contains): the server accepts the token, and no
|
|
101
|
+
// builder operator can author it, so the capability is unreachable from the
|
|
102
|
+
// filter UI. Closing it needs a new builder operator with a user-visible
|
|
103
|
+
// label — a new key in all ten locale packs — which is feature work and
|
|
104
|
+
// deliberately NOT carried by the dependency bump that surfaced it
|
|
105
|
+
// (objectui#3560). Tracked as objectui#3567; delete this exclusion when it
|
|
106
|
+
// lands, and the assertion below will hold it honest.
|
|
107
|
+
const KNOWN_UNREACHABLE = new Set(['$eq', '$between', '$icontains']);
|
|
96
108
|
const unreachable = [...SPEC_OPERATORS].filter(
|
|
97
|
-
(op) => op
|
|
109
|
+
(op) => !KNOWN_UNREACHABLE.has(op) && !emitted.has(op),
|
|
98
110
|
);
|
|
99
111
|
expect(
|
|
100
112
|
unreachable,
|
|
101
113
|
'FieldOperatorsSchema accepts these but no builder operator can author them',
|
|
102
114
|
).toEqual([]);
|
|
103
115
|
});
|
|
116
|
+
|
|
117
|
+
it('every KNOWN_UNREACHABLE token is still a spec operator (the exclusion ratchet)', () => {
|
|
118
|
+
// A stale exclusion is how a parity test rots into a tautology: if the spec
|
|
119
|
+
// ever drops one of these, the entry must go too rather than sit there
|
|
120
|
+
// excusing a token nobody ships. `$eq` / `$between` / `$icontains` are all
|
|
121
|
+
// real `FieldOperatorsSchema` keys today.
|
|
122
|
+
for (const op of ['$eq', '$between', '$icontains']) {
|
|
123
|
+
expect(SPEC_OPERATORS.has(op), `'${op}' is excluded but no longer a spec operator`).toBe(
|
|
124
|
+
true,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
104
128
|
});
|
|
105
129
|
|
|
106
130
|
describe('kvToCondition round-trips what condToMongo writes', () => {
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The address value shape, and the ONE formatting rule every address surface
|
|
11
|
+
* renders it with.
|
|
12
|
+
*
|
|
13
|
+
* This module exists because the rule had exactly one implementation and only
|
|
14
|
+
* one consumer could reach it (objectui#4037). `AddressField`'s readonly branch
|
|
15
|
+
* collapsed a stored address to a formatted line, while the display (read)
|
|
16
|
+
* registry the detail page uses mapped `address` straight to `JsonCellRenderer`
|
|
17
|
+
* — so the same stored value rendered as `Street, City, State ZIP, Country` in
|
|
18
|
+
* a readonly form and as `{"street":"…","city":"…"}` on the detail page.
|
|
19
|
+
*
|
|
20
|
+
* The fix keeps ONE definition rather than a second copy next to the renderer:
|
|
21
|
+
* a duplicated rule is a rule that drifts, and two spellings of the same
|
|
22
|
+
* address on two surfaces of one app is the very failure the issue reports.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately pure — no React, no component imports — so the eager
|
|
25
|
+
* `@object-ui/fields` barrel can use it for cell rendering without pulling
|
|
26
|
+
* `AddressField` (and its inputs) out of the lazily-loaded widget chunk.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { AddressValue } from '@objectstack/spec/data';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Address data structure — `z.input` of `@objectstack/spec`'s
|
|
33
|
+
* `AddressValueSchema`, which is what the platform stores and what
|
|
34
|
+
* `/api/v1/data/**` serves back.
|
|
35
|
+
*
|
|
36
|
+
* The postal code is spelled `postalCode` (objectstack#5143). `AddressField`
|
|
37
|
+
* used to read and write `zipCode`, a key that appears nowhere in the spec, so
|
|
38
|
+
* the stored postal code never reached the input — and whatever the user then
|
|
39
|
+
* typed into that apparently-empty box was written under a key
|
|
40
|
+
* `AddressValueSchema` strips: lost outright on a new record, and on an
|
|
41
|
+
* existing one silently discarded while the stale stored value survived. (A
|
|
42
|
+
* postal code nobody touched did survive an unrelated edit, because the write
|
|
43
|
+
* spread the whole stored object through; the issue's account of that half is
|
|
44
|
+
* corrected in `AddressField.postalCode.test.tsx`.) One name, on both sides:
|
|
45
|
+
* the contract's.
|
|
46
|
+
*
|
|
47
|
+
* IMPORTED rather than declared since objectui#4167. rc.6 publishes
|
|
48
|
+
* `AddressValue` under this exact name, and the local copy — whose own comment
|
|
49
|
+
* already claimed to be "the part names of `AddressSchema`" — declared five of
|
|
50
|
+
* the seven parts. `countryCode` and `formatted` were missing, which is
|
|
51
|
+
* objectstack#4115's failure class precisely: a canonical CLAIM over a narrower
|
|
52
|
+
* shape, believed by the next reader. The claim is now the code.
|
|
53
|
+
*
|
|
54
|
+
* `AddressField` still renders five inputs and {@link formatAddress} still
|
|
55
|
+
* composes five parts, and that is a deliberate split rather than an omission:
|
|
56
|
+
* `formatted` is a derived one-line rendering of the parts (what this module
|
|
57
|
+
* computes) and `countryCode` is the ISO pair for `country`, neither of which a
|
|
58
|
+
* human types into a part box. Binding the type does not publish them as
|
|
59
|
+
* inputs — it stops the type from asserting the platform cannot store them, and
|
|
60
|
+
* it means a write from the widget no longer looks type-clean while dropping a
|
|
61
|
+
* key the contract carries: the `{ ...address }` spread in `handleFieldChange`
|
|
62
|
+
* preserves both, and now says so.
|
|
63
|
+
*
|
|
64
|
+
* The declaration moved here (objectui#4037) so the display cell renderer and
|
|
65
|
+
* the widget share one definition; the SOURCE of that definition is the spec.
|
|
66
|
+
*/
|
|
67
|
+
export type { AddressValue };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The shape written by builds up to and including 17.0.0-rc.1, whose postal
|
|
71
|
+
* code landed under `zipCode` (objectstack#5143). Read-time compatibility ONLY,
|
|
72
|
+
* and deliberately not part of {@link AddressValue}: authoring code must not be
|
|
73
|
+
* able to spell `zipCode`, and nothing here ever writes it back — the first
|
|
74
|
+
* edit of any part normalizes the record onto `postalCode` (see
|
|
75
|
+
* `AddressField`'s `handleFieldChange`). This is not a producer alias to be
|
|
76
|
+
* honoured forever; it exists to carry data THIS widget mis-wrote, and can go
|
|
77
|
+
* once no such data remains.
|
|
78
|
+
*/
|
|
79
|
+
export type LegacyAddressValue = AddressValue & { zipCode?: string };
|
|
80
|
+
|
|
81
|
+
/** Postal code of a stored address, preferring the canonical key. */
|
|
82
|
+
export function readPostalCode(addr: AddressValue): string | undefined {
|
|
83
|
+
return addr.postalCode ?? (addr as LegacyAddressValue).zipCode;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One address part, normalized for joining: absent, non-string and
|
|
88
|
+
* whitespace-only parts all collapse to `''` so the caller's `filter(Boolean)`
|
|
89
|
+
* drops them. This is what keeps a partial address free of dangling separators
|
|
90
|
+
* and of the string `undefined`.
|
|
91
|
+
*/
|
|
92
|
+
function part(value: unknown): string {
|
|
93
|
+
if (value == null) return '';
|
|
94
|
+
return String(value).trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Formats a stored address as a single line: `Street, City, State ZIP, Country`.
|
|
99
|
+
*
|
|
100
|
+
* Ordering and separators are NOT invented here — they are the rule
|
|
101
|
+
* `AddressField` already applied in its readonly branch, and the sub-field set
|
|
102
|
+
* its inputs expose (street / city / state / postalCode / country). Missing
|
|
103
|
+
* parts are dropped rather than spaced over, so a street-only address is
|
|
104
|
+
* `"中策路 1 号"` and never `", , ,"`.
|
|
105
|
+
*
|
|
106
|
+
* Returns `''` when no part is usable — callers decide what an empty address
|
|
107
|
+
* looks like (an `EmptyValue` placeholder for the cell renderer), and a value
|
|
108
|
+
* carrying no recognized part at all is NOT silently blanked: see
|
|
109
|
+
* `AddressCellRenderer`, which falls back to JSON so unknown data stays visible.
|
|
110
|
+
*/
|
|
111
|
+
export function formatAddress(addr: AddressValue): string {
|
|
112
|
+
// State and postal code share one comma-delimited group, space-separated
|
|
113
|
+
// inside it ("CA 94102"); either one alone occupies the group by itself.
|
|
114
|
+
const stateAndPostal = [part(addr.state), part(readPostalCode(addr))]
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(' ');
|
|
117
|
+
return [part(addr.street), part(addr.city), stateAndPostal, part(addr.country)]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join(', ');
|
|
120
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side `maxSize` guard shared by the file and image widgets
|
|
3
|
+
* (objectui#4141).
|
|
4
|
+
*
|
|
5
|
+
* Why this is shared rather than mirrored per widget: the guard is the only
|
|
6
|
+
* thing standing between a declared `maxSize` and a real `presigned → PUT →
|
|
7
|
+
* complete` round trip, and `paramToField` copies `maxSize` onto the field
|
|
8
|
+
* config for **every** param type regardless of which widget ends up rendering
|
|
9
|
+
* it. So a widget that re-derives the check drifts silently — the image param
|
|
10
|
+
* shipped receiving the constraint and never reading it, while the file param
|
|
11
|
+
* rejected the identical pick, and nothing failed until QA drove the two side by
|
|
12
|
+
* side. One exported function makes the difference visible: a widget either
|
|
13
|
+
* calls it or conspicuously does not.
|
|
14
|
+
*
|
|
15
|
+
* Both widgets deliberately share the `fields.file.exceedsMaxSize` key too. The
|
|
16
|
+
* sentence names a file and a limit and says nothing file-widget-specific, it is
|
|
17
|
+
* already translated in all 11 built-in locales, and a parallel `fields.image.*`
|
|
18
|
+
* twin would be the same sentence maintained twice — drifting the moment one
|
|
19
|
+
* translator rewords one of them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minimal shape of the i18next `t` this module needs, matching the established
|
|
24
|
+
* `TranslateFn` pattern in `app-shell/src/providers/writeWarningToast`.
|
|
25
|
+
*/
|
|
26
|
+
export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
|
27
|
+
|
|
28
|
+
/** Divisor for rendering a byte limit as MB, matching the widgets' size display. */
|
|
29
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The rejection message for a pick that exceeds `maxSize`, or `null` when the
|
|
33
|
+
* pick is within the limit — so a caller reads it as `if (err) reject`.
|
|
34
|
+
*
|
|
35
|
+
* A falsy `maxSize` (undeclared, or an explicit `0`) means **unrestricted**,
|
|
36
|
+
* preserving the `if (maxSize && …)` semantics the file widget has always
|
|
37
|
+
* applied: an undeclared limit must never turn into a 0-byte one.
|
|
38
|
+
*
|
|
39
|
+
* Takes `name` and `size` separately instead of a `File` because the image
|
|
40
|
+
* widget's crop path validates a re-encoded `Blob`, which carries no name of its
|
|
41
|
+
* own — that name comes from the cropper's output.
|
|
42
|
+
*/
|
|
43
|
+
export function maxSizeError(
|
|
44
|
+
t: TranslateFn,
|
|
45
|
+
pick: { name: string; size: number },
|
|
46
|
+
maxSize: number | undefined,
|
|
47
|
+
): string | null {
|
|
48
|
+
if (!maxSize || pick.size <= maxSize) return null;
|
|
49
|
+
const maxMB = (maxSize / BYTES_PER_MB).toFixed(1);
|
|
50
|
+
return t('fields.file.exceedsMaxSize', {
|
|
51
|
+
defaultValue: `"${pick.name}" exceeds max size (${maxMB} MB)`,
|
|
52
|
+
name: pick.name,
|
|
53
|
+
max: maxMB,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import { InstantValueSchema } from '@objectstack/spec/data';
|
|
11
|
+
import {
|
|
12
|
+
toDateInputValue,
|
|
13
|
+
toDateTimeInputValue,
|
|
14
|
+
fromDateTimeInputValue,
|
|
15
|
+
} from './nativeDateValue';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* These assertions are deliberately written to hold in EVERY timezone, because
|
|
19
|
+
* the bug they pin (objectui#3127) is a timezone-shaped one and a test that
|
|
20
|
+
* only passes in UTC would go green on CI while the defect it guards is live
|
|
21
|
+
* for every user east or west of it.
|
|
22
|
+
*
|
|
23
|
+
* The trick: never hardcode the expected wall clock. Assert the SHAPE the
|
|
24
|
+
* native control demands, and assert that reading that shape back (as local
|
|
25
|
+
* time, which is how both the browser and `new Date` read a zone-less
|
|
26
|
+
* datetime) lands on the same INSTANT we started from.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const localInstantOf = (bare: string) => new Date(bare).getTime();
|
|
30
|
+
|
|
31
|
+
describe('toDateInputValue', () => {
|
|
32
|
+
it('keeps a leading YYYY-MM-DD verbatim, in any timezone', () => {
|
|
33
|
+
// Re-parsing would read this as UTC midnight and hand back the 16th
|
|
34
|
+
// anywhere west of Greenwich. The calendar day is the value here.
|
|
35
|
+
expect(toDateInputValue('2026-06-17')).toBe('2026-06-17');
|
|
36
|
+
expect(toDateInputValue('2026-06-17T14:30:00.000Z')).toBe('2026-06-17');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('formats a Date to the local calendar day', () => {
|
|
40
|
+
const d = new Date(2026, 5, 17, 23, 59);
|
|
41
|
+
expect(toDateInputValue(d)).toBe('2026-06-17');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('maps empty and unparseable input to an empty control', () => {
|
|
45
|
+
expect(toDateInputValue(null)).toBe('');
|
|
46
|
+
expect(toDateInputValue(undefined)).toBe('');
|
|
47
|
+
expect(toDateInputValue('')).toBe('');
|
|
48
|
+
expect(toDateInputValue('not a date')).toBe('');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('toDateTimeInputValue', () => {
|
|
53
|
+
it('renders a UTC ISO instant as a local wall clock the control accepts', () => {
|
|
54
|
+
const iso = '2026-06-17T14:30:00.000Z';
|
|
55
|
+
const shown = toDateTimeInputValue(iso);
|
|
56
|
+
|
|
57
|
+
// 1. The shape `datetime-local` demands — the whole point of #3127.
|
|
58
|
+
expect(shown).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
|
59
|
+
// 2. …denoting the SAME instant the record holds.
|
|
60
|
+
expect(localInstantOf(shown)).toBe(new Date(iso).getTime());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('truncates an already-local datetime to minutes without re-parsing it', () => {
|
|
64
|
+
expect(toDateTimeInputValue('2026-06-17T14:30')).toBe('2026-06-17T14:30');
|
|
65
|
+
expect(toDateTimeInputValue('2026-06-17T14:30:59')).toBe('2026-06-17T14:30');
|
|
66
|
+
expect(toDateTimeInputValue('2026-06-17T14:30:59.123')).toBe('2026-06-17T14:30');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('maps empty and unparseable input to an empty control', () => {
|
|
70
|
+
expect(toDateTimeInputValue(null)).toBe('');
|
|
71
|
+
expect(toDateTimeInputValue(undefined)).toBe('');
|
|
72
|
+
expect(toDateTimeInputValue('')).toBe('');
|
|
73
|
+
expect(toDateTimeInputValue('not a date')).toBe('');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('fromDateTimeInputValue', () => {
|
|
78
|
+
it('reads the control back on the same basis it was written', () => {
|
|
79
|
+
const local = '2026-06-17T14:30';
|
|
80
|
+
const stored = fromDateTimeInputValue(local);
|
|
81
|
+
|
|
82
|
+
expect(stored).toMatch(/Z$/);
|
|
83
|
+
expect(new Date(stored).getTime()).toBe(localInstantOf(local));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('round-trips an ISO instant through the control unchanged', () => {
|
|
87
|
+
// The invariant that makes the read/write pair safe: opening an editor and
|
|
88
|
+
// re-picking the same minute must not move the record.
|
|
89
|
+
const iso = '2026-06-17T14:30:00.000Z';
|
|
90
|
+
expect(fromDateTimeInputValue(toDateTimeInputValue(iso))).toBe(iso);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns an unparseable value untouched rather than blanking it', () => {
|
|
94
|
+
expect(fromDateTimeInputValue('garbage')).toBe('garbage');
|
|
95
|
+
expect(fromDateTimeInputValue('')).toBe('');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The producer side of objectstack#5061. This function's output is not just
|
|
100
|
+
* "an ISO string" by convention — it IS the platform's `datetime` value
|
|
101
|
+
* contract, and `ActionParamDialog` now relies on that: it POSTs the widget's
|
|
102
|
+
* value with no conversion of its own, so a change here that dropped the zone
|
|
103
|
+
* (or emitted the control's naive wall clock) would make every datetime action
|
|
104
|
+
* param 400 again. Pinned against the real schema rather than a regex so the
|
|
105
|
+
* two cannot drift.
|
|
106
|
+
*/
|
|
107
|
+
it('emits a value the platform datetime contract accepts (InstantValueSchema)', () => {
|
|
108
|
+
expect(InstantValueSchema.safeParse(fromDateTimeInputValue('2026-06-17T14:30')).success).toBe(true);
|
|
109
|
+
// The control may include seconds; those must survive into the instant.
|
|
110
|
+
expect(InstantValueSchema.safeParse(fromDateTimeInputValue('2026-06-17T14:30:45')).success).toBe(true);
|
|
111
|
+
// The shape the contract rejects — what the control itself hands us, and so
|
|
112
|
+
// exactly what must NOT come back out of this function.
|
|
113
|
+
expect(InstantValueSchema.safeParse('2026-06-17T14:30').success).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|