@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.
Files changed (156) hide show
  1. package/CHANGELOG.md +941 -0
  2. package/README.md +1 -12
  3. package/dist/FieldEditWidget.d.ts +7 -1
  4. package/dist/currency.d.ts +46 -0
  5. package/dist/field-type-alias.d.ts +19 -1
  6. package/dist/index.cjs +4 -4
  7. package/dist/index.css +633 -0
  8. package/dist/index.d.ts +48 -12
  9. package/dist/index.js +5026 -4933
  10. package/dist/widgets/AddressField.d.ts +3 -11
  11. package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
  12. package/dist/widgets/GridField.d.ts +12 -2
  13. package/dist/widgets/OptionsEmptyState.d.ts +15 -1
  14. package/dist/widgets/RecordPickerDialog.d.ts +32 -6
  15. package/dist/widgets/SelectField.d.ts +25 -5
  16. package/dist/widgets/address-format.d.ts +68 -0
  17. package/dist/widgets/file-size-guard.d.ts +41 -0
  18. package/dist/widgets/nativeDateValue.d.ts +37 -0
  19. package/dist/widgets/toDomProps.d.ts +30 -10
  20. package/dist/widgets/toHostGroupProps.d.ts +91 -0
  21. package/dist/widgets/useRecordQuery.d.ts +7 -1
  22. package/package.json +17 -13
  23. package/src/FieldEditWidget.tsx +17 -1
  24. package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
  25. package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
  26. package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
  27. package/src/__tests__/DateCellRenderer.test.tsx +9 -0
  28. package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
  29. package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
  30. package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
  31. package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
  32. package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
  33. package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
  34. package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
  35. package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
  36. package/src/__tests__/date-locale-channel.test.tsx +305 -0
  37. package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
  38. package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
  39. package/src/__tests__/group-labelling-declaration.test.ts +148 -0
  40. package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
  41. package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
  42. package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
  43. package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
  44. package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
  45. package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
  46. package/src/complex-widgets.test.tsx +22 -6
  47. package/src/currency.ts +130 -0
  48. package/src/datetime-widgets.test.tsx +94 -2
  49. package/src/field-type-alias.multiple.test.ts +81 -0
  50. package/src/field-type-alias.ts +58 -2
  51. package/src/index.css +44 -0
  52. package/src/index.tsx +489 -247
  53. package/src/standard-widgets.test.tsx +8 -1
  54. package/src/widgets/AddressField.postalCode.test.tsx +257 -0
  55. package/src/widgets/AddressField.tsx +79 -29
  56. package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
  57. package/src/widgets/BooleanField.tsx +41 -7
  58. package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
  59. package/src/widgets/CheckboxesField.tsx +35 -2
  60. package/src/widgets/CurrencyField.tsx +55 -15
  61. package/src/widgets/DateField.tsx +11 -2
  62. package/src/widgets/DateTimeField.tsx +12 -3
  63. package/src/widgets/FileField.tsx +27 -9
  64. package/src/widgets/FormulaField.tsx +6 -1
  65. package/src/widgets/FullscreenFieldEditor.tsx +47 -258
  66. package/src/widgets/GeolocationField.tsx +29 -3
  67. package/src/widgets/GridField.test.tsx +226 -0
  68. package/src/widgets/GridField.tsx +114 -10
  69. package/src/widgets/ImageField.maxSize.test.tsx +154 -0
  70. package/src/widgets/ImageField.tsx +54 -4
  71. package/src/widgets/LookupField.hydration.test.tsx +1 -1
  72. package/src/widgets/LookupField.tsx +8 -4
  73. package/src/widgets/MultiSelectField.tsx +56 -2
  74. package/src/widgets/OptionsEmptyState.tsx +16 -0
  75. package/src/widgets/RadioField.tsx +35 -3
  76. package/src/widgets/RatingField.tsx +15 -2
  77. package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
  78. package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
  79. package/src/widgets/RecordPickerDialog.tsx +64 -15
  80. package/src/widgets/RichTextField.tsx +1 -1
  81. package/src/widgets/SelectField.tsx +36 -5
  82. package/src/widgets/UserField.tsx +3 -2
  83. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
  84. package/src/widgets/address-format.ts +120 -0
  85. package/src/widgets/file-size-guard.ts +55 -0
  86. package/src/widgets/nativeDateValue.test.ts +115 -0
  87. package/src/widgets/nativeDateValue.ts +112 -0
  88. package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
  89. package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
  90. package/src/widgets/toDomProps.ts +48 -19
  91. package/src/widgets/toHostGroupProps.ts +102 -0
  92. package/src/widgets/useFieldTranslation.ts +15 -33
  93. package/src/widgets/useRecordQuery.ts +30 -3
  94. package/dist/FieldEditWidget.test.d.ts +0 -8
  95. package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
  96. package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
  97. package/dist/__tests__/cell-truncation.test.d.ts +0 -0
  98. package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
  99. package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
  100. package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
  101. package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
  102. package/dist/__tests__/registry-collision.test.d.ts +0 -0
  103. package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
  104. package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
  105. package/dist/__tests__/validation-feedback.test.d.ts +0 -0
  106. package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
  107. package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
  108. package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
  109. package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
  110. package/dist/coerce-safe-value.test.d.ts +0 -1
  111. package/dist/complex-widgets.test.d.ts +0 -1
  112. package/dist/datetime-widgets.test.d.ts +0 -1
  113. package/dist/field-type-coverage.test.d.ts +0 -1
  114. package/dist/standard-widgets.test.d.ts +0 -0
  115. package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
  116. package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
  117. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  118. package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
  119. package/dist/widgets/GridField.test.d.ts +0 -1
  120. package/dist/widgets/ImageLightbox.test.d.ts +0 -1
  121. package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
  122. package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
  123. package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
  124. package/dist/widgets/LookupField.idField.test.d.ts +0 -1
  125. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  126. package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
  127. package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
  128. package/dist/widgets/PeoplePicker.test.d.ts +0 -8
  129. package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
  130. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  131. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  132. package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
  133. package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
  134. package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
  135. package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
  136. package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
  137. package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
  138. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
  139. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
  140. package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
  141. package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
  142. package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
  143. package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
  144. package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
  145. package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
  146. package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
  147. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
  148. package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
  149. package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
  150. package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
  151. package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
  152. package/dist/widgets/file-value.test.d.ts +0 -1
  153. package/dist/widgets/personDisplay.test.d.ts +0 -8
  154. package/dist/widgets/recentLookups.test.d.ts +0 -1
  155. package/dist/widgets/useRecordQuery.test.d.ts +0 -8
  156. 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
- import { useSafeFieldLabel } from '@object-ui/i18n';
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 constraints applied to every query, in QueryParams.$filter
364
- * record form. Unlike `lookupFilters`, entries here never surface in the
365
- * filter bar and cannot be overridden by user filter input — used for the
366
- * dependent (cascading) lookup chain, where the parent field's value MUST
367
- * scope the candidate set (#2215).
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?: Record<string, any>;
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. The hard
590
- // `baseFilter` constraint (dependent-lookup chain, #2215) is spread LAST so
591
- // user filter-bar input can never widen it back out.
592
- const mergedFilter = useMemo<Record<string, any> | undefined>(() => {
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 combined = { ...lookupBase, ...userFilter, ...(baseFilter ?? {}) };
600
- return Object.keys(combined).length > 0 ? combined : undefined;
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. Delegating here (rather than
25
- * only at a type-resolution layer) means every surface that renders the
26
- * `select` widget — the object form, the inline grid editor, and
27
- * `ActionParamDialog` — inherits multi-select identically, with no drift
28
- * between them. Single-value selects keep the cascading dropdown below.
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` when rendered via the createFieldRenderer wrapper — mirror the
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 !== '$eq' && op !== '$between' && !emitted.has(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
+ });