@object-ui/fields 17.3.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +941 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +4 -4
- package/dist/index.css +633 -0
- package/dist/index.d.ts +48 -12
- package/dist/index.js +5026 -4933
- package/dist/widgets/AddressField.d.ts +3 -11
- package/dist/widgets/FullscreenFieldEditor.d.ts +42 -152
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/OptionsEmptyState.d.ts +15 -1
- package/dist/widgets/RecordPickerDialog.d.ts +32 -6
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +30 -10
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/package.json +17 -13
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +29 -270
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +489 -247
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +79 -29
- package/src/widgets/AddressField.uniqueIds.test.tsx +2 -1
- package/src/widgets/BooleanField.tsx +41 -7
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CheckboxesField.tsx +35 -2
- package/src/widgets/CurrencyField.tsx +55 -15
- package/src/widgets/DateField.tsx +11 -2
- package/src/widgets/DateTimeField.tsx +12 -3
- package/src/widgets/FileField.tsx +27 -9
- package/src/widgets/FormulaField.tsx +6 -1
- package/src/widgets/FullscreenFieldEditor.tsx +47 -258
- package/src/widgets/GeolocationField.tsx +29 -3
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +114 -10
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +54 -4
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +8 -4
- package/src/widgets/MultiSelectField.tsx +56 -2
- package/src/widgets/OptionsEmptyState.tsx +16 -0
- package/src/widgets/RadioField.tsx +35 -3
- package/src/widgets/RatingField.tsx +15 -2
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.tsx +64 -15
- package/src/widgets/RichTextField.tsx +1 -1
- package/src/widgets/SelectField.tsx +36 -5
- package/src/widgets/UserField.tsx +3 -2
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +48 -19
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/useFieldTranslation.ts +15 -33
- package/src/widgets/useRecordQuery.ts +30 -3
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/cell-truncation.test.d.ts +0 -0
- package/dist/__tests__/field-carrier-sdui.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-aria-invalid-registry-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-dom-leak-e2e.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/AddressField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GeolocationField.uniqueIds.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.gateHintLabel.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.no-provider.test.d.ts +0 -0
- package/dist/widgets/OptionsEmptyState.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RecordPickerDialog.filterOptions.test.d.ts +0 -8
- package/dist/widgets/RecordPickerDialog.selectLabel.test.d.ts +0 -8
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.no-provider.test.d.ts +0 -0
- package/dist/widgets/TagsField.placeholder.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/__tests__/FullscreenFieldEditor.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/FullscreenFieldEditor.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/RichTextField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.announcements.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.characterCount.no-provider.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.fullscreenDisabled.test.d.ts +0 -0
- package/dist/widgets/__tests__/TextAreaField.mobileFullscreen.test.d.ts +0 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
package/src/index.tsx
CHANGED
|
@@ -9,12 +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';
|
|
17
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';
|
|
18
22
|
|
|
19
23
|
// Module-level cache so multiple renderers fetching the same lookup ID
|
|
20
24
|
// only trigger one network call. Keyed by `${objectName}:${id}`.
|
|
@@ -285,46 +289,15 @@ function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) =
|
|
|
285
289
|
return t as (key: string, params?: Record<string, unknown>) => string;
|
|
286
290
|
}
|
|
287
291
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
import { UrlField } from './widgets/UrlField';
|
|
296
|
-
import { CurrencyField } from './widgets/CurrencyField';
|
|
297
|
-
import { TextAreaField } from './widgets/TextAreaField';
|
|
298
|
-
import { RichTextField } from './widgets/RichTextField';
|
|
299
|
-
import { LookupField } from './widgets/LookupField';
|
|
300
|
-
import { CapabilityMultiSelectField } from './widgets/CapabilityMultiSelectField';
|
|
301
|
-
import { DateTimeField } from './widgets/DateTimeField';
|
|
302
|
-
import { TimeField } from './widgets/TimeField';
|
|
303
|
-
import { PercentField } from './widgets/PercentField';
|
|
304
|
-
import { PasswordField } from './widgets/PasswordField';
|
|
305
|
-
import { FileField } from './widgets/FileField';
|
|
306
|
-
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.
|
|
307
299
|
import { ImageLightbox } from './widgets/ImageLightbox';
|
|
308
300
|
import { readFileValues } from './widgets/file-value';
|
|
309
|
-
import { LocationField } from './widgets/LocationField';
|
|
310
|
-
import { FormulaField } from './widgets/FormulaField';
|
|
311
|
-
import { SummaryField } from './widgets/SummaryField';
|
|
312
|
-
import { AutoNumberField } from './widgets/AutoNumberField';
|
|
313
|
-
import { UserField } from './widgets/UserField';
|
|
314
|
-
import { ObjectField } from './widgets/ObjectField';
|
|
315
|
-
import { VectorField } from './widgets/VectorField';
|
|
316
|
-
import { GridField } from './widgets/GridField';
|
|
317
|
-
// New widgets according to @objectstack/spec
|
|
318
|
-
import { ColorField } from './widgets/ColorField';
|
|
319
|
-
import { SliderField } from './widgets/SliderField';
|
|
320
|
-
import { RatingField } from './widgets/RatingField';
|
|
321
|
-
import { CodeField } from './widgets/CodeField';
|
|
322
|
-
import { AvatarField } from './widgets/AvatarField';
|
|
323
|
-
import { AddressField } from './widgets/AddressField';
|
|
324
|
-
import { GeolocationField } from './widgets/GeolocationField';
|
|
325
|
-
import { SignatureField } from './widgets/SignatureField';
|
|
326
|
-
import { QRCodeField } from './widgets/QRCodeField';
|
|
327
|
-
import { MasterDetailField } from './widgets/MasterDetailField';
|
|
328
301
|
|
|
329
302
|
/**
|
|
330
303
|
* Cell renderer props
|
|
@@ -385,27 +358,56 @@ export function coerceToSafeValue(value: unknown): string | number | boolean | n
|
|
|
385
358
|
* USD for unconfigured currency fields was the #1 source of "why is my
|
|
386
359
|
* RMB amount showing as dollars?" bug reports.
|
|
387
360
|
*
|
|
388
|
-
* Trailing
|
|
389
|
-
* 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.
|
|
390
367
|
*/
|
|
391
|
-
import { resolveFieldCurrency } from './currency';
|
|
368
|
+
import { resolveFieldCurrency, currencyFractionDigits } from './currency';
|
|
392
369
|
export { resolveFieldCurrency };
|
|
393
370
|
|
|
394
|
-
export function formatCurrency(value: number, currency?: string): string {
|
|
371
|
+
export function formatCurrency(value: number, currency?: string, locale?: string): string {
|
|
395
372
|
const isWhole = Number.isFinite(value) && value === Math.trunc(value);
|
|
396
|
-
|
|
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;
|
|
397
399
|
if (!currency) {
|
|
398
|
-
return formatNumber(value,
|
|
400
|
+
return formatNumber(value, fracDigits, locale);
|
|
399
401
|
}
|
|
400
402
|
try {
|
|
401
|
-
return
|
|
402
|
-
|
|
403
|
+
return formatDisplayNumber(value, {
|
|
404
|
+
locale,
|
|
403
405
|
currency,
|
|
404
|
-
minimumFractionDigits:
|
|
405
|
-
maximumFractionDigits:
|
|
406
|
-
})
|
|
406
|
+
minimumFractionDigits: fracDigits,
|
|
407
|
+
maximumFractionDigits: fracDigits,
|
|
408
|
+
});
|
|
407
409
|
} catch {
|
|
408
|
-
return `${currency} ${value.toFixed(
|
|
410
|
+
return `${currency} ${value.toFixed(fracDigits)}`;
|
|
409
411
|
}
|
|
410
412
|
}
|
|
411
413
|
|
|
@@ -414,25 +416,26 @@ export function formatCurrency(value: number, currency?: string): string {
|
|
|
414
416
|
* E.g., $150,000 → $150K, $1,200,000 → $1.2M
|
|
415
417
|
* When `currency` is undefined, returns a compact number without symbol.
|
|
416
418
|
*/
|
|
417
|
-
export function formatCompactCurrency(value: number, currency?: string): string {
|
|
419
|
+
export function formatCompactCurrency(value: number, currency?: string, locale?: string): string {
|
|
418
420
|
if (!currency) {
|
|
419
421
|
try {
|
|
420
|
-
const formatted =
|
|
422
|
+
const formatted = formatDisplayNumber(value, {
|
|
423
|
+
locale,
|
|
421
424
|
notation: 'compact',
|
|
422
425
|
maximumFractionDigits: 1,
|
|
423
|
-
})
|
|
426
|
+
});
|
|
424
427
|
return formatted.replace(/\.0(?=[KMBT])/, '');
|
|
425
428
|
} catch {
|
|
426
429
|
return String(value);
|
|
427
430
|
}
|
|
428
431
|
}
|
|
429
432
|
try {
|
|
430
|
-
const formatted =
|
|
431
|
-
|
|
433
|
+
const formatted = formatDisplayNumber(value, {
|
|
434
|
+
locale,
|
|
432
435
|
currency,
|
|
433
436
|
notation: 'compact',
|
|
434
437
|
maximumFractionDigits: 1,
|
|
435
|
-
})
|
|
438
|
+
});
|
|
436
439
|
// Strip trailing ".0" before compact suffix for consistent cross-environment output
|
|
437
440
|
// e.g. "$150.0K" → "$150K" while keeping "$1.5M" intact
|
|
438
441
|
return formatted.replace(/\.0(?=[KMBT])/, '');
|
|
@@ -446,26 +449,95 @@ export function formatCompactCurrency(value: number, currency?: string): string
|
|
|
446
449
|
* Used as a safe fallback when a currency-typed field has no `currency`
|
|
447
450
|
* configured — we'd rather render `1,234.50` than silently assume USD.
|
|
448
451
|
*/
|
|
449
|
-
export function formatNumber(value: number, decimals: number = 2): string {
|
|
452
|
+
export function formatNumber(value: number, decimals: number = 2, locale?: string): string {
|
|
450
453
|
try {
|
|
451
|
-
|
|
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,
|
|
452
461
|
minimumFractionDigits: decimals,
|
|
453
462
|
maximumFractionDigits: decimals,
|
|
454
|
-
})
|
|
463
|
+
});
|
|
455
464
|
} catch {
|
|
456
465
|
return value.toFixed(decimals);
|
|
457
466
|
}
|
|
458
467
|
}
|
|
459
468
|
|
|
460
469
|
/**
|
|
461
|
-
*
|
|
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.
|
|
462
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.
|
|
463
535
|
*/
|
|
464
|
-
export function formatPercent(value: number, precision: number = 0): string {
|
|
536
|
+
export function formatPercent(value: number, precision: number = 0, locale?: string): string {
|
|
465
537
|
// Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so
|
|
466
538
|
// the list cell and the dashboard measure formatter (`formatMeasure`) agree.
|
|
467
539
|
const displayValue = percentDisplayValue(value);
|
|
468
|
-
return
|
|
540
|
+
return formatPercentBody(displayValue, precision, locale);
|
|
469
541
|
}
|
|
470
542
|
|
|
471
543
|
/**
|
|
@@ -549,8 +621,13 @@ export function formatDate(value: string | Date | number, style?: string, option
|
|
|
549
621
|
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
550
622
|
|
|
551
623
|
if (style === 'short') {
|
|
552
|
-
// Compact format for mobile: "Jan 15, '24"
|
|
553
|
-
|
|
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' });
|
|
554
631
|
const day = date.getDate();
|
|
555
632
|
const year = String(date.getFullYear()).slice(-2);
|
|
556
633
|
return `${month} ${day}, '${year}`;
|
|
@@ -574,14 +651,22 @@ export function formatDate(value: string | Date | number, style?: string, option
|
|
|
574
651
|
}
|
|
575
652
|
|
|
576
653
|
/**
|
|
577
|
-
* 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()`.
|
|
578
663
|
*/
|
|
579
|
-
export function formatDateTime(value: string | Date | number): string {
|
|
664
|
+
export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string {
|
|
580
665
|
if (value === null || value === undefined || value === '') return '—';
|
|
581
666
|
const date = value instanceof Date ? value : new Date(value as any);
|
|
582
667
|
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
583
|
-
|
|
584
|
-
return date.toLocaleDateString(
|
|
668
|
+
|
|
669
|
+
return date.toLocaleDateString(options?.locale, {
|
|
585
670
|
year: 'numeric',
|
|
586
671
|
month: 'short',
|
|
587
672
|
day: 'numeric',
|
|
@@ -632,8 +717,12 @@ export function TextCellRenderer({ value }: CellRendererProps): React.ReactEleme
|
|
|
632
717
|
* Number field cell renderer
|
|
633
718
|
*/
|
|
634
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();
|
|
635
724
|
if (value == null) return <EmptyValue />;
|
|
636
|
-
|
|
725
|
+
|
|
637
726
|
const safe = coerceToSafeValue(value);
|
|
638
727
|
const numField = field as any;
|
|
639
728
|
// Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
|
|
@@ -644,13 +733,22 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
644
733
|
// scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
|
|
645
734
|
// keep the minimum at 0 so trailing zeros are trimmed and only cap the
|
|
646
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.
|
|
647
743
|
const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
|
|
648
744
|
const num = Number(safe);
|
|
649
745
|
const formatted = !isNaN(num)
|
|
650
|
-
?
|
|
746
|
+
? formatDisplayNumber(num, {
|
|
747
|
+
locale,
|
|
748
|
+
scale,
|
|
651
749
|
minimumFractionDigits: scale ?? 0,
|
|
652
750
|
maximumFractionDigits: scale ?? 20,
|
|
653
|
-
})
|
|
751
|
+
})
|
|
654
752
|
: String(safe);
|
|
655
753
|
|
|
656
754
|
return <span className="tabular-nums">{formatted}</span>;
|
|
@@ -663,6 +761,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
|
|
|
663
761
|
// Hooks before the empty-value early return — a value flipping between
|
|
664
762
|
// null and set must not change the hook count between renders.
|
|
665
763
|
const { currency: tenantCurrency } = useLocalization();
|
|
764
|
+
const locale = useDisplayLocale();
|
|
666
765
|
if (value == null) return <EmptyValue />;
|
|
667
766
|
|
|
668
767
|
const safe = coerceToSafeValue(value);
|
|
@@ -673,7 +772,7 @@ export function CurrencyCellRenderer({ value, field }: CellRendererProps): React
|
|
|
673
772
|
const currency = resolveFieldCurrency(field as any, tenantCurrency);
|
|
674
773
|
const num = Number(safe);
|
|
675
774
|
const formatted = !isNaN(num)
|
|
676
|
-
? formatCurrency(num, currency)
|
|
775
|
+
? formatCurrency(num, currency, locale)
|
|
677
776
|
: String(safe);
|
|
678
777
|
|
|
679
778
|
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
@@ -686,8 +785,12 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
|
|
|
686
785
|
* Percent field cell renderer with mini progress bar
|
|
687
786
|
*/
|
|
688
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();
|
|
689
792
|
if (value == null) return <EmptyValue />;
|
|
690
|
-
|
|
793
|
+
|
|
691
794
|
const safe = coerceToSafeValue(value);
|
|
692
795
|
const percentField = field as any;
|
|
693
796
|
const precision = percentField.precision ?? 0;
|
|
@@ -701,13 +804,35 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
701
804
|
const barValue = isWholePercentField
|
|
702
805
|
? numValue
|
|
703
806
|
: (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
|
|
704
|
-
|
|
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);
|
|
705
817
|
const clampedBar = Math.max(0, Math.min(100, barValue));
|
|
706
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.
|
|
707
832
|
return (
|
|
708
|
-
<div className="flex items-center gap-2">
|
|
833
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
709
834
|
<div
|
|
710
|
-
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"
|
|
711
836
|
role="progressbar"
|
|
712
837
|
aria-valuenow={clampedBar}
|
|
713
838
|
aria-valuemin={0}
|
|
@@ -718,7 +843,7 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
718
843
|
style={{ width: `${clampedBar}%` }}
|
|
719
844
|
/>
|
|
720
845
|
</div>
|
|
721
|
-
<span className="tabular-nums whitespace-nowrap">{formatted}</span>
|
|
846
|
+
<span className="shrink-0 tabular-nums whitespace-nowrap">{formatted}</span>
|
|
722
847
|
</div>
|
|
723
848
|
);
|
|
724
849
|
}
|
|
@@ -778,7 +903,14 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
778
903
|
* Date field cell renderer
|
|
779
904
|
*/
|
|
780
905
|
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
781
|
-
|
|
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();
|
|
782
914
|
const t = useFieldTranslate();
|
|
783
915
|
if (!value) return <EmptyValue />;
|
|
784
916
|
const safe = coerceToSafeValue(value);
|
|
@@ -815,17 +947,26 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
815
947
|
* DateTime field cell renderer (Airtable-style with date and time visually separated)
|
|
816
948
|
*/
|
|
817
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();
|
|
818
956
|
if (!value) return <EmptyValue />;
|
|
819
957
|
const safe = coerceToSafeValue(value);
|
|
820
958
|
const date = safe != null ? new Date(safe as string | number) : null;
|
|
821
959
|
if (date === null || isNaN(date.getTime())) return <EmptyValue />;
|
|
822
960
|
|
|
823
|
-
const datePart = date.toLocaleDateString(
|
|
961
|
+
const datePart = date.toLocaleDateString(locale, {
|
|
824
962
|
month: 'numeric',
|
|
825
963
|
day: 'numeric',
|
|
826
964
|
year: 'numeric',
|
|
827
965
|
});
|
|
828
|
-
|
|
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, {
|
|
829
970
|
hour: 'numeric',
|
|
830
971
|
minute: '2-digit',
|
|
831
972
|
hour12: true,
|
|
@@ -1388,6 +1529,89 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
1388
1529
|
);
|
|
1389
1530
|
}
|
|
1390
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
|
+
|
|
1391
1615
|
/**
|
|
1392
1616
|
* Lookup/Master-Detail field cell renderer.
|
|
1393
1617
|
*
|
|
@@ -1460,15 +1684,24 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1460
1684
|
// Compute inside try, render outside — constructing JSX in a try/catch
|
|
1461
1685
|
// doesn't catch its render errors anyway (react-hooks/error-boundaries).
|
|
1462
1686
|
let parsedDisplay = '';
|
|
1687
|
+
let parsedId: string | number | undefined;
|
|
1463
1688
|
try {
|
|
1464
1689
|
const parsed = JSON.parse(s) as Record<string, unknown>;
|
|
1465
1690
|
if (parsed && typeof parsed === 'object') {
|
|
1466
1691
|
parsedDisplay =
|
|
1467
1692
|
resolveLookupRecordName(parsed, refSchema, displayField) ||
|
|
1468
1693
|
String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
|
|
1694
|
+
// An external-id reference has no record id yet — stays unlinked.
|
|
1695
|
+
parsedId = referencedRecordId(parsed);
|
|
1469
1696
|
}
|
|
1470
1697
|
} catch { /* not JSON — fall through to normal resolution */ }
|
|
1471
|
-
if (parsedDisplay)
|
|
1698
|
+
if (parsedDisplay) {
|
|
1699
|
+
return (
|
|
1700
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={parsedId}>
|
|
1701
|
+
<TruncatedText text={parsedDisplay} />
|
|
1702
|
+
</ReferencedRecordLink>
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1472
1705
|
}
|
|
1473
1706
|
}
|
|
1474
1707
|
|
|
@@ -1479,7 +1712,11 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1479
1712
|
const display =
|
|
1480
1713
|
resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
|
|
1481
1714
|
if (display) {
|
|
1482
|
-
return
|
|
1715
|
+
return (
|
|
1716
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(obj)}>
|
|
1717
|
+
<TruncatedText text={display} />
|
|
1718
|
+
</ReferencedRecordLink>
|
|
1719
|
+
);
|
|
1483
1720
|
}
|
|
1484
1721
|
}
|
|
1485
1722
|
|
|
@@ -1517,18 +1754,26 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1517
1754
|
label = r.text;
|
|
1518
1755
|
muted = r.muted;
|
|
1519
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.
|
|
1520
1759
|
return (
|
|
1521
|
-
<
|
|
1760
|
+
<ReferencedRecordLink
|
|
1522
1761
|
key={idx}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
? 'bg-muted/40 text-muted-foreground'
|
|
1527
|
-
: 'bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200',
|
|
1528
|
-
)}
|
|
1762
|
+
objectName={referenceTo}
|
|
1763
|
+
recordId={referencedRecordId(item)}
|
|
1764
|
+
className="text-inherit"
|
|
1529
1765
|
>
|
|
1530
|
-
|
|
1531
|
-
|
|
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>
|
|
1532
1777
|
);
|
|
1533
1778
|
})}
|
|
1534
1779
|
</div>
|
|
@@ -1539,12 +1784,23 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
1539
1784
|
const label =
|
|
1540
1785
|
resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
|
|
1541
1786
|
String((value as any).id || (value as any)._id || '[Object]');
|
|
1542
|
-
return
|
|
1787
|
+
return (
|
|
1788
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
|
|
1789
|
+
<TruncatedText text={label} />
|
|
1790
|
+
</ReferencedRecordLink>
|
|
1791
|
+
);
|
|
1543
1792
|
}
|
|
1544
1793
|
|
|
1545
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.
|
|
1546
1798
|
const { text, muted } = resolveLabel(value);
|
|
1547
|
-
return
|
|
1799
|
+
return (
|
|
1800
|
+
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
|
|
1801
|
+
<TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />
|
|
1802
|
+
</ReferencedRecordLink>
|
|
1803
|
+
);
|
|
1548
1804
|
}
|
|
1549
1805
|
|
|
1550
1806
|
/**
|
|
@@ -1796,6 +2052,39 @@ export function LocationCellRenderer({ value }: CellRendererProps): React.ReactE
|
|
|
1796
2052
|
return <JsonCellRenderer value={value} field={{} as any} />;
|
|
1797
2053
|
}
|
|
1798
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
|
+
|
|
1799
2088
|
/**
|
|
1800
2089
|
* Get the appropriate cell renderer for a field type
|
|
1801
2090
|
*/
|
|
@@ -1852,7 +2141,7 @@ export function getCellRenderer(fieldType: string): React.FC<CellRendererProps>
|
|
|
1852
2141
|
secret: () => <span>••••••</span>,
|
|
1853
2142
|
location: LocationCellRenderer,
|
|
1854
2143
|
geolocation: LocationCellRenderer,
|
|
1855
|
-
address:
|
|
2144
|
+
address: AddressCellRenderer,
|
|
1856
2145
|
color: ColorSwatchCellRenderer,
|
|
1857
2146
|
json: JsonCellRenderer,
|
|
1858
2147
|
object: JsonCellRenderer,
|
|
@@ -2060,64 +2349,16 @@ export function evaluateCondition(condition: any, formData: any): boolean {
|
|
|
2060
2349
|
return true;
|
|
2061
2350
|
}
|
|
2062
2351
|
|
|
2063
|
-
//
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
required: schema?.required,
|
|
2074
|
-
readonly: schema?.readonly || schema?.readOnly,
|
|
2075
|
-
help: schema?.help,
|
|
2076
|
-
description: schema?.description,
|
|
2077
|
-
// `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
|
|
2078
|
-
// and `||` silently threw it away (same falsy-as-empty class as cloud#972).
|
|
2079
|
-
defaultValue: schema?.defaultValue ?? schema?.value,
|
|
2080
|
-
...schema,
|
|
2081
|
-
};
|
|
2082
|
-
|
|
2083
|
-
const handleChange = React.useCallback((newValue: any) => {
|
|
2084
|
-
setValue(newValue);
|
|
2085
|
-
if (props.onChange) {
|
|
2086
|
-
props.onChange(newValue);
|
|
2087
|
-
}
|
|
2088
|
-
}, [props]);
|
|
2089
|
-
|
|
2090
|
-
const readonly = schema?.readonly || schema?.readOnly || false;
|
|
2091
|
-
|
|
2092
|
-
return (
|
|
2093
|
-
<div
|
|
2094
|
-
className="grid w-full items-center gap-1.5"
|
|
2095
|
-
data-obj-id={schema?.id}
|
|
2096
|
-
data-obj-type={schema?.type}
|
|
2097
|
-
>
|
|
2098
|
-
{schema?.label && (
|
|
2099
|
-
<label htmlFor={schema.id} className={schema.required ? "after:content-['*'] after:ml-0.5 after:text-red-500" : ""}>
|
|
2100
|
-
{schema.label}
|
|
2101
|
-
</label>
|
|
2102
|
-
)}
|
|
2103
|
-
<FieldWidget
|
|
2104
|
-
value={value}
|
|
2105
|
-
onChange={handleChange}
|
|
2106
|
-
field={field}
|
|
2107
|
-
readonly={readonly}
|
|
2108
|
-
className={className}
|
|
2109
|
-
/>
|
|
2110
|
-
{schema?.description && (
|
|
2111
|
-
<p className="text-sm text-gray-500">{schema.description}</p>
|
|
2112
|
-
)}
|
|
2113
|
-
</div>
|
|
2114
|
-
);
|
|
2115
|
-
};
|
|
2116
|
-
|
|
2117
|
-
FieldRenderer.displayName = `FieldRenderer(${FieldWidget.displayName || FieldWidget.name || 'Component'})`;
|
|
2118
|
-
|
|
2119
|
-
return FieldRenderer;
|
|
2120
|
-
}
|
|
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).
|
|
2121
2362
|
|
|
2122
2363
|
/**
|
|
2123
2364
|
* Field widget map for lazy loading
|
|
@@ -2294,6 +2535,50 @@ const FIELD_TYPES_SKIP_FALLBACK = new Set([
|
|
|
2294
2535
|
'recipient-picker',
|
|
2295
2536
|
]);
|
|
2296
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
|
+
|
|
2297
2582
|
export function registerField(fieldType: string): void {
|
|
2298
2583
|
const loader = fieldWidgetMap[fieldType];
|
|
2299
2584
|
if (!loader) {
|
|
@@ -2311,6 +2596,10 @@ export function registerField(fieldType: string): void {
|
|
|
2311
2596
|
ComponentRegistry.register(fieldType, withFieldCarrier(LazyFieldWidget), {
|
|
2312
2597
|
namespace: 'field',
|
|
2313
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),
|
|
2314
2603
|
});
|
|
2315
2604
|
}
|
|
2316
2605
|
|
|
@@ -2330,100 +2619,37 @@ export function registerAllFields(): void {
|
|
|
2330
2619
|
});
|
|
2331
2620
|
}
|
|
2332
2621
|
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
ComponentRegistry.register('url', createFieldRenderer(UrlField), { namespace: 'field' });
|
|
2365
|
-
|
|
2366
|
-
// Specialized fields - wrapped for documentation compatibility
|
|
2367
|
-
ComponentRegistry.register('currency', createFieldRenderer(CurrencyField), { namespace: 'field' });
|
|
2368
|
-
ComponentRegistry.register('percent', createFieldRenderer(PercentField), { namespace: 'field' });
|
|
2369
|
-
// `password` collides with the `ui:password` input variant; namespaced-only.
|
|
2370
|
-
ComponentRegistry.register('password', createFieldRenderer(PasswordField), { namespace: 'field', skipFallback: true });
|
|
2371
|
-
// `markdown` collides with the markdown DISPLAY renderer (plugin-markdown:markdown),
|
|
2372
|
-
// which owns bare `{ type: 'markdown', content }`; the field renderer here is the
|
|
2373
|
-
// RichText editor, reached by forms via `field:markdown`. Namespaced-only.
|
|
2374
|
-
ComponentRegistry.register('markdown', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
|
|
2375
|
-
// `html` collides with the HTML-rendering display widget. Keep the
|
|
2376
|
-
// markdown field, but expose the HTML field only via `field:html` so the
|
|
2377
|
-
// display widget remains the default for { type: 'html', content }.
|
|
2378
|
-
ComponentRegistry.register('html', createFieldRenderer(RichTextField), { namespace: 'field', skipFallback: true });
|
|
2379
|
-
ComponentRegistry.register('lookup', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
2380
|
-
// Capability multi-select (ADR-0056 P2) — reached only via the field
|
|
2381
|
-
// `widget: 'capability-multiselect'` hint (see MetadataProvider), never a bare
|
|
2382
|
-
// field type. Registered WITHOUT createFieldRenderer: it is a fully-controlled
|
|
2383
|
-
// widget (value/onChange from RHF), so the wrapper's extra label + local value
|
|
2384
|
-
// buffer would only duplicate the form's FormLabel and desync the value.
|
|
2385
|
-
ComponentRegistry.register('capability-multiselect', withFieldCarrier(CapabilityMultiSelectField), { namespace: 'field', skipFallback: true });
|
|
2386
|
-
// master_detail = child-side FK lookup (single value, typically required). See
|
|
2387
|
-
// fieldWidgetMap above for rationale.
|
|
2388
|
-
ComponentRegistry.register('master_detail', createFieldRenderer(LookupField), { namespace: 'field' });
|
|
2389
|
-
|
|
2390
|
-
// File fields
|
|
2391
|
-
ComponentRegistry.register('file', createFieldRenderer(FileField), { namespace: 'field' });
|
|
2392
|
-
// `image` collides with the display image widget; namespaced-only.
|
|
2393
|
-
ComponentRegistry.register('image', createFieldRenderer(ImageField), { namespace: 'field', skipFallback: true });
|
|
2394
|
-
|
|
2395
|
-
// Location field
|
|
2396
|
-
ComponentRegistry.register('location', createFieldRenderer(LocationField), { namespace: 'field' });
|
|
2397
|
-
|
|
2398
|
-
// Computed/Read-only fields
|
|
2399
|
-
ComponentRegistry.register('formula', createFieldRenderer(FormulaField), { namespace: 'field' });
|
|
2400
|
-
ComponentRegistry.register('summary', createFieldRenderer(SummaryField), { namespace: 'field' });
|
|
2401
|
-
ComponentRegistry.register('auto_number', createFieldRenderer(AutoNumberField), { namespace: 'field' });
|
|
2402
|
-
|
|
2403
|
-
// User fields
|
|
2404
|
-
ComponentRegistry.register('user', createFieldRenderer(UserField), { namespace: 'field' });
|
|
2405
|
-
ComponentRegistry.register('owner', createFieldRenderer(UserField), { namespace: 'field' });
|
|
2406
|
-
|
|
2407
|
-
// Complex data types
|
|
2408
|
-
ComponentRegistry.register('object', createFieldRenderer(ObjectField), { namespace: 'field' });
|
|
2409
|
-
ComponentRegistry.register('vector', createFieldRenderer(VectorField), { namespace: 'field' });
|
|
2410
|
-
// `grid` collides with the layout grid component; namespaced-only.
|
|
2411
|
-
ComponentRegistry.register('grid', createFieldRenderer(GridField), { namespace: 'field', skipFallback: true });
|
|
2412
|
-
|
|
2413
|
-
// NEW: Additional field types from @objectstack/spec
|
|
2414
|
-
ComponentRegistry.register('color', createFieldRenderer(ColorField), { namespace: 'field' });
|
|
2415
|
-
// `slider` collides with the `ui:slider` display control; namespaced-only.
|
|
2416
|
-
ComponentRegistry.register('slider', createFieldRenderer(SliderField), { namespace: 'field', skipFallback: true });
|
|
2417
|
-
ComponentRegistry.register('rating', createFieldRenderer(RatingField), { namespace: 'field' });
|
|
2418
|
-
ComponentRegistry.register('code', createFieldRenderer(CodeField), { namespace: 'field' });
|
|
2419
|
-
// `avatar` collides with the display avatar widget; namespaced-only.
|
|
2420
|
-
ComponentRegistry.register('avatar', createFieldRenderer(AvatarField), { namespace: 'field', skipFallback: true });
|
|
2421
|
-
// Namespaced-only — see the 'time' registration above for rationale.
|
|
2422
|
-
ComponentRegistry.register('address', createFieldRenderer(AddressField), { namespace: 'field', skipFallback: true });
|
|
2423
|
-
ComponentRegistry.register('geolocation', createFieldRenderer(GeolocationField), { namespace: 'field' });
|
|
2424
|
-
ComponentRegistry.register('signature', createFieldRenderer(SignatureField), { namespace: 'field' });
|
|
2425
|
-
ComponentRegistry.register('qrcode', createFieldRenderer(QRCodeField), { namespace: 'field' });
|
|
2426
|
-
}
|
|
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).
|
|
2427
2653
|
|
|
2428
2654
|
export * from './widgets/types';
|
|
2429
2655
|
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
|
|
@@ -2499,5 +2725,21 @@ export { withFieldCarrier } from './withFieldCarrier';
|
|
|
2499
2725
|
export { toDomProps } from './widgets/toDomProps';
|
|
2500
2726
|
export type { DomProps } from './widgets/toDomProps';
|
|
2501
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
|
+
|
|
2502
2744
|
// Initialize registry
|
|
2503
2745
|
registerAllFields();
|