@object-ui/fields 17.2.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 +1533 -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 +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- 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 +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- 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__/cell-truncation.test.tsx +96 -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__/field-carrier-sdui.test.tsx +169 -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__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- 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 +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- 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 +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- 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__/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-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/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.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.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/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/SelectField.cascade.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/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
package/src/index.tsx
CHANGED
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
|
|
11
11
|
import { ComponentRegistry, percentDisplayValue, getRecordDisplayName } from '@object-ui/core';
|
|
12
|
-
import { useLocalization } from '@object-ui/i18n';
|
|
12
|
+
import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n';
|
|
13
13
|
import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
|
|
14
14
|
import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
|
|
15
15
|
import { useObjectTranslation } from '@object-ui/react';
|
|
16
16
|
import { SchemaRendererContext as _SchemaRendererContext } from '@object-ui/react';
|
|
17
|
+
import { useRelatedRecordActions } from '@object-ui/react';
|
|
18
|
+
import { withFieldCarrier } from './withFieldCarrier';
|
|
19
|
+
// Pure formatting rule shared with `AddressField`'s readonly branch — no React,
|
|
20
|
+
// so this does not pull the widget out of its lazy chunk (objectui#4037).
|
|
21
|
+
import { formatAddress, type AddressValue } from './widgets/address-format';
|
|
17
22
|
|
|
18
23
|
// Module-level cache so multiple renderers fetching the same lookup ID
|
|
19
24
|
// only trigger one network call. Keyed by `${objectName}:${id}`.
|
|
@@ -284,46 +289,15 @@ function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) =
|
|
|
284
289
|
return t as (key: string, params?: Record<string, unknown>) => string;
|
|
285
290
|
}
|
|
286
291
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
import { UrlField } from './widgets/UrlField';
|
|
295
|
-
import { CurrencyField } from './widgets/CurrencyField';
|
|
296
|
-
import { TextAreaField } from './widgets/TextAreaField';
|
|
297
|
-
import { RichTextField } from './widgets/RichTextField';
|
|
298
|
-
import { LookupField } from './widgets/LookupField';
|
|
299
|
-
import { CapabilityMultiSelectField } from './widgets/CapabilityMultiSelectField';
|
|
300
|
-
import { DateTimeField } from './widgets/DateTimeField';
|
|
301
|
-
import { TimeField } from './widgets/TimeField';
|
|
302
|
-
import { PercentField } from './widgets/PercentField';
|
|
303
|
-
import { PasswordField } from './widgets/PasswordField';
|
|
304
|
-
import { FileField } from './widgets/FileField';
|
|
305
|
-
import { ImageField } from './widgets/ImageField';
|
|
292
|
+
// Only the two symbols this module actually CALLS are imported eagerly. Every
|
|
293
|
+
// field widget used to be imported here as well, statically, purely to hand a
|
|
294
|
+
// component reference to the docs-demo `createFieldRenderer()` wrapper —
|
|
295
|
+
// removed with it (objectui#3910). The live registration path resolves widgets
|
|
296
|
+
// through the lazy loaders in `fieldWidgetMap` below, so nothing here needs a
|
|
297
|
+
// static reference; the widgets stay publicly available via the `export * from
|
|
298
|
+
// './widgets/…'` block at the end of this file.
|
|
306
299
|
import { ImageLightbox } from './widgets/ImageLightbox';
|
|
307
300
|
import { readFileValues } from './widgets/file-value';
|
|
308
|
-
import { LocationField } from './widgets/LocationField';
|
|
309
|
-
import { FormulaField } from './widgets/FormulaField';
|
|
310
|
-
import { SummaryField } from './widgets/SummaryField';
|
|
311
|
-
import { AutoNumberField } from './widgets/AutoNumberField';
|
|
312
|
-
import { UserField } from './widgets/UserField';
|
|
313
|
-
import { ObjectField } from './widgets/ObjectField';
|
|
314
|
-
import { VectorField } from './widgets/VectorField';
|
|
315
|
-
import { GridField } from './widgets/GridField';
|
|
316
|
-
// New widgets according to @objectstack/spec
|
|
317
|
-
import { ColorField } from './widgets/ColorField';
|
|
318
|
-
import { SliderField } from './widgets/SliderField';
|
|
319
|
-
import { RatingField } from './widgets/RatingField';
|
|
320
|
-
import { CodeField } from './widgets/CodeField';
|
|
321
|
-
import { AvatarField } from './widgets/AvatarField';
|
|
322
|
-
import { AddressField } from './widgets/AddressField';
|
|
323
|
-
import { GeolocationField } from './widgets/GeolocationField';
|
|
324
|
-
import { SignatureField } from './widgets/SignatureField';
|
|
325
|
-
import { QRCodeField } from './widgets/QRCodeField';
|
|
326
|
-
import { MasterDetailField } from './widgets/MasterDetailField';
|
|
327
301
|
|
|
328
302
|
/**
|
|
329
303
|
* Cell renderer props
|
|
@@ -384,27 +358,56 @@ export function coerceToSafeValue(value: unknown): string | number | boolean | n
|
|
|
384
358
|
* USD for unconfigured currency fields was the #1 source of "why is my
|
|
385
359
|
* RMB amount showing as dollars?" bug reports.
|
|
386
360
|
*
|
|
387
|
-
* Trailing
|
|
388
|
-
* convention: `$1,234.50` keeps cents; `$1,234` does not.
|
|
361
|
+
* Trailing minor units are dropped when the value is a whole number —
|
|
362
|
+
* Salesforce convention: `$1,234.50` keeps cents; `$1,234` does not. Wholeness
|
|
363
|
+
* picks ONE fraction-digit width, never a range: a whole amount shows 0 digits,
|
|
364
|
+
* and a fractional amount shows the width the CURRENCY has — so a real cents
|
|
365
|
+
* value of `.50` renders `.50`, not `.5`, and a yen amount renders `¥1,235`
|
|
366
|
+
* rather than cents the yen does not have.
|
|
389
367
|
*/
|
|
390
|
-
import { resolveFieldCurrency } from './currency';
|
|
368
|
+
import { resolveFieldCurrency, currencyFractionDigits } from './currency';
|
|
391
369
|
export { resolveFieldCurrency };
|
|
392
370
|
|
|
393
|
-
export function formatCurrency(value: number, currency?: string): string {
|
|
371
|
+
export function formatCurrency(value: number, currency?: string, locale?: string): string {
|
|
394
372
|
const isWhole = Number.isFinite(value) && value === Math.trunc(value);
|
|
395
|
-
|
|
373
|
+
// ONE width for both bounds, not a range (objectui#4332). The symbol branch
|
|
374
|
+
// used to pass `minimumFractionDigits: 0` against a wholeness-switched
|
|
375
|
+
// maximum, which handed Intl the range [0, 2] — and Intl then emits the
|
|
376
|
+
// SHORTEST representation in range, so a genuine cents value of `.50` was
|
|
377
|
+
// printed as `.5`: `$1,234.5` instead of the `$1,234.50` promised above.
|
|
378
|
+
// It was the only branch that did: the no-currency branch reaches
|
|
379
|
+
// `formatNumber`, which sets both bounds to the width it is given, and the
|
|
380
|
+
// bad-currency fallback below has always used `toFixed`. Both already
|
|
381
|
+
// rendered `1,234.50` for the same amount.
|
|
382
|
+
//
|
|
383
|
+
// The non-whole width is the CURRENCY's own ISO 4217 minor-unit count, not a
|
|
384
|
+
// literal 2 (objectui#4361). Passing 2 for every currency on earth OVERRODE
|
|
385
|
+
// what `Intl` already knows: JPY has no minor unit and KWD has three, so a
|
|
386
|
+
// yen amount was printed with cents it does not have (`¥1,234.50`) and a
|
|
387
|
+
// dinar amount one digit short (`KWD 1.50`).
|
|
388
|
+
//
|
|
389
|
+
// The wholeness switch itself is NOT retired — dropping both bounds and
|
|
390
|
+
// letting `Intl` decide would fix the digit count by turning `$1,234` back
|
|
391
|
+
// into `$1,234.00`, which is exactly the convention this function documents
|
|
392
|
+
// and objectui#4033 pinned. It is extended instead: whole amounts drop the
|
|
393
|
+
// fraction for EVERY currency (`KWD 1`, not `KWD 1.000`), fractional amounts
|
|
394
|
+
// take that currency's own count.
|
|
395
|
+
//
|
|
396
|
+
// With no currency in hand there is nothing to derive from, so that branch
|
|
397
|
+
// keeps the historical 2.
|
|
398
|
+
const fracDigits = isWhole ? 0 : currency ? currencyFractionDigits(currency) : 2;
|
|
396
399
|
if (!currency) {
|
|
397
|
-
return formatNumber(value,
|
|
400
|
+
return formatNumber(value, fracDigits, locale);
|
|
398
401
|
}
|
|
399
402
|
try {
|
|
400
|
-
return
|
|
401
|
-
|
|
403
|
+
return formatDisplayNumber(value, {
|
|
404
|
+
locale,
|
|
402
405
|
currency,
|
|
403
|
-
minimumFractionDigits:
|
|
404
|
-
maximumFractionDigits:
|
|
405
|
-
})
|
|
406
|
+
minimumFractionDigits: fracDigits,
|
|
407
|
+
maximumFractionDigits: fracDigits,
|
|
408
|
+
});
|
|
406
409
|
} catch {
|
|
407
|
-
return `${currency} ${value.toFixed(
|
|
410
|
+
return `${currency} ${value.toFixed(fracDigits)}`;
|
|
408
411
|
}
|
|
409
412
|
}
|
|
410
413
|
|
|
@@ -413,25 +416,26 @@ export function formatCurrency(value: number, currency?: string): string {
|
|
|
413
416
|
* E.g., $150,000 → $150K, $1,200,000 → $1.2M
|
|
414
417
|
* When `currency` is undefined, returns a compact number without symbol.
|
|
415
418
|
*/
|
|
416
|
-
export function formatCompactCurrency(value: number, currency?: string): string {
|
|
419
|
+
export function formatCompactCurrency(value: number, currency?: string, locale?: string): string {
|
|
417
420
|
if (!currency) {
|
|
418
421
|
try {
|
|
419
|
-
const formatted =
|
|
422
|
+
const formatted = formatDisplayNumber(value, {
|
|
423
|
+
locale,
|
|
420
424
|
notation: 'compact',
|
|
421
425
|
maximumFractionDigits: 1,
|
|
422
|
-
})
|
|
426
|
+
});
|
|
423
427
|
return formatted.replace(/\.0(?=[KMBT])/, '');
|
|
424
428
|
} catch {
|
|
425
429
|
return String(value);
|
|
426
430
|
}
|
|
427
431
|
}
|
|
428
432
|
try {
|
|
429
|
-
const formatted =
|
|
430
|
-
|
|
433
|
+
const formatted = formatDisplayNumber(value, {
|
|
434
|
+
locale,
|
|
431
435
|
currency,
|
|
432
436
|
notation: 'compact',
|
|
433
437
|
maximumFractionDigits: 1,
|
|
434
|
-
})
|
|
438
|
+
});
|
|
435
439
|
// Strip trailing ".0" before compact suffix for consistent cross-environment output
|
|
436
440
|
// e.g. "$150.0K" → "$150K" while keeping "$1.5M" intact
|
|
437
441
|
return formatted.replace(/\.0(?=[KMBT])/, '');
|
|
@@ -445,26 +449,95 @@ export function formatCompactCurrency(value: number, currency?: string): string
|
|
|
445
449
|
* Used as a safe fallback when a currency-typed field has no `currency`
|
|
446
450
|
* configured — we'd rather render `1,234.50` than silently assume USD.
|
|
447
451
|
*/
|
|
448
|
-
export function formatNumber(value: number, decimals: number = 2): string {
|
|
452
|
+
export function formatNumber(value: number, decimals: number = 2, locale?: string): string {
|
|
449
453
|
try {
|
|
450
|
-
|
|
454
|
+
// Deliberately passes NO `scale`: `decimals` here is a display width the
|
|
455
|
+
// caller chose, not a field's declared scale, so the ordinal no-grouping
|
|
456
|
+
// policy must not fire. `formatCurrency`'s no-currency fallback lands here
|
|
457
|
+
// with `decimals: 0` for a whole amount — that is still money and must keep
|
|
458
|
+
// its separators.
|
|
459
|
+
return formatDisplayNumber(value, {
|
|
460
|
+
locale,
|
|
451
461
|
minimumFractionDigits: decimals,
|
|
452
462
|
maximumFractionDigits: decimals,
|
|
453
|
-
})
|
|
463
|
+
});
|
|
454
464
|
} catch {
|
|
455
465
|
return value.toFixed(decimals);
|
|
456
466
|
}
|
|
457
467
|
}
|
|
458
468
|
|
|
459
469
|
/**
|
|
460
|
-
*
|
|
470
|
+
* The percent rendering itself, on a value ALREADY in display magnitude (`80`
|
|
471
|
+
* means 80%).
|
|
472
|
+
*
|
|
473
|
+
* Split out from {@link formatPercent} because `PercentCellRenderer`'s
|
|
474
|
+
* whole-percent branch needs this exact rendering under a DIFFERENT scaling
|
|
475
|
+
* policy (see there). Two copies of the expression is precisely the drift
|
|
476
|
+
* `percentDisplayValue`'s doc comment exists to prevent, so there is one copy
|
|
477
|
+
* and the scaling decision is made by the caller.
|
|
478
|
+
*/
|
|
479
|
+
function formatPercentBody(displayValue: number, precision: number, locale?: string): string {
|
|
480
|
+
try {
|
|
481
|
+
// `style: 'percentPoints'` renders a value that is ALREADY in percentage
|
|
482
|
+
// points, so there is no `/ 100` here. Going through `Intl` rather than
|
|
483
|
+
// appending a literal '%' is what buys the locale's percent CONVENTION and
|
|
484
|
+
// not merely its separators: German writes `1.235 %` with a no-break space
|
|
485
|
+
// before the sign, English `1,235%` with none, Turkish puts the sign in
|
|
486
|
+
// FRONT. Both bounds are set to `precision` so the width is exactly the one
|
|
487
|
+
// the caller asked for — the same contract `toFixed` gave.
|
|
488
|
+
//
|
|
489
|
+
// ⚠️ NOT `style: 'percent'` (objectui#4590). That style wants a FRACTION, so
|
|
490
|
+
// this used to divide by 100 for `Intl` to multiply straight back — and the
|
|
491
|
+
// round trip is not value-preserving. `Intl` formats from the SHORTEST
|
|
492
|
+
// decimal representation of the double it is handed, and the quotient's is
|
|
493
|
+
// not the authored one: `1.005` is `1.005`, but `1.005 / 100` is
|
|
494
|
+
// `0.010049999999999999`, which percent-scales to `1.0049999999999999` and
|
|
495
|
+
// rounds DOWN — so a stored 1.005 rendered `1.00%` where half-up is `1.01%`.
|
|
496
|
+
// The DIVISION lost the digit, not the rounding, which is why it reproduced
|
|
497
|
+
// in every locale and why 27,577 of 1,200,003 ordinary en-US forms moved
|
|
498
|
+
// (0.005-step grid to 2,000, precisions 0/1/2), every one a last-digit
|
|
499
|
+
// off-by-one. The same artefact reached the top of the double range:
|
|
500
|
+
// `MAX_SAFE_INTEGER` points rendered `…740,990%` for `…740,991%`.
|
|
501
|
+
//
|
|
502
|
+
// The affix is unchanged by the switch: `'percentPoints'` is `Intl`'s
|
|
503
|
+
// `style: 'unit'` / `unit: 'percent'` / `unitDisplay: 'narrow'`, measured
|
|
504
|
+
// byte-identical to `style: 'percent'` across all 171 locale tags in #4576
|
|
505
|
+
// and re-measured on THIS call shape in #4590 — 720 combinations (10 locales
|
|
506
|
+
// x 18 values x 4 precisions), 0 convention diffs, 130 numeral diffs.
|
|
507
|
+
// `formatMeasure` renders through the same option, so a percentage point
|
|
508
|
+
// reads identically in a list cell and in a dashboard measure.
|
|
509
|
+
return formatDisplayNumber(displayValue, {
|
|
510
|
+
locale,
|
|
511
|
+
style: 'percentPoints',
|
|
512
|
+
minimumFractionDigits: precision,
|
|
513
|
+
maximumFractionDigits: precision,
|
|
514
|
+
});
|
|
515
|
+
} catch {
|
|
516
|
+
return `${displayValue.toFixed(precision)}%`;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Format percent value.
|
|
461
522
|
* Handles both decimal (0.8 = 80%) and whole number (80 = 80%) inputs.
|
|
523
|
+
*
|
|
524
|
+
* `locale` is the third positional parameter, matching {@link formatNumber} and
|
|
525
|
+
* {@link formatCurrency} — the shape the sibling formatters already use.
|
|
526
|
+
* Callers should pass the tag from `useDisplayLocale()`.
|
|
527
|
+
*
|
|
528
|
+
* Before objectui#4553 this function took no locale and never touched `Intl`:
|
|
529
|
+
* its whole body was `${percentDisplayValue(value).toFixed(precision)}%`, so it
|
|
530
|
+
* rendered in NO locale rather than the machine's — an ASCII decimal mark and
|
|
531
|
+
* never a grouping separator, byte-identical on every machine. That made
|
|
532
|
+
* `1235%` the output everywhere, which is wrong in en-US as well as in German,
|
|
533
|
+
* so the grouping and the locale are fixed together: en output MOVES from
|
|
534
|
+
* `1235%` to `1,235%` at four digits and up, and that move is the fix.
|
|
462
535
|
*/
|
|
463
|
-
export function formatPercent(value: number, precision: number = 0): string {
|
|
536
|
+
export function formatPercent(value: number, precision: number = 0, locale?: string): string {
|
|
464
537
|
// Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so
|
|
465
538
|
// the list cell and the dashboard measure formatter (`formatMeasure`) agree.
|
|
466
539
|
const displayValue = percentDisplayValue(value);
|
|
467
|
-
return
|
|
540
|
+
return formatPercentBody(displayValue, precision, locale);
|
|
468
541
|
}
|
|
469
542
|
|
|
470
543
|
/**
|
|
@@ -548,8 +621,13 @@ export function formatDate(value: string | Date | number, style?: string, option
|
|
|
548
621
|
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
549
622
|
|
|
550
623
|
if (style === 'short') {
|
|
551
|
-
// Compact format for mobile: "Jan 15, '24"
|
|
552
|
-
|
|
624
|
+
// Compact format for mobile: "Jan 15, '24" / "1月 15, '24".
|
|
625
|
+
// Only the MONTH token is localized: the surrounding compact shape (day,
|
|
626
|
+
// apostrophe + 2-digit year) is a deliberate fixed layout for narrow
|
|
627
|
+
// cards, not a locale-derived one. The tag comes from `options.locale`
|
|
628
|
+
// like the default branch below — hardcoding `'en-US'` here made this the
|
|
629
|
+
// one branch that ignored a locale its caller had threaded (objectui#4272).
|
|
630
|
+
const month = date.toLocaleDateString(options?.locale, { month: 'short' });
|
|
553
631
|
const day = date.getDate();
|
|
554
632
|
const year = String(date.getFullYear()).slice(-2);
|
|
555
633
|
return `${month} ${day}, '${year}`;
|
|
@@ -573,14 +651,22 @@ export function formatDate(value: string | Date | number, style?: string, option
|
|
|
573
651
|
}
|
|
574
652
|
|
|
575
653
|
/**
|
|
576
|
-
* Format datetime value
|
|
654
|
+
* Format datetime value.
|
|
655
|
+
*
|
|
656
|
+
* `options` mirrors {@link formatDate}'s and is optional, so an existing
|
|
657
|
+
* caller that passes nothing keeps the exact runtime-default behavior it had.
|
|
658
|
+
* Before objectui#4272 the parameter did not exist at all, which meant no
|
|
659
|
+
* caller could localize this function however hard it tried — it always handed
|
|
660
|
+
* `Intl` an `undefined` tag, i.e. the MACHINE's locale, which is neither of
|
|
661
|
+
* the repo's two locale channels. Callers should pass the tag from
|
|
662
|
+
* `useDisplayLocale()`.
|
|
577
663
|
*/
|
|
578
|
-
export function formatDateTime(value: string | Date | number): string {
|
|
664
|
+
export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string {
|
|
579
665
|
if (value === null || value === undefined || value === '') return '—';
|
|
580
666
|
const date = value instanceof Date ? value : new Date(value as any);
|
|
581
667
|
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
582
|
-
|
|
583
|
-
return date.toLocaleDateString(
|
|
668
|
+
|
|
669
|
+
return date.toLocaleDateString(options?.locale, {
|
|
584
670
|
year: 'numeric',
|
|
585
671
|
month: 'short',
|
|
586
672
|
day: 'numeric',
|
|
@@ -589,21 +675,54 @@ export function formatDateTime(value: string | Date | number): string {
|
|
|
589
675
|
});
|
|
590
676
|
}
|
|
591
677
|
|
|
678
|
+
/**
|
|
679
|
+
* Single-line cell value with a working ellipsis and a full-text fallback.
|
|
680
|
+
*
|
|
681
|
+
* `truncate` on a bare inline `<span>` never clips — an inline box has no
|
|
682
|
+
* width box for `overflow:hidden` / `text-overflow:ellipsis` to act on, so
|
|
683
|
+
* the value renders at full content width and the tail is silently cut by
|
|
684
|
+
* whatever ancestor happens to clip (the detail card edge), with no ellipsis
|
|
685
|
+
* (objectui#3466; same mechanism as the JSON cell in objectui#2578).
|
|
686
|
+
* Block-level + `max-w-full` gives the span its parent's width to truncate
|
|
687
|
+
* against, and as a flex item its `overflow:hidden` zeroes the automatic
|
|
688
|
+
* min-size so it can shrink below the text width. The `title` keeps the full
|
|
689
|
+
* text reachable on hover — host rows (detail sections) put their inline-edit
|
|
690
|
+
* hint in *their* `title`, so the value's full text must live on the value
|
|
691
|
+
* element itself, where it takes precedence under the cursor.
|
|
692
|
+
*/
|
|
693
|
+
function TruncatedText({
|
|
694
|
+
text,
|
|
695
|
+
className,
|
|
696
|
+
}: {
|
|
697
|
+
text: string;
|
|
698
|
+
className?: string;
|
|
699
|
+
}): React.ReactElement {
|
|
700
|
+
return (
|
|
701
|
+
<span className={cn('block max-w-full truncate', className)} title={text}>
|
|
702
|
+
{text}
|
|
703
|
+
</span>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
592
707
|
/**
|
|
593
708
|
* Text field cell renderer
|
|
594
709
|
*/
|
|
595
710
|
export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
596
711
|
const safe = coerceToSafeValue(value);
|
|
597
712
|
if (safe == null || safe === '') return <EmptyValue />;
|
|
598
|
-
return <
|
|
713
|
+
return <TruncatedText text={String(safe)} />;
|
|
599
714
|
}
|
|
600
715
|
|
|
601
716
|
/**
|
|
602
717
|
* Number field cell renderer
|
|
603
718
|
*/
|
|
604
719
|
export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
720
|
+
// Hook before the empty-value early return — a value flipping between null
|
|
721
|
+
// and set must not change the hook count between renders (same rule as
|
|
722
|
+
// CurrencyCellRenderer below).
|
|
723
|
+
const locale = useDisplayLocale();
|
|
605
724
|
if (value == null) return <EmptyValue />;
|
|
606
|
-
|
|
725
|
+
|
|
607
726
|
const safe = coerceToSafeValue(value);
|
|
608
727
|
const numField = field as any;
|
|
609
728
|
// Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
|
|
@@ -614,13 +733,22 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
614
733
|
// scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
|
|
615
734
|
// keep the minimum at 0 so trailing zeros are trimmed and only cap the
|
|
616
735
|
// maximum (20 = Intl max) to preserve the value's natural precision.
|
|
736
|
+
//
|
|
737
|
+
// `scale` is also the grouping POLICY input (objectui#4033): a declared
|
|
738
|
+
// `scale: 0` with no currency is a discrete integer — a year, a fiscal
|
|
739
|
+
// period, an ordinal — and those are rendered ungrouped, so a `Field.number`
|
|
740
|
+
// year finally shows `2026` instead of `2,026`. An ABSENT scale keeps
|
|
741
|
+
// grouping: absent means "decimals unknown", not "integer". The policy and
|
|
742
|
+
// its interim status live in `formatDisplayNumber`, not here.
|
|
617
743
|
const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
|
|
618
744
|
const num = Number(safe);
|
|
619
745
|
const formatted = !isNaN(num)
|
|
620
|
-
?
|
|
746
|
+
? formatDisplayNumber(num, {
|
|
747
|
+
locale,
|
|
748
|
+
scale,
|
|
621
749
|
minimumFractionDigits: scale ?? 0,
|
|
622
750
|
maximumFractionDigits: scale ?? 20,
|
|
623
|
-
})
|
|
751
|
+
})
|
|
624
752
|
: String(safe);
|
|
625
753
|
|
|
626
754
|
return <span className="tabular-nums">{formatted}</span>;
|
|
@@ -633,6 +761,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
|
|
|
633
761
|
// Hooks before the empty-value early return — a value flipping between
|
|
634
762
|
// null and set must not change the hook count between renders.
|
|
635
763
|
const { currency: tenantCurrency } = useLocalization();
|
|
764
|
+
const locale = useDisplayLocale();
|
|
636
765
|
if (value == null) return <EmptyValue />;
|
|
637
766
|
|
|
638
767
|
const safe = coerceToSafeValue(value);
|
|
@@ -643,7 +772,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
|
|
|
643
772
|
const currency = resolveFieldCurrency(field as any, tenantCurrency);
|
|
644
773
|
const num = Number(safe);
|
|
645
774
|
const formatted = !isNaN(num)
|
|
646
|
-
? formatCurrency(num, currency)
|
|
775
|
+
? formatCurrency(num, currency, locale)
|
|
647
776
|
: String(safe);
|
|
648
777
|
|
|
649
778
|
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
@@ -656,8 +785,12 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
|
|
|
656
785
|
* Percent field cell renderer with mini progress bar
|
|
657
786
|
*/
|
|
658
787
|
export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
788
|
+
// Hook before the empty-value early return — a value flipping between null
|
|
789
|
+
// and set must not change the hook count between renders (same rule as
|
|
790
|
+
// NumberCellRenderer / CurrencyCellRenderer above).
|
|
791
|
+
const locale = useDisplayLocale();
|
|
659
792
|
if (value == null) return <EmptyValue />;
|
|
660
|
-
|
|
793
|
+
|
|
661
794
|
const safe = coerceToSafeValue(value);
|
|
662
795
|
const percentField = field as any;
|
|
663
796
|
const precision = percentField.precision ?? 0;
|
|
@@ -671,13 +804,35 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
671
804
|
const barValue = isWholePercentField
|
|
672
805
|
? numValue
|
|
673
806
|
: (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
|
|
674
|
-
|
|
807
|
+
// Both branches render through the same locale-aware body (objectui#4553);
|
|
808
|
+
// they differ ONLY in the scaling policy, which is the whole point of the
|
|
809
|
+
// branch. The whole-percent branch used to be a second bare `toFixed` path,
|
|
810
|
+
// so before this card a `progress` field was ungrouped and unlocalized even
|
|
811
|
+
// where an ordinary percent column would not have been — leaving it behind
|
|
812
|
+
// would have made ONE grid internally inconsistent, which is worse than the
|
|
813
|
+
// uniform defect it had.
|
|
814
|
+
const formatted = isWholePercentField
|
|
815
|
+
? formatPercentBody(numValue, precision, locale)
|
|
816
|
+
: formatPercent(numValue, precision, locale);
|
|
675
817
|
const clampedBar = Math.max(0, Math.min(100, barValue));
|
|
676
818
|
|
|
819
|
+
// Layout contract (objectstack#5066): THE NUMBER IS THE CONTENT, THE BAR IS
|
|
820
|
+
// DECORATION. The bar used to be `w-16 shrink-0` while the value span was
|
|
821
|
+
// shrinkable, so in a narrow clipping container — a `record:highlights` chip
|
|
822
|
+
// is `basis-[9rem]`/`min-w-[7rem]` and clips with `truncate` — the 64px bar
|
|
823
|
+
// took the space and the value was silently cut mid-digit: a stored `33.33`
|
|
824
|
+
// rendered `33%` in the DOM but read as `3` on screen, with no ellipsis and
|
|
825
|
+
// nothing in the accessible name to signal the loss.
|
|
826
|
+
//
|
|
827
|
+
// So the priority is inverted: the value span is `shrink-0` (never sacrificed)
|
|
828
|
+
// and the bar keeps `w-16` only as its PREFERRED width, free to shrink away
|
|
829
|
+
// under pressure. It is deliberately NOT `flex-1` — growing is not wanted, or
|
|
830
|
+
// every wide grid cell would stretch its bar; `w-16` stays the upper bound and
|
|
831
|
+
// wide containers look exactly as before.
|
|
677
832
|
return (
|
|
678
|
-
<div className="flex items-center gap-2">
|
|
833
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
679
834
|
<div
|
|
680
|
-
className="h-1.5 w-16 rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden
|
|
835
|
+
className="h-1.5 w-16 min-w-0 shrink rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden"
|
|
681
836
|
role="progressbar"
|
|
682
837
|
aria-valuenow={clampedBar}
|
|
683
838
|
aria-valuemin={0}
|
|
@@ -688,7 +843,7 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
688
843
|
style={{ width: `${clampedBar}%` }}
|
|
689
844
|
/>
|
|
690
845
|
</div>
|
|
691
|
-
<span className="tabular-nums whitespace-nowrap">{formatted}</span>
|
|
846
|
+
<span className="shrink-0 tabular-nums whitespace-nowrap">{formatted}</span>
|
|
692
847
|
</div>
|
|
693
848
|
);
|
|
694
849
|
}
|
|
@@ -748,7 +903,14 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
748
903
|
* Date field cell renderer
|
|
749
904
|
*/
|
|
750
905
|
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
751
|
-
|
|
906
|
+
// `useDisplayLocale()`, NOT `useLocalization().locale` (objectui#4468). The
|
|
907
|
+
// raw tenant locale is `undefined` on any workspace that never configured
|
|
908
|
+
// one, and `Intl` reads the MACHINE's locale from `undefined` — so a `zh`
|
|
909
|
+
// console rendered `逾期 6 天` (which resolves through `t`, the ACTIVE UI
|
|
910
|
+
// language) beside `In 3 days` and `6 days ago` (which resolve through
|
|
911
|
+
// `Intl`) on the SAME row. One channel for both halves, or the row
|
|
912
|
+
// disagrees with itself.
|
|
913
|
+
const locale = useDisplayLocale();
|
|
752
914
|
const t = useFieldTranslate();
|
|
753
915
|
if (!value) return <EmptyValue />;
|
|
754
916
|
const safe = coerceToSafeValue(value);
|
|
@@ -785,17 +947,26 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
785
947
|
* DateTime field cell renderer (Airtable-style with date and time visually separated)
|
|
786
948
|
*/
|
|
787
949
|
export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
950
|
+
// Hook before every early return — a value flipping between null and set
|
|
951
|
+
// must not change the hook count between renders (same rule as the number /
|
|
952
|
+
// currency renderers above). This is the site objectui#4468 caught rendering
|
|
953
|
+
// `8/11/2026 12:00 am` inside an otherwise Chinese grid: both calls passed
|
|
954
|
+
// `undefined`, i.e. the machine's locale, on every session.
|
|
955
|
+
const locale = useDisplayLocale();
|
|
788
956
|
if (!value) return <EmptyValue />;
|
|
789
957
|
const safe = coerceToSafeValue(value);
|
|
790
958
|
const date = safe != null ? new Date(safe as string | number) : null;
|
|
791
959
|
if (date === null || isNaN(date.getTime())) return <EmptyValue />;
|
|
792
960
|
|
|
793
|
-
const datePart = date.toLocaleDateString(
|
|
961
|
+
const datePart = date.toLocaleDateString(locale, {
|
|
794
962
|
month: 'numeric',
|
|
795
963
|
day: 'numeric',
|
|
796
964
|
year: 'numeric',
|
|
797
965
|
});
|
|
798
|
-
|
|
966
|
+
// `hour12` stays declared: this is the compact Airtable-style cell, and the
|
|
967
|
+
// 12-hour face is its design, not a locale artefact. Locales that write no
|
|
968
|
+
// am/pm marker simply ignore it.
|
|
969
|
+
const timePart = date.toLocaleTimeString(locale, {
|
|
799
970
|
hour: 'numeric',
|
|
800
971
|
minute: '2-digit',
|
|
801
972
|
hour12: true,
|
|
@@ -1089,10 +1260,13 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1089
1260
|
|| SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
|
|
1090
1261
|
|| hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
|
|
1091
1262
|
const dotClass = DOT_COLOR_MAP[colorName] || DOT_COLOR_MAP.gray;
|
|
1263
|
+
// max-w-full bounds the (otherwise content-sized) inline-flex box so the
|
|
1264
|
+
// inner truncate can engage; title keeps the full label on hover
|
|
1265
|
+
// (objectui#3466, same class of bug as the badge branch below).
|
|
1092
1266
|
return (
|
|
1093
|
-
<span key={key} className="inline-flex items-center gap-1.5 text-sm">
|
|
1267
|
+
<span key={key} className="inline-flex max-w-full items-center gap-1.5 text-sm" title={label}>
|
|
1094
1268
|
<span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
|
|
1095
|
-
<span className="truncate">{label}</span>
|
|
1269
|
+
<span className="min-w-0 truncate">{label}</span>
|
|
1096
1270
|
</span>
|
|
1097
1271
|
);
|
|
1098
1272
|
}
|
|
@@ -1266,7 +1440,7 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
1266
1440
|
}
|
|
1267
1441
|
|
|
1268
1442
|
const fileName = value.name || value.original_name || 'File';
|
|
1269
|
-
return <
|
|
1443
|
+
return <TruncatedText text={String(fileName)} className="text-sm" />;
|
|
1270
1444
|
}
|
|
1271
1445
|
|
|
1272
1446
|
/**
|
|
@@ -1355,6 +1529,89 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
1355
1529
|
);
|
|
1356
1530
|
}
|
|
1357
1531
|
|
|
1532
|
+
/**
|
|
1533
|
+
* The referenced record's id inside one lookup value — an `$expand`-ed record
|
|
1534
|
+
* object (`{ id, name, … }`) or the raw foreign key itself. `undefined` when
|
|
1535
|
+
* the value carries no id we could address (e.g. an unresolved external-id
|
|
1536
|
+
* reference), which the link below reads as "not navigable".
|
|
1537
|
+
*/
|
|
1538
|
+
function referencedRecordId(item: unknown): string | number | undefined {
|
|
1539
|
+
if (item == null || item === '') return undefined;
|
|
1540
|
+
if (typeof item === 'object') {
|
|
1541
|
+
const id = (item as Record<string, unknown>).id ?? (item as Record<string, unknown>)._id;
|
|
1542
|
+
if (typeof id === 'number') return id;
|
|
1543
|
+
return typeof id === 'string' && id !== '' ? id : undefined;
|
|
1544
|
+
}
|
|
1545
|
+
if (typeof item === 'string' || typeof item === 'number') return item;
|
|
1546
|
+
return undefined;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Wrap a lookup's display value in a link to the record it references
|
|
1551
|
+
* (objectui#4336).
|
|
1552
|
+
*
|
|
1553
|
+
* On the record detail page a valued lookup used to render as plain text plus
|
|
1554
|
+
* a copy button — the referenced document's name was right there and there was
|
|
1555
|
+
* no way to reach it, so users copied the number and searched for it from the
|
|
1556
|
+
* list. Related-list cells pointing at a third object were dead in the same
|
|
1557
|
+
* way. Both surfaces resolve through `LookupCellRenderer`, so the affordance
|
|
1558
|
+
* belongs here, once.
|
|
1559
|
+
*
|
|
1560
|
+
* **The URL is not built here.** This package has no router and no business
|
|
1561
|
+
* knowing what a record route looks like; the host publishes its own builder
|
|
1562
|
+
* through `RelatedRecordActionsContext` (`recordHref` / `openRecord`) — the
|
|
1563
|
+
* same one the related list's row navigation uses. No host, or a host that
|
|
1564
|
+
* cannot route to that object, renders the value EXACTLY as before: no anchor,
|
|
1565
|
+
* no styling change, nothing to un-learn (Studio designer, embedded renderers,
|
|
1566
|
+
* standalone grids).
|
|
1567
|
+
*
|
|
1568
|
+
* A real `href` rather than a click handler, so middle-click / ⌘-click / "copy
|
|
1569
|
+
* link address" behave the way a link is supposed to; a plain left click is
|
|
1570
|
+
* handed to the host so navigation stays in-app.
|
|
1571
|
+
*/
|
|
1572
|
+
function ReferencedRecordLink({
|
|
1573
|
+
objectName,
|
|
1574
|
+
recordId,
|
|
1575
|
+
className,
|
|
1576
|
+
children,
|
|
1577
|
+
}: {
|
|
1578
|
+
objectName?: string;
|
|
1579
|
+
recordId?: string | number;
|
|
1580
|
+
className?: string;
|
|
1581
|
+
children: React.ReactNode;
|
|
1582
|
+
}): React.ReactElement {
|
|
1583
|
+
const host = useRelatedRecordActions();
|
|
1584
|
+
const navigable = !!objectName && recordId != null && recordId !== '';
|
|
1585
|
+
const href =
|
|
1586
|
+
navigable && host?.recordHref
|
|
1587
|
+
? host.recordHref(objectName as string, recordId as string | number)
|
|
1588
|
+
: null;
|
|
1589
|
+
|
|
1590
|
+
if (!href) return <>{children}</>;
|
|
1591
|
+
|
|
1592
|
+
return (
|
|
1593
|
+
<a
|
|
1594
|
+
href={href}
|
|
1595
|
+
className={cn('text-primary underline-offset-4 hover:underline', className)}
|
|
1596
|
+
onClick={(e) => {
|
|
1597
|
+
// The rows this cell sits in carry their own click handlers — the
|
|
1598
|
+
// detail row copies the field value, a related-list row opens ITS own
|
|
1599
|
+
// record. Following the reference must not also fire those.
|
|
1600
|
+
e.stopPropagation();
|
|
1601
|
+
// Modifier and non-primary clicks belong to the browser (new tab, new
|
|
1602
|
+
// window) — that is the point of rendering a real href.
|
|
1603
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
1604
|
+
// No SPA handler: let the anchor navigate on its own.
|
|
1605
|
+
if (!host?.openRecord) return;
|
|
1606
|
+
e.preventDefault();
|
|
1607
|
+
host.openRecord(objectName as string, recordId as string | number);
|
|
1608
|
+
}}
|
|
1609
|
+
>
|
|
1610
|
+
{children}
|
|
1611
|
+
</a>
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1358
1615
|
/**
|
|
1359
1616
|
* Lookup/Master-Detail field cell renderer.
|
|
1360
1617
|
*
|
|
@@ -1427,15 +1684,24 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1427
1684
|
// Compute inside try, render outside — constructing JSX in a try/catch
|
|
1428
1685
|
// doesn't catch its render errors anyway (react-hooks/error-boundaries).
|
|
1429
1686
|
let parsedDisplay = '';
|
|
1687
|
+
let parsedId: string | number | undefined;
|
|
1430
1688
|
try {
|
|
1431
1689
|
const parsed = JSON.parse(s) as Record<string, unknown>;
|
|
1432
1690
|
if (parsed && typeof parsed === 'object') {
|
|
1433
1691
|
parsedDisplay =
|
|
1434
1692
|
resolveLookupRecordName(parsed, refSchema, displayField) ||
|
|
1435
1693
|
String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
|
|
1694
|
+
// An external-id reference has no record id yet — stays unlinked.
|
|
1695
|
+
parsedId = referencedRecordId(parsed);
|
|
1436
1696
|
}
|
|
1437
1697
|
} catch { /* not JSON — fall through to normal resolution */ }
|
|
1438
|
-
if (parsedDisplay)
|
|
1698
|
+
if (parsedDisplay) {
|
|
1699
|
+
return (
|
|
1700
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={parsedId}>
|
|
1701
|
+
<TruncatedText text={parsedDisplay} />
|
|
1702
|
+
</ReferencedRecordLink>
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1439
1705
|
}
|
|
1440
1706
|
}
|
|
1441
1707
|
|
|
@@ -1446,7 +1712,11 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1446
1712
|
const display =
|
|
1447
1713
|
resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
|
|
1448
1714
|
if (display) {
|
|
1449
|
-
return
|
|
1715
|
+
return (
|
|
1716
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(obj)}>
|
|
1717
|
+
<TruncatedText text={display} />
|
|
1718
|
+
</ReferencedRecordLink>
|
|
1719
|
+
);
|
|
1450
1720
|
}
|
|
1451
1721
|
}
|
|
1452
1722
|
|
|
@@ -1484,18 +1754,26 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1484
1754
|
label = r.text;
|
|
1485
1755
|
muted = r.muted;
|
|
1486
1756
|
}
|
|
1757
|
+
// Each chip is one referenced record, so each links on its own —
|
|
1758
|
+
// there is no single destination a multi-value cell could point at.
|
|
1487
1759
|
return (
|
|
1488
|
-
<
|
|
1760
|
+
<ReferencedRecordLink
|
|
1489
1761
|
key={idx}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
? 'bg-muted/40 text-muted-foreground'
|
|
1494
|
-
: 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
|
|
1495
|
-
)}
|
|
1762
|
+
objectName={referenceTo}
|
|
1763
|
+
recordId={referencedRecordId(item)}
|
|
1764
|
+
className="text-inherit"
|
|
1496
1765
|
>
|
|
1497
|
-
|
|
1498
|
-
|
|
1766
|
+
<span
|
|
1767
|
+
className={cn(
|
|
1768
|
+
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
|
1769
|
+
muted
|
|
1770
|
+
? 'bg-muted/40 text-muted-foreground'
|
|
1771
|
+
: 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
|
|
1772
|
+
)}
|
|
1773
|
+
>
|
|
1774
|
+
{label}
|
|
1775
|
+
</span>
|
|
1776
|
+
</ReferencedRecordLink>
|
|
1499
1777
|
);
|
|
1500
1778
|
})}
|
|
1501
1779
|
</div>
|
|
@@ -1506,12 +1784,23 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1506
1784
|
const label =
|
|
1507
1785
|
resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
|
|
1508
1786
|
String((value as any).id || (value as any)._id || '[Object]');
|
|
1509
|
-
return
|
|
1787
|
+
return (
|
|
1788
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
|
|
1789
|
+
<TruncatedText text={label} />
|
|
1790
|
+
</ReferencedRecordLink>
|
|
1791
|
+
);
|
|
1510
1792
|
}
|
|
1511
1793
|
|
|
1512
1794
|
// Primitive value (e.g. raw ID): try options → resolver → opaque-ID placeholder → raw
|
|
1795
|
+
// The value IS the foreign key, so it addresses the record even when the
|
|
1796
|
+
// display name could not be resolved (the muted placeholder) — a reference
|
|
1797
|
+
// that is present but unnamed is still worth being able to open.
|
|
1513
1798
|
const { text, muted } = resolveLabel(value);
|
|
1514
|
-
return
|
|
1799
|
+
return (
|
|
1800
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
|
|
1801
|
+
<TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />
|
|
1802
|
+
</ReferencedRecordLink>
|
|
1803
|
+
);
|
|
1515
1804
|
}
|
|
1516
1805
|
|
|
1517
1806
|
/**
|
|
@@ -1535,7 +1824,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
1535
1824
|
|
|
1536
1825
|
// Primitive value: just display the ID/username as text
|
|
1537
1826
|
if (typeof value !== 'object') {
|
|
1538
|
-
return <
|
|
1827
|
+
return <TruncatedText text={String(value)} />;
|
|
1539
1828
|
}
|
|
1540
1829
|
|
|
1541
1830
|
if (Array.isArray(value)) {
|
|
@@ -1544,11 +1833,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
1544
1833
|
{value.slice(0, 3).map((user, idx) => {
|
|
1545
1834
|
// Primitive user in array
|
|
1546
1835
|
if (typeof user !== 'object' || user === null) {
|
|
1547
|
-
return (
|
|
1548
|
-
<span key={idx} className="truncate text-sm">
|
|
1549
|
-
{String(user)}
|
|
1550
|
-
</span>
|
|
1551
|
-
);
|
|
1836
|
+
return <TruncatedText key={idx} text={String(user)} className="text-sm" />;
|
|
1552
1837
|
}
|
|
1553
1838
|
const name = user.name || user.username || 'User';
|
|
1554
1839
|
const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
|
|
@@ -1588,7 +1873,7 @@ export function UserCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
1588
1873
|
{initials}
|
|
1589
1874
|
</AvatarFallback>
|
|
1590
1875
|
</Avatar>
|
|
1591
|
-
<
|
|
1876
|
+
<TruncatedText text={String(name)} />
|
|
1592
1877
|
</div>
|
|
1593
1878
|
);
|
|
1594
1879
|
}
|
|
@@ -1670,13 +1955,10 @@ export function JsonCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
1670
1955
|
} else {
|
|
1671
1956
|
text = String(value);
|
|
1672
1957
|
}
|
|
1673
|
-
//
|
|
1674
|
-
//
|
|
1675
|
-
//
|
|
1676
|
-
|
|
1677
|
-
// spills into the neighbouring column (objectui#2578). The title keeps the
|
|
1678
|
-
// full value on hover.
|
|
1679
|
-
return <span className="block max-w-full font-mono text-xs text-gray-600 truncate" title={text}>{text}</span>;
|
|
1958
|
+
// The original site of the block-level+max-w-full+title pattern
|
|
1959
|
+
// (objectui#2578) — now shared with every single-line value renderer via
|
|
1960
|
+
// TruncatedText (objectui#3466).
|
|
1961
|
+
return <TruncatedText text={text} className="font-mono text-xs text-gray-600" />;
|
|
1680
1962
|
}
|
|
1681
1963
|
|
|
1682
1964
|
/**
|
|
@@ -1770,6 +2052,39 @@ export function LocationCellRenderer({ value }: CellRendererProps): React.ReactE
|
|
|
1770
2052
|
return <JsonCellRenderer value={value} field={{} as any} />;
|
|
1771
2053
|
}
|
|
1772
2054
|
|
|
2055
|
+
/**
|
|
2056
|
+
* Renders an `address` value as a formatted single-line postal address instead
|
|
2057
|
+
* of stringified JSON (objectui#4037).
|
|
2058
|
+
*
|
|
2059
|
+
* The detail page's display registry mapped `address` to {@link JsonCellRenderer},
|
|
2060
|
+
* so a populated address read as `{"street":"中策路 1 号","city":"杭州",…}` on the
|
|
2061
|
+
* detail page and in its inline-edit read state — while the very same value
|
|
2062
|
+
* rendered correctly in the create/edit dialog, whose input registry has always
|
|
2063
|
+
* carried `address`. Read side only: nothing about the input widget changes.
|
|
2064
|
+
*
|
|
2065
|
+
* Layout is not invented here. It is {@link formatAddress} — the rule
|
|
2066
|
+
* `AddressField`'s own readonly branch already applied, over the sub-field set
|
|
2067
|
+
* its inputs expose — so a readonly form and a detail page cannot spell one
|
|
2068
|
+
* stored address two ways.
|
|
2069
|
+
*
|
|
2070
|
+
* Anything `formatAddress` cannot recognize falls back to compact JSON rather
|
|
2071
|
+
* than rendering blank: an unknown shape stays visible (today's behaviour) and
|
|
2072
|
+
* is never silently swallowed by the fix.
|
|
2073
|
+
*/
|
|
2074
|
+
export function AddressCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
2075
|
+
if (value == null || value === '') return <EmptyValue />;
|
|
2076
|
+
// A plain string address (some apps store one) is already display-ready.
|
|
2077
|
+
if (typeof value === 'string') return <TruncatedText text={value} className="text-sm" />;
|
|
2078
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
2079
|
+
const formatted = formatAddress(value as AddressValue);
|
|
2080
|
+
if (formatted) return <TruncatedText text={formatted} className="text-sm" />;
|
|
2081
|
+
// An object carrying no recognized part: `{}` reads as empty, while
|
|
2082
|
+
// `{ foo: 1 }` keeps its JSON so real data is never hidden.
|
|
2083
|
+
if (Object.keys(value as Record<string, unknown>).length === 0) return <EmptyValue />;
|
|
2084
|
+
}
|
|
2085
|
+
return <JsonCellRenderer value={value} field={{} as any} />;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
1773
2088
|
/**
|
|
1774
2089
|
* Get the appropriate cell renderer for a field type
|
|
1775
2090
|
*/
|
|
@@ -1826,7 +2141,7 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
|
|
|
1826
2141
|
secret: () => <span>••••••</span>,
|
|
1827
2142
|
location: LocationCellRenderer,
|
|
1828
2143
|
geolocation: LocationCellRenderer,
|
|
1829
|
-
address:
|
|
2144
|
+
address: AddressCellRenderer,
|
|
1830
2145
|
color: ColorSwatchCellRenderer,
|
|
1831
2146
|
json: JsonCellRenderer,
|
|
1832
2147
|
object: JsonCellRenderer,
|
|
@@ -2034,64 +2349,16 @@ export function evaluateCondition(condition: any, formData: any): boolean {
|
|
|
2034
2349
|
return true;
|
|
2035
2350
|
}
|
|
2036
2351
|
|
|
2037
|
-
//
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
required: schema?.required,
|
|
2048
|
-
readonly: schema?.readonly || schema?.readOnly,
|
|
2049
|
-
help: schema?.help,
|
|
2050
|
-
description: schema?.description,
|
|
2051
|
-
// `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
|
|
2052
|
-
// and `||` silently threw it away (same falsy-as-empty class as cloud#972).
|
|
2053
|
-
defaultValue: schema?.defaultValue ?? schema?.value,
|
|
2054
|
-
...schema,
|
|
2055
|
-
};
|
|
2056
|
-
|
|
2057
|
-
const handleChange = React.useCallback((newValue: any) => {
|
|
2058
|
-
setValue(newValue);
|
|
2059
|
-
if (props.onChange) {
|
|
2060
|
-
props.onChange(newValue);
|
|
2061
|
-
}
|
|
2062
|
-
}, [props]);
|
|
2063
|
-
|
|
2064
|
-
const readonly = schema?.readonly || schema?.readOnly || false;
|
|
2065
|
-
|
|
2066
|
-
return (
|
|
2067
|
-
<div
|
|
2068
|
-
className="grid w-full items-center gap-1.5"
|
|
2069
|
-
data-obj-id={schema?.id}
|
|
2070
|
-
data-obj-type={schema?.type}
|
|
2071
|
-
>
|
|
2072
|
-
{schema?.label && (
|
|
2073
|
-
<label htmlFor={schema.id} className={schema.required ? "after:content-['*'] after:ml-0.5 after:text-red-500" : ""}>
|
|
2074
|
-
{schema.label}
|
|
2075
|
-
</label>
|
|
2076
|
-
)}
|
|
2077
|
-
<FieldWidget
|
|
2078
|
-
value={value}
|
|
2079
|
-
onChange={handleChange}
|
|
2080
|
-
field={field}
|
|
2081
|
-
readonly={readonly}
|
|
2082
|
-
className={className}
|
|
2083
|
-
/>
|
|
2084
|
-
{schema?.description && (
|
|
2085
|
-
<p className="text-sm text-gray-500">{schema.description}</p>
|
|
2086
|
-
)}
|
|
2087
|
-
</div>
|
|
2088
|
-
);
|
|
2089
|
-
};
|
|
2090
|
-
|
|
2091
|
-
FieldRenderer.displayName = `FieldRenderer(${FieldWidget.displayName || FieldWidget.name || 'Component'})`;
|
|
2092
|
-
|
|
2093
|
-
return FieldRenderer;
|
|
2094
|
-
}
|
|
2352
|
+
// TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `createFieldRenderer()`
|
|
2353
|
+
// lived here. It wrapped a field widget in synthesized label/description chrome
|
|
2354
|
+
// plus a local `useState` + `onChange`, which let a BARE field node render
|
|
2355
|
+
// standalone under `SchemaRenderer`. Only `registerFields()` (below, also
|
|
2356
|
+
// removed) ever called it, and only the docs site called that — so the chrome
|
|
2357
|
+
// existed nowhere a real application could reach, and the docs demos showed a
|
|
2358
|
+
// rendering no app produces. The catalog's field examples are form-hosted now
|
|
2359
|
+
// (`{ type: 'form', fields: [...] }`), where the real form renderer owns label
|
|
2360
|
+
// and value state. Do not re-add a second, demo-only registration path: one
|
|
2361
|
+
// registration seam is the whole point (objectui#3308).
|
|
2095
2362
|
|
|
2096
2363
|
/**
|
|
2097
2364
|
* Field widget map for lazy loading
|
|
@@ -2268,6 +2535,50 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
|
|
|
2268
2535
|
'recipient-picker',
|
|
2269
2536
|
]);
|
|
2270
2537
|
|
|
2538
|
+
/**
|
|
2539
|
+
* Widgets whose labelled surface is NOT a labelable HTML element, so a host's
|
|
2540
|
+
* `<label for>` cannot reach it and the association has to go by IDREF instead
|
|
2541
|
+
* (`ComponentMeta.labelling`, objectui#3961). Two shapes, one declaration:
|
|
2542
|
+
*
|
|
2543
|
+
* - real composites — `address` / `geolocation` render several inputs under one
|
|
2544
|
+
* container, and `checkboxes` / `radio` / `rating` / `multiselect` a set of
|
|
2545
|
+
* choice controls; the host's label names the GROUP, each sub-control keeps
|
|
2546
|
+
* its own name (a sub-label, or the chip's own text content).
|
|
2547
|
+
* - `file` is not composite at all: it has exactly ONE control, the dropzone,
|
|
2548
|
+
* which is a `div[role="button"]` (it is the keyboard path to the hidden file
|
|
2549
|
+
* input). It is here because a `div` cannot be `for`-labelled, not because it
|
|
2550
|
+
* is a group, and it renders NO `role="group"` — the dropzone itself takes the
|
|
2551
|
+
* `aria-labelledby`.
|
|
2552
|
+
*
|
|
2553
|
+
* Measured, not assumed: every entry was verified in a real form to be a widget
|
|
2554
|
+
* whose host label either resolved to nothing (`address` / `geolocation`, whose
|
|
2555
|
+
* sub-input ids overwrote the host id — objectui#3343) or resolved to an element
|
|
2556
|
+
* that cannot carry it (`checkboxes` / `radio` / `rating` / `file` /
|
|
2557
|
+
* `multiselect`). In both shapes the visible group label was, before this
|
|
2558
|
+
* declaration, the accessible name of NOTHING.
|
|
2559
|
+
*
|
|
2560
|
+
* `multiselect` arrived one issue later (objectui#3975) for a coverage reason,
|
|
2561
|
+
* not a mechanism one: #3961's probe covered the six above, and re-running it
|
|
2562
|
+
* over the full widget map afterwards found `multiselect` on the byte-identical
|
|
2563
|
+
* failure shape as `checkboxes` — the host id kept, on the chip row's wrapper
|
|
2564
|
+
* `div`, where a `for` is inert. Same declaration, same container answer.
|
|
2565
|
+
*
|
|
2566
|
+
* A widget NOT listed here takes the single-control path unchanged. That is the
|
|
2567
|
+
* safe default: the host keeps emitting `for`, and a composite that forgot to
|
|
2568
|
+
* declare itself is caught by the label-association tests (objectui#3952) rather
|
|
2569
|
+
* than silently emitting an `aria-labelledby` onto a container with no role.
|
|
2570
|
+
*/
|
|
2571
|
+
const FIELD_TYPES_GROUP_LABELLED = new Set([
|
|
2572
|
+
'address',
|
|
2573
|
+
'geolocation',
|
|
2574
|
+
'checkboxes',
|
|
2575
|
+
'radio',
|
|
2576
|
+
'rating',
|
|
2577
|
+
'file',
|
|
2578
|
+
// objectui#3975 — the residual after #3961's six, same shape as `checkboxes`.
|
|
2579
|
+
'multiselect',
|
|
2580
|
+
]);
|
|
2581
|
+
|
|
2271
2582
|
export function registerField(fieldType: string): void {
|
|
2272
2583
|
const loader = fieldWidgetMap[fieldType];
|
|
2273
2584
|
if (!loader) {
|
|
@@ -2277,11 +2588,18 @@ export function registerField(fieldType: string): void {
|
|
|
2277
2588
|
|
|
2278
2589
|
// Create lazy component
|
|
2279
2590
|
const LazyFieldWidget = React.lazy(loader);
|
|
2280
|
-
|
|
2281
|
-
// Register with field namespace
|
|
2282
|
-
|
|
2591
|
+
|
|
2592
|
+
// Register with field namespace. No LAYOUT wrapper — the form renderer owns
|
|
2593
|
+
// label/description/spacing. The only thing wrapped is the metadata carrier:
|
|
2594
|
+
// `withFieldCarrier` maps `SchemaRenderer`'s `schema` node onto `field`
|
|
2595
|
+
// (objectui#3233) and renders nothing of its own.
|
|
2596
|
+
ComponentRegistry.register(fieldType, withFieldCarrier(LazyFieldWidget), {
|
|
2283
2597
|
namespace: 'field',
|
|
2284
2598
|
skipFallback: FIELD_TYPES_SKIP_FALLBACK.has(fieldType),
|
|
2599
|
+
// Only the group-labelled widgets carry the key; everything else leaves it
|
|
2600
|
+
// absent, which the form renderer reads as `'control'` (objectui#3961). One
|
|
2601
|
+
// spelling of the default, in one place.
|
|
2602
|
+
...(FIELD_TYPES_GROUP_LABELLED.has(fieldType) ? { labelling: 'group' as const } : null),
|
|
2285
2603
|
});
|
|
2286
2604
|
}
|
|
2287
2605
|
|
|
@@ -2301,100 +2619,37 @@ export function registerAllFields(): void {
|
|
|
2301
2619
|
});
|
|
2302
2620
|
}
|
|
2303
2621
|
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
ComponentRegistry.register('url', createFieldRenderer(UrlField), { namespace: 'field' });
|
|
2336
|
-
|
|
2337
|
-
// Specialized fields - wrapped for documentation compatibility
|
|
2338
|
-
ComponentRegistry.register('currency', createFieldRenderer(CurrencyField), { namespace: 'field' });
|
|
2339
|
-
ComponentRegistry.register('percent', createFieldRenderer(PercentField), { namespace: 'field' });
|
|
2340
|
-
// `password` collides with the `ui:password` input variant; namespaced-only.
|
|
2341
|
-
ComponentRegistry.register('password', createFieldRenderer(PasswordField), { namespace: 'field', skipFallback: true });
|
|
2342
|
-
// `markdown` collides with the markdown DISPLAY renderer (plugin-markdown:markdown),
|
|
2343
|
-
// which owns bare `{ type: 'markdown', content }`; the field renderer here is the
|
|
2344
|
-
// RichText editor, reached by forms via `field:markdown`. Namespaced-only.
|
|
2345
|
-
ComponentRegistry.register('markdown', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
|
|
2346
|
-
// `html` collides with the HTML-rendering display widget. Keep the
|
|
2347
|
-
// markdown field, but expose the HTML field only via `field:html` so the
|
|
2348
|
-
// display widget remains the default for { type: 'html', content }.
|
|
2349
|
-
ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
|
|
2350
|
-
ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
2351
|
-
// Capability multi-select (ADR-0056 P2) — reached only via the field
|
|
2352
|
-
// `widget: 'capability-multiselect'` hint (see MetadataProvider), never a bare
|
|
2353
|
-
// field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
|
|
2354
|
-
// widget (value/onChange from RHF), so the wrapper's extra label + local value
|
|
2355
|
-
// buffer would only duplicate the form's FormLabel and desync the value.
|
|
2356
|
-
ComponentRegistry.register('capability-multiselect', CapabilityMultiSelectField, { namespace: 'field', skipFallback: true });
|
|
2357
|
-
// master_detail = child-side FK lookup (single value, typically required). See
|
|
2358
|
-
// fieldWidgetMap above for rationale.
|
|
2359
|
-
ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
2360
|
-
|
|
2361
|
-
// File fields
|
|
2362
|
-
ComponentRegistry.register('file', createFieldRenderer(FileField), { namespace: 'field' });
|
|
2363
|
-
// `image` collides with the display image widget; namespaced-only.
|
|
2364
|
-
ComponentRegistry.register('image', createFieldRenderer(ImageField), { namespace: 'field', skipFallback: true });
|
|
2365
|
-
|
|
2366
|
-
// Location field
|
|
2367
|
-
ComponentRegistry.register('location', createFieldRenderer(LocationField), { namespace: 'field' });
|
|
2368
|
-
|
|
2369
|
-
// Computed/Read-only fields
|
|
2370
|
-
ComponentRegistry.register('formula', createFieldRenderer(FormulaField), { namespace: 'field' });
|
|
2371
|
-
ComponentRegistry.register('summary', createFieldRenderer(SummaryField), { namespace: 'field' });
|
|
2372
|
-
ComponentRegistry.register('auto_number', createFieldRenderer(AutoNumberField), { namespace: 'field' });
|
|
2373
|
-
|
|
2374
|
-
// User fields
|
|
2375
|
-
ComponentRegistry.register('user', createFieldRenderer(UserField), { namespace: 'field' });
|
|
2376
|
-
ComponentRegistry.register('owner', createFieldRenderer(UserField), { namespace: 'field' });
|
|
2377
|
-
|
|
2378
|
-
// Complex data types
|
|
2379
|
-
ComponentRegistry.register('object', createFieldRenderer(ObjectField), { namespace: 'field' });
|
|
2380
|
-
ComponentRegistry.register('vector', createFieldRenderer(VectorField), { namespace: 'field' });
|
|
2381
|
-
// `grid` collides with the layout grid component; namespaced-only.
|
|
2382
|
-
ComponentRegistry.register('grid', createFieldRenderer(GridField), { namespace: 'field', skipFallback: true });
|
|
2383
|
-
|
|
2384
|
-
// NEW: Additional field types from @objectstack/spec
|
|
2385
|
-
ComponentRegistry.register('color', createFieldRenderer(ColorField), { namespace: 'field' });
|
|
2386
|
-
// `slider` collides with the `ui:slider` display control; namespaced-only.
|
|
2387
|
-
ComponentRegistry.register('slider', createFieldRenderer(SliderField), { namespace: 'field', skipFallback: true });
|
|
2388
|
-
ComponentRegistry.register('rating', createFieldRenderer(RatingField), { namespace: 'field' });
|
|
2389
|
-
ComponentRegistry.register('code', createFieldRenderer(CodeField), { namespace: 'field' });
|
|
2390
|
-
// `avatar` collides with the display avatar widget; namespaced-only.
|
|
2391
|
-
ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field', skipFallback: true });
|
|
2392
|
-
// Namespaced-only — see the 'time' registration above for rationale.
|
|
2393
|
-
ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field', skipFallback: true });
|
|
2394
|
-
ComponentRegistry.register('geolocation', createFieldRenderer(GeolocationField), { namespace: 'field' });
|
|
2395
|
-
ComponentRegistry.register('signature', createFieldRenderer(SignatureField), { namespace: 'field' });
|
|
2396
|
-
ComponentRegistry.register('qrcode', createFieldRenderer(QRCodeField), { namespace: 'field' });
|
|
2397
|
-
}
|
|
2622
|
+
// TOMBSTONE (objectui#3910, ruling B of objectui#3798) — `registerFields()` lived
|
|
2623
|
+
// here. It registered every widget wrapped in `createFieldRenderer` (above) under
|
|
2624
|
+
// the same `field:<type>` keys `registerAllFields()` owns, so whoever called it
|
|
2625
|
+
// LAST won the registry. Its only caller was the docs site, which needed the
|
|
2626
|
+
// synthesized label + `onChange` to render a bare field node standalone.
|
|
2627
|
+
//
|
|
2628
|
+
// objectui#3308 ruled "registration gets exactly one path"; the implementation
|
|
2629
|
+
// (PR #3793) then disproved the assumption that this was a redundant duplicate —
|
|
2630
|
+
// it was a demo host adapter, and switching the docs site to `registerAllFields()`
|
|
2631
|
+
// alone would have left the demo inputs inert. Ruling B of objectui#3798 resolved
|
|
2632
|
+
// that by moving the docs catalog's field examples to form hosting
|
|
2633
|
+
// (`{ type: 'form', fields: [...] }`), which deletes the need for the adapter
|
|
2634
|
+
// instead of relocating it: the form renderer already owns label and value state,
|
|
2635
|
+
// so the docs now show what a real application actually renders.
|
|
2636
|
+
//
|
|
2637
|
+
// Do not re-add this or any second registration path. `registerAllFields()` (and
|
|
2638
|
+
// `registerField()` for one type) is the seam; a docs- or test-only wrapper that
|
|
2639
|
+
// re-registers `field:<type>` silently shadows the live entry for every consumer
|
|
2640
|
+
// sharing the registry.
|
|
2641
|
+
//
|
|
2642
|
+
// TOMBSTONE (objectui#3308, ADR-0049 enforce-or-remove) — `field:capability-multiselect`
|
|
2643
|
+
// was registered inside `registerFields()`, and ONLY there. Because the docs site
|
|
2644
|
+
// was that function's sole caller, the key never existed on the live path, so a
|
|
2645
|
+
// field authored with `widget: 'capability-multiselect'` resolved to nothing in
|
|
2646
|
+
// every real app while a comment here advertised it as usable from a record form.
|
|
2647
|
+
// Nothing stamped the hint either: ADR-0056 P1 stamps `permission-facet-link` on
|
|
2648
|
+
// all six `sys_permission_set` facets (`data-objectstack/src/index.ts`
|
|
2649
|
+
// applyFieldWidgetOverrides) and P2 put the capability editor in Studio. The
|
|
2650
|
+
// widget NAME stays retired — do not add it to `fieldWidgetMap`.
|
|
2651
|
+
// `CapabilityMultiSelectField` itself lives on as a plain component, imported and
|
|
2652
|
+
// rendered directly by Studio's `PermissionMatrixEditor` (ADR-0056 P2's design).
|
|
2398
2653
|
|
|
2399
2654
|
export * from './widgets/types';
|
|
2400
2655
|
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
|
|
@@ -2423,6 +2678,11 @@ export * from './widgets/ObjectRefField';
|
|
|
2423
2678
|
export * from './widgets/FilterConditionField';
|
|
2424
2679
|
export * from './widgets/RecipientPickerField';
|
|
2425
2680
|
export * from './widgets/RecordPickerDialog';
|
|
2681
|
+
// Shared picker-column derivation (ADR-0085 highlightFields → displayFields →
|
|
2682
|
+
// schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a
|
|
2683
|
+
// lookup picker and a related-list Add dialog of the same object agree on
|
|
2684
|
+
// columns (#3365).
|
|
2685
|
+
export * from './widgets/deriveLookupColumns';
|
|
2426
2686
|
export * from './widgets/FileField';
|
|
2427
2687
|
export * from './widgets/ImageField';
|
|
2428
2688
|
export { ImageCropperDialog } from './widgets/ImageCropperDialog';
|
|
@@ -2451,5 +2711,35 @@ export * from './widgets/RadioField';
|
|
|
2451
2711
|
export * from './widgets/CheckboxesField';
|
|
2452
2712
|
export * from './widgets/TagsField';
|
|
2453
2713
|
|
|
2714
|
+
// The SDUI-node → `field` adapter every registration here goes through
|
|
2715
|
+
// (objectui#3233). Exported so a widget registered OUTSIDE this repo can reach
|
|
2716
|
+
// the same single-carrier guarantee instead of re-growing a `field || schema`
|
|
2717
|
+
// read of its own.
|
|
2718
|
+
export { withFieldCarrier } from './withFieldCarrier';
|
|
2719
|
+
|
|
2720
|
+
// The whitelist that decides what a widget's `...props` spread may put on a
|
|
2721
|
+
// DOM element (objectui#3291) — the runtime executor of the "DOM pass-through"
|
|
2722
|
+
// block of `FieldWidgetComponentProps`. Exported for the same reason as
|
|
2723
|
+
// `withFieldCarrier` above: a widget authored outside this repo needs to reach
|
|
2724
|
+
// it, or it re-grows the bare spread this closed.
|
|
2725
|
+
export { toDomProps } from './widgets/toDomProps';
|
|
2726
|
+
export type { DomProps } from './widgets/toDomProps';
|
|
2727
|
+
|
|
2728
|
+
// The native date/time control value adapters (objectui#3127). `DateTimeField`
|
|
2729
|
+
// is ISO-canonical on BOTH sides — it takes the record's ISO instant and hands
|
|
2730
|
+
// an ISO instant back, which is also the wire form the platform's `datetime`
|
|
2731
|
+
// value contract requires (`InstantValueSchema`: an ISO-8601 instant with an
|
|
2732
|
+
// explicit zone), so no consumer has to convert at its own serialization
|
|
2733
|
+
// boundary. `ActionParamDialog` used to (objectstack#5061 removed it — the
|
|
2734
|
+
// zone-less wall clock it produced was the one shape the validator rejects).
|
|
2735
|
+
// Exported for the same reason as `toDomProps` above: a widget authored outside
|
|
2736
|
+
// this repo needs the same pair, and a second copy of "what the local wall clock
|
|
2737
|
+
// of an instant is" would drift from this one.
|
|
2738
|
+
export {
|
|
2739
|
+
toDateInputValue,
|
|
2740
|
+
toDateTimeInputValue,
|
|
2741
|
+
fromDateTimeInputValue,
|
|
2742
|
+
} from './widgets/nativeDateValue';
|
|
2743
|
+
|
|
2454
2744
|
// Initialize registry
|
|
2455
2745
|
registerAllFields();
|