@object-ui/fields 17.5.0 → 17.7.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 +6150 -0
- package/README.md +108 -8
- package/dist/FieldEditWidget.d.ts +70 -8
- package/dist/coerceToSafeValue.d.ts +54 -0
- package/dist/currency.d.ts +1 -1
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.css +53 -11
- package/dist/index.d.ts +653 -128
- package/dist/index.js +4275 -3171
- package/dist/widgets/AddressField.d.ts +3 -3
- package/dist/widgets/AutoNumberField.d.ts +1 -1
- package/dist/widgets/AvatarField.d.ts +20 -4
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +1 -1
- 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/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +18 -2
- package/dist/widgets/FilterConditionField.d.ts +43 -2
- package/dist/widgets/FormulaField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +9 -2
- package/dist/widgets/GeolocationField.d.ts +10 -4
- package/dist/widgets/GridField.d.ts +102 -86
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +31 -4
- package/dist/widgets/LookupField.d.ts +9 -1
- package/dist/widgets/MasterDetailField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +10 -3
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +38 -8
- package/dist/widgets/PasswordField.d.ts +15 -1
- package/dist/widgets/PeoplePicker.d.ts +17 -2
- package/dist/widgets/PercentField.d.ts +3 -2
- package/dist/widgets/PersonRow.d.ts +13 -2
- 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 +44 -1
- package/dist/widgets/RecordPickerDialog.d.ts +33 -16
- package/dist/widgets/RichTextField.d.ts +37 -1
- package/dist/widgets/SelectField.d.ts +1 -1
- package/dist/widgets/SelectionTray.d.ts +13 -2
- package/dist/widgets/SignatureField.d.ts +32 -2
- package/dist/widgets/SliderField.d.ts +37 -2
- package/dist/widgets/SummaryField.d.ts +1 -1
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +6 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/UserField.d.ts +1 -1
- package/dist/widgets/VectorField.d.ts +1 -1
- package/dist/widgets/_cell-renderer-bridge.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +27 -7
- package/dist/widgets/file-affordance.d.ts +33 -0
- package/dist/widgets/file-size-guard.d.ts +5 -23
- package/dist/widgets/file-value.d.ts +38 -9
- package/dist/widgets/lookupColumnDisplay.d.ts +110 -0
- package/dist/widgets/nativeDateValue.d.ts +9 -27
- package/dist/widgets/number-format.d.ts +137 -0
- package/dist/widgets/numberBadInput.d.ts +135 -0
- package/dist/widgets/personDisplay.d.ts +22 -4
- package/dist/widgets/richTextDisplay.d.ts +116 -0
- package/dist/widgets/toDomProps.d.ts +10 -1
- package/dist/widgets/toHostGroupProps.d.ts +172 -21
- package/dist/widgets/toHostProps.d.ts +105 -0
- package/dist/widgets/types.d.ts +19 -6
- package/dist/widgets/upload-error-message.d.ts +6 -0
- package/dist/widgets/uploadingScope.d.ts +142 -0
- package/dist/widgets/useCascadingOptions.d.ts +3 -2
- package/dist/widgets/useRecordQuery.d.ts +4 -2
- package/dist/widgets/useUploadingSignal.d.ts +8 -0
- package/package.json +11 -13
- package/dist/widgets/CharacterCount.d.ts +0 -56
- package/src/FieldEditWidget.test.ts +0 -133
- package/src/FieldEditWidget.tsx +0 -207
- package/src/__tests__/AddressCellRenderer.test.tsx +0 -166
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +0 -191
- package/src/__tests__/CurrencyField.symbol.test.tsx +0 -198
- package/src/__tests__/DateCellRenderer.test.tsx +0 -82
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +0 -146
- package/src/__tests__/NumberCellRenderer.test.tsx +0 -60
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +0 -152
- package/src/__tests__/PercentCellRenderer.test.tsx +0 -115
- package/src/__tests__/boolean-label-association-e2e.test.tsx +0 -298
- package/src/__tests__/capability-multiselect-retired.test.ts +0 -109
- package/src/__tests__/cell-truncation.test.tsx +0 -96
- package/src/__tests__/composite-group-label-e2e.test.tsx +0 -436
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +0 -403
- package/src/__tests__/date-formatter-residue-4272.test.ts +0 -120
- package/src/__tests__/date-locale-channel.test.tsx +0 -305
- package/src/__tests__/field-carrier-sdui.test.tsx +0 -169
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +0 -158
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +0 -221
- package/src/__tests__/group-labelling-declaration.test.ts +0 -148
- package/src/__tests__/lookupCellDisplayField.test.tsx +0 -107
- package/src/__tests__/lookupCellDisplayName.test.tsx +0 -119
- package/src/__tests__/lookupCellRecordLink.test.tsx +0 -171
- package/src/__tests__/lookupCellReferenceKey.test.tsx +0 -57
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +0 -115
- package/src/__tests__/percent-formatter-locale-4553.test.ts +0 -147
- package/src/__tests__/percent-tie-halfup-4590.test.ts +0 -224
- package/src/__tests__/registry-collision.test.ts +0 -91
- package/src/__tests__/resolveFieldCurrency.test.ts +0 -20
- package/src/__tests__/select-label-association-e2e.test.tsx +0 -199
- package/src/__tests__/spec-symbol-batch7.test.ts +0 -126
- package/src/__tests__/validation-feedback.test.tsx +0 -361
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +0 -265
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +0 -338
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +0 -357
- package/src/__tests__/widget-props-contract.test.tsx +0 -142
- package/src/coerce-safe-value.test.ts +0 -34
- package/src/complex-widgets.test.tsx +0 -924
- package/src/currency.ts +0 -144
- package/src/datetime-widgets.test.tsx +0 -185
- package/src/field-type-alias.multiple.test.ts +0 -81
- package/src/field-type-alias.ts +0 -156
- package/src/field-type-coverage.test.ts +0 -67
- package/src/index.css +0 -44
- package/src/index.tsx +0 -2745
- package/src/standard-widgets.test.tsx +0 -364
- package/src/widgets/AddressField.postalCode.test.tsx +0 -257
- package/src/widgets/AddressField.tsx +0 -182
- package/src/widgets/AddressField.uniqueIds.test.tsx +0 -93
- package/src/widgets/AutoNumberField.tsx +0 -18
- package/src/widgets/AvatarField.tsx +0 -117
- package/src/widgets/BooleanField.tsx +0 -86
- package/src/widgets/CapabilityMultiSelectField.test.tsx +0 -168
- package/src/widgets/CapabilityMultiSelectField.tsx +0 -241
- package/src/widgets/CharacterCount.tsx +0 -263
- package/src/widgets/CheckboxesField.cascade.test.tsx +0 -143
- package/src/widgets/CheckboxesField.tsx +0 -169
- package/src/widgets/CodeField.tsx +0 -41
- package/src/widgets/ColorField.tsx +0 -53
- package/src/widgets/CurrencyField.tsx +0 -135
- package/src/widgets/DateField.tsx +0 -40
- package/src/widgets/DateTimeField.tsx +0 -46
- package/src/widgets/EmailField.tsx +0 -37
- package/src/widgets/FileField.tsx +0 -512
- package/src/widgets/FilterConditionField.tsx +0 -454
- package/src/widgets/FormulaField.tsx +0 -38
- package/src/widgets/FullscreenFieldEditor.tsx +0 -60
- package/src/widgets/GeolocationField.tsx +0 -193
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +0 -88
- package/src/widgets/GridField.test.tsx +0 -632
- package/src/widgets/GridField.tsx +0 -1070
- package/src/widgets/ImageCropperDialog.tsx +0 -245
- package/src/widgets/ImageField.maxSize.test.tsx +0 -154
- package/src/widgets/ImageField.tsx +0 -305
- package/src/widgets/ImageLightbox.test.tsx +0 -52
- package/src/widgets/ImageLightbox.tsx +0 -108
- package/src/widgets/LocationField.tsx +0 -57
- package/src/widgets/LookupField.dependsOn.test.tsx +0 -233
- package/src/widgets/LookupField.gateHintLabel.test.tsx +0 -135
- package/src/widgets/LookupField.hydration.test.tsx +0 -136
- package/src/widgets/LookupField.idField.test.tsx +0 -61
- package/src/widgets/LookupField.tsx +0 -1295
- package/src/widgets/MarkdownContent.tsx +0 -19
- package/src/widgets/MasterDetailField.tsx +0 -131
- package/src/widgets/MultiSelectField.cascade.test.tsx +0 -143
- package/src/widgets/MultiSelectField.tsx +0 -196
- package/src/widgets/NumberField.tsx +0 -49
- package/src/widgets/ObjectField.tsx +0 -94
- package/src/widgets/ObjectRefField.tsx +0 -121
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +0 -88
- package/src/widgets/OptionsEmptyState.test.tsx +0 -169
- package/src/widgets/OptionsEmptyState.tsx +0 -99
- package/src/widgets/PasswordField.tsx +0 -49
- package/src/widgets/PeoplePicker.test.tsx +0 -360
- package/src/widgets/PeoplePicker.tsx +0 -579
- package/src/widgets/PeoplePickerComponents.test.tsx +0 -104
- package/src/widgets/PercentField.tsx +0 -95
- package/src/widgets/PersonRow.tsx +0 -117
- package/src/widgets/PhoneField.tsx +0 -37
- package/src/widgets/QRCodeField.tsx +0 -103
- package/src/widgets/RadioField.cascade.test.tsx +0 -141
- package/src/widgets/RadioField.tsx +0 -143
- package/src/widgets/RatingField.tsx +0 -94
- package/src/widgets/RecipientPickerField.test.tsx +0 -157
- package/src/widgets/RecipientPickerField.tsx +0 -188
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +0 -191
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +0 -124
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +0 -611
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +0 -112
- package/src/widgets/RecordPickerDialog.tsx +0 -1365
- package/src/widgets/RichTextField.tsx +0 -176
- package/src/widgets/SelectField.cascade.test.tsx +0 -138
- package/src/widgets/SelectField.tsx +0 -193
- package/src/widgets/SelectionTray.tsx +0 -110
- package/src/widgets/SignatureField.tsx +0 -140
- package/src/widgets/SliderField.tsx +0 -39
- package/src/widgets/SummaryField.tsx +0 -31
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +0 -68
- package/src/widgets/TagsField.placeholder.test.tsx +0 -95
- package/src/widgets/TagsField.tsx +0 -88
- package/src/widgets/TextAreaField.tsx +0 -213
- package/src/widgets/TextField.tsx +0 -45
- package/src/widgets/TimeField.tsx +0 -31
- package/src/widgets/UrlField.tsx +0 -46
- package/src/widgets/UserField.tsx +0 -59
- package/src/widgets/UserFieldPicker.test.tsx +0 -78
- package/src/widgets/VectorField.tsx +0 -29
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +0 -66
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +0 -166
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +0 -248
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +0 -116
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +0 -244
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +0 -195
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +0 -417
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +0 -287
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +0 -160
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +0 -161
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +0 -346
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +0 -246
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +0 -175
- package/src/widgets/_cell-renderer-bridge.ts +0 -19
- package/src/widgets/address-format.ts +0 -120
- package/src/widgets/deriveLookupColumns.test.ts +0 -112
- package/src/widgets/deriveLookupColumns.ts +0 -108
- package/src/widgets/file-size-guard.ts +0 -55
- package/src/widgets/file-value.test.ts +0 -230
- package/src/widgets/file-value.ts +0 -246
- package/src/widgets/nativeDateValue.test.ts +0 -115
- package/src/widgets/nativeDateValue.ts +0 -112
- package/src/widgets/openNativePicker.ts +0 -18
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +0 -267
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +0 -150
- package/src/widgets/personDisplay.test.ts +0 -113
- package/src/widgets/personDisplay.ts +0 -92
- package/src/widgets/recentLookups.test.ts +0 -45
- package/src/widgets/recentLookups.ts +0 -47
- package/src/widgets/toDomProps.ts +0 -204
- package/src/widgets/toHostGroupProps.ts +0 -102
- package/src/widgets/types.ts +0 -242
- package/src/widgets/useCascadingOptions.ts +0 -46
- package/src/widgets/useFieldTranslation.ts +0 -132
- package/src/widgets/useRecordQuery.test.ts +0 -174
- package/src/widgets/useRecordQuery.ts +0 -291
- package/src/widgets/useUploadingSignal.test.tsx +0 -62
- package/src/widgets/useUploadingSignal.ts +0 -20
- package/src/withFieldCarrier.tsx +0 -75
package/src/index.tsx
DELETED
|
@@ -1,2745 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* ObjectUI
|
|
3
|
-
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
-
*
|
|
5
|
-
* This source code is licensed under the MIT license found in the
|
|
6
|
-
* LICENSE file in the root directory of this source tree.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import React from 'react';
|
|
10
|
-
import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
|
|
11
|
-
import { ComponentRegistry, percentDisplayValue, getRecordDisplayName } from '@object-ui/core';
|
|
12
|
-
import { useLocalization, useDisplayLocale, formatDisplayNumber } from '@object-ui/i18n';
|
|
13
|
-
import { Badge, Avatar, AvatarImage, AvatarFallback, Button, Checkbox, EmptyValue, cn } from '@object-ui/components';
|
|
14
|
-
import { Check, X, Copy, Phone as PhoneIcon, MapPin } from 'lucide-react';
|
|
15
|
-
import { useObjectTranslation } from '@object-ui/react';
|
|
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';
|
|
22
|
-
|
|
23
|
-
// Module-level cache so multiple renderers fetching the same lookup ID
|
|
24
|
-
// only trigger one network call. Keyed by `${objectName}:${id}`.
|
|
25
|
-
type LookupCacheEntry =
|
|
26
|
-
| { state: 'pending'; promise: Promise<void> }
|
|
27
|
-
| { state: 'ok'; name: string | undefined }
|
|
28
|
-
| { state: 'err' };
|
|
29
|
-
const lookupNameCache: Map<string, LookupCacheEntry> = new Map();
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Pick the most reasonable display name from an arbitrary record object.
|
|
33
|
-
* Tries common name-like keys in priority order, then falls back to undefined.
|
|
34
|
-
*/
|
|
35
|
-
export function pickRecordDisplayName(
|
|
36
|
-
record: Record<string, unknown> | null | undefined,
|
|
37
|
-
preferredField?: string,
|
|
38
|
-
): string | undefined {
|
|
39
|
-
if (!record || typeof record !== 'object') return undefined;
|
|
40
|
-
// Caller-provided hint (typically the target object's displayNameField)
|
|
41
|
-
// beats every heuristic so domain-specific names like `legal_name` win.
|
|
42
|
-
if (preferredField) {
|
|
43
|
-
const pv = record[preferredField];
|
|
44
|
-
if (typeof pv === 'string' && pv.trim()) return pv.trim();
|
|
45
|
-
if (typeof pv === 'number') return String(pv);
|
|
46
|
-
}
|
|
47
|
-
const candidates = ['name', 'full_name', 'display_name', 'label', 'title', 'subject', 'username'];
|
|
48
|
-
for (const k of candidates) {
|
|
49
|
-
const v = record[k];
|
|
50
|
-
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
51
|
-
if (typeof v === 'number') return String(v);
|
|
52
|
-
}
|
|
53
|
-
// Salesforce-style: build a composite name from common person-record
|
|
54
|
-
// fields when no top-level display field is present. Preferred over the
|
|
55
|
-
// raw `email` fallback below so `Bob Lin` beats `bob.lin@acme.com`.
|
|
56
|
-
const first = record['first_name'];
|
|
57
|
-
const last = record['last_name'];
|
|
58
|
-
const salutation = record['salutation'];
|
|
59
|
-
const composite = [salutation, first, last]
|
|
60
|
-
.filter((p) => typeof p === 'string' && (p as string).trim())
|
|
61
|
-
.map((p) => (p as string).trim())
|
|
62
|
-
.join(' ');
|
|
63
|
-
if (composite) return composite;
|
|
64
|
-
// Email is the last-resort identifier (better than the opaque id).
|
|
65
|
-
const email = record['email'];
|
|
66
|
-
if (typeof email === 'string' && email.trim()) return email.trim();
|
|
67
|
-
// Heuristic fallback: pick the first string-valued field whose name looks
|
|
68
|
-
// like a human-facing identifier (legal_name, framework_name, control_number,
|
|
69
|
-
// policy_code, etc.). This covers domain schemas that don't use the
|
|
70
|
-
// hardcoded canonical names above. We skip obvious metadata keys.
|
|
71
|
-
const SKIP = new Set([
|
|
72
|
-
'id', '_id', 'organization_id', 'created_by', 'updated_by',
|
|
73
|
-
'created_at', 'updated_at', 'tenant_id',
|
|
74
|
-
]);
|
|
75
|
-
const SUFFIXES = ['_name', '_title', '_number', '_code', '_label'];
|
|
76
|
-
for (const [k, v] of Object.entries(record)) {
|
|
77
|
-
if (SKIP.has(k)) continue;
|
|
78
|
-
if (k.endsWith('_id')) continue;
|
|
79
|
-
if (!SUFFIXES.some((s) => k.endsWith(s))) continue;
|
|
80
|
-
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
81
|
-
if (typeof v === 'number') return String(v);
|
|
82
|
-
}
|
|
83
|
-
return undefined;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Module-level cache of referenced-object schemas (keyed by object name) so
|
|
87
|
-
// many lookup cells pointing at the same object trigger ONE metadata fetch.
|
|
88
|
-
type RefSchemaCacheEntry =
|
|
89
|
-
| { state: 'pending'; promise: Promise<void> }
|
|
90
|
-
| { state: 'ok'; schema: unknown }
|
|
91
|
-
| { state: 'err' };
|
|
92
|
-
const refObjectSchemaCache: Map<string, RefSchemaCacheEntry> = new Map();
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Fetch (and cache) the referenced object's schema via
|
|
96
|
-
* `dataSource.getObjectSchema`. Resolves to `undefined` when the data source
|
|
97
|
-
* doesn't expose schema metadata or the fetch fails — callers fall back to the
|
|
98
|
-
* key heuristic in that case.
|
|
99
|
-
*/
|
|
100
|
-
async function fetchRefObjectSchema(dataSource: any, referenceTo: string): Promise<any> {
|
|
101
|
-
const settled = () => {
|
|
102
|
-
const entry = refObjectSchemaCache.get(referenceTo);
|
|
103
|
-
return entry?.state === 'ok' ? entry.schema : undefined;
|
|
104
|
-
};
|
|
105
|
-
const existing = refObjectSchemaCache.get(referenceTo);
|
|
106
|
-
if (existing?.state === 'pending') {
|
|
107
|
-
await existing.promise;
|
|
108
|
-
return settled();
|
|
109
|
-
}
|
|
110
|
-
if (existing) return settled();
|
|
111
|
-
const getSchema = dataSource?.getObjectSchema;
|
|
112
|
-
if (typeof getSchema !== 'function') return undefined;
|
|
113
|
-
const promise = Promise.resolve()
|
|
114
|
-
.then(() => getSchema.call(dataSource, referenceTo))
|
|
115
|
-
.then((schema: unknown) => { refObjectSchemaCache.set(referenceTo, { state: 'ok', schema }); })
|
|
116
|
-
.catch(() => { refObjectSchemaCache.set(referenceTo, { state: 'err' }); });
|
|
117
|
-
refObjectSchemaCache.set(referenceTo, { state: 'pending', promise });
|
|
118
|
-
await promise;
|
|
119
|
-
return settled();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* React hook wrapper over {@link fetchRefObjectSchema}: returns the referenced
|
|
124
|
-
* object's schema once loaded (re-rendering when it lands), `undefined` while
|
|
125
|
-
* pending or when schema metadata isn't available.
|
|
126
|
-
*/
|
|
127
|
-
function useRefObjectSchema(referenceTo: string | undefined): any {
|
|
128
|
-
const ctx = React.useContext(_SchemaRendererContext);
|
|
129
|
-
const dataSource = ctx?.dataSource as any;
|
|
130
|
-
const [, force] = React.useState(0);
|
|
131
|
-
const canFetch =
|
|
132
|
-
!!referenceTo && !!dataSource && typeof dataSource.getObjectSchema === 'function';
|
|
133
|
-
|
|
134
|
-
React.useEffect(() => {
|
|
135
|
-
if (!canFetch) return;
|
|
136
|
-
const entry = refObjectSchemaCache.get(referenceTo!);
|
|
137
|
-
if (entry && entry.state !== 'pending') return; // settled before this render
|
|
138
|
-
let alive = true;
|
|
139
|
-
fetchRefObjectSchema(dataSource, referenceTo!).then(() => {
|
|
140
|
-
if (alive) force((n) => n + 1);
|
|
141
|
-
});
|
|
142
|
-
return () => { alive = false; };
|
|
143
|
-
}, [canFetch, referenceTo, dataSource]);
|
|
144
|
-
|
|
145
|
-
if (!canFetch) return undefined;
|
|
146
|
-
const entry = refObjectSchemaCache.get(referenceTo!);
|
|
147
|
-
return entry?.state === 'ok' ? entry.schema : undefined;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Resolve an expanded/fetched lookup record to its display name the same way
|
|
152
|
-
* the picker does (issue #2357): explicit `displayField` first, then the
|
|
153
|
-
* unified ADR-0079 resolver against the referenced object's schema
|
|
154
|
-
* (`nameField`/`displayNameField` → `titleFormat` → type-aware derivation,
|
|
155
|
-
* which excludes autonumber). Only when no schema is available does it fall
|
|
156
|
-
* back to {@link pickRecordDisplayName}, whose `_number`/`_code` suffix scan
|
|
157
|
-
* can otherwise surface an autonumber (`0001`) over the record's real name.
|
|
158
|
-
*/
|
|
159
|
-
function resolveLookupRecordName(
|
|
160
|
-
record: Record<string, unknown> | null | undefined,
|
|
161
|
-
refSchema?: any,
|
|
162
|
-
displayField?: string,
|
|
163
|
-
): string | undefined {
|
|
164
|
-
if (!record || typeof record !== 'object') return undefined;
|
|
165
|
-
if (refSchema) {
|
|
166
|
-
const resolved = getRecordDisplayName(refSchema, record, { titleField: displayField });
|
|
167
|
-
// Stop short of the resolver's `Record #<id>` / `Untitled` floor so the
|
|
168
|
-
// cell keeps its own id-placeholder handling for nameless records.
|
|
169
|
-
const id = (record as any).id ?? (record as any)._id;
|
|
170
|
-
const isFloor =
|
|
171
|
-
resolved === 'Untitled' || (id != null && resolved === `Record #${id}`);
|
|
172
|
-
if (!isFloor && resolved) return resolved;
|
|
173
|
-
}
|
|
174
|
-
return pickRecordDisplayName(record, displayField);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Heuristic: detect strings that look like opaque foreign-key IDs (e.g. nanoid
|
|
179
|
-
* or BSON ObjectId). Used so we don't display random gibberish to users when
|
|
180
|
-
* a lookup wasn't expanded.
|
|
181
|
-
*/
|
|
182
|
-
export function isLikelyOpaqueId(v: unknown): boolean {
|
|
183
|
-
if (typeof v !== 'string') return false;
|
|
184
|
-
// 12-32 chars, only [A-Za-z0-9_-], no whitespace.
|
|
185
|
-
if (!/^[A-Za-z0-9_-]{12,32}$/.test(v)) return false;
|
|
186
|
-
// Must have BOTH upper- and lower-case letters (real words rarely do at this length).
|
|
187
|
-
// Also accept tokens that contain `_` or `-` separators alongside any case mix.
|
|
188
|
-
const hasUpper = /[A-Z]/.test(v);
|
|
189
|
-
const hasLower = /[a-z]/.test(v);
|
|
190
|
-
const hasDigitOrSep = /[0-9_-]/.test(v);
|
|
191
|
-
return (hasUpper && hasLower) || (hasUpper && hasDigitOrSep) || (hasLower && hasDigitOrSep);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Fetch-on-demand resolver for foreign-key IDs that weren't expanded by the
|
|
196
|
-
* server. Reads `dataSource` from SchemaRendererContext; safely no-ops if
|
|
197
|
-
* the context isn't installed. Returns the resolved display name or
|
|
198
|
-
* `undefined` while pending or unresolvable.
|
|
199
|
-
*/
|
|
200
|
-
function useLookupName(
|
|
201
|
-
referenceTo: string | undefined,
|
|
202
|
-
value: unknown,
|
|
203
|
-
displayField?: string,
|
|
204
|
-
): string | undefined {
|
|
205
|
-
const ctx = React.useContext(_SchemaRendererContext);
|
|
206
|
-
const dataSource = ctx?.dataSource;
|
|
207
|
-
const [, force] = React.useState(0);
|
|
208
|
-
|
|
209
|
-
const isResolvable =
|
|
210
|
-
!!referenceTo &&
|
|
211
|
-
!!dataSource &&
|
|
212
|
-
typeof dataSource.find === 'function' &&
|
|
213
|
-
(typeof value === 'string' || typeof value === 'number') &&
|
|
214
|
-
value !== '';
|
|
215
|
-
// The preferred display field is part of the cache identity: two columns
|
|
216
|
-
// targeting the same record with different `display_field`s must not
|
|
217
|
-
// serve each other's cached name (#2926 ⑧).
|
|
218
|
-
const cacheKey = isResolvable
|
|
219
|
-
? `${referenceTo}:${String(value)}:${displayField ?? ''}`
|
|
220
|
-
: '';
|
|
221
|
-
|
|
222
|
-
React.useEffect(() => {
|
|
223
|
-
if (!isResolvable) return;
|
|
224
|
-
const existing = lookupNameCache.get(cacheKey);
|
|
225
|
-
if (existing && existing.state !== 'pending' && (existing as any).promise == null) return;
|
|
226
|
-
if (existing?.state === 'pending') return;
|
|
227
|
-
|
|
228
|
-
const promise: Promise<void> = (async () => {
|
|
229
|
-
try {
|
|
230
|
-
let record: Record<string, unknown> | undefined;
|
|
231
|
-
if (typeof (dataSource as any).findOne === 'function') {
|
|
232
|
-
record = await (dataSource as any).findOne(referenceTo, value);
|
|
233
|
-
} else {
|
|
234
|
-
const result = await (dataSource as any).find(referenceTo, {
|
|
235
|
-
$filter: { id: value },
|
|
236
|
-
options: { $top: 1 },
|
|
237
|
-
});
|
|
238
|
-
const records: any[] = Array.isArray(result)
|
|
239
|
-
? result
|
|
240
|
-
: (result?.value || result?.data || []);
|
|
241
|
-
record = records[0];
|
|
242
|
-
}
|
|
243
|
-
// Resolve through the referenced object's schema (nameField /
|
|
244
|
-
// titleFormat) so the chip agrees with the picker — the bare key
|
|
245
|
-
// heuristic alone can surface an autonumber over the real name.
|
|
246
|
-
const schema = await fetchRefObjectSchema(dataSource, referenceTo!);
|
|
247
|
-
const name = resolveLookupRecordName(record, schema, displayField);
|
|
248
|
-
lookupNameCache.set(cacheKey, { state: 'ok', name });
|
|
249
|
-
} catch {
|
|
250
|
-
lookupNameCache.set(cacheKey, { state: 'err' });
|
|
251
|
-
}
|
|
252
|
-
force((n) => n + 1);
|
|
253
|
-
})();
|
|
254
|
-
|
|
255
|
-
lookupNameCache.set(cacheKey, { state: 'pending', promise });
|
|
256
|
-
}, [cacheKey, isResolvable, referenceTo, value, displayField, dataSource]);
|
|
257
|
-
|
|
258
|
-
if (!isResolvable) return undefined;
|
|
259
|
-
const entry = lookupNameCache.get(cacheKey);
|
|
260
|
-
return entry?.state === 'ok' ? entry.name : undefined;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* Safe label resolver for cell-level UI strings. Falls back to the English
|
|
265
|
-
* default when no I18nProvider is available or when the key is missing.
|
|
266
|
-
*/
|
|
267
|
-
function useFieldLabel() {
|
|
268
|
-
// useObjectTranslation is provider-safe (its context read is optional and
|
|
269
|
-
// react-i18next falls back to the global instance), so no try/catch —
|
|
270
|
-
// wrapping a hook call in try/catch violates rules-of-hooks: a throw after
|
|
271
|
-
// some hooks ran would desync hook order on the next render.
|
|
272
|
-
const { t } = useObjectTranslation();
|
|
273
|
-
return (key: string, fallback: string) => {
|
|
274
|
-
const v = t(key);
|
|
275
|
-
return !v || v === key ? fallback : v;
|
|
276
|
-
};
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Raw translate fn (with interpolation params) for cell-level strings, or
|
|
281
|
-
* undefined when no I18nProvider is mounted — callers keep their English
|
|
282
|
-
* fallback in that case.
|
|
283
|
-
*/
|
|
284
|
-
function useFieldTranslate(): ((key: string, params?: Record<string, unknown>) => string) | undefined {
|
|
285
|
-
// useObjectTranslation is provider-safe (optional context read; react-i18next
|
|
286
|
-
// falls back to the global instance), so it never throws — no try/catch, which
|
|
287
|
-
// would wrap a hook call and violate rules-of-hooks. Mirrors useFieldLabel.
|
|
288
|
-
const { t } = useObjectTranslation();
|
|
289
|
-
return t as (key: string, params?: Record<string, unknown>) => string;
|
|
290
|
-
}
|
|
291
|
-
|
|
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.
|
|
299
|
-
import { ImageLightbox } from './widgets/ImageLightbox';
|
|
300
|
-
import { readFileValues } from './widgets/file-value';
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* Cell renderer props
|
|
304
|
-
*/
|
|
305
|
-
export interface CellRendererProps {
|
|
306
|
-
value: any;
|
|
307
|
-
field: FieldMetadata;
|
|
308
|
-
isEditing?: boolean;
|
|
309
|
-
onChange?: (value: any) => void;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
/**
|
|
313
|
-
* Coerce a value to a safe primitive for rendering.
|
|
314
|
-
* Handles MongoDB wrapper types ($numberDecimal, $oid, $date), expanded
|
|
315
|
-
* reference objects, and arrays so that no raw object is ever passed as
|
|
316
|
-
* a React child — preventing React error #310.
|
|
317
|
-
*/
|
|
318
|
-
export function coerceToSafeValue(value: unknown): string | number | boolean | null | undefined {
|
|
319
|
-
if (value == null) return value as null | undefined;
|
|
320
|
-
if (typeof value === 'number' || typeof value === 'boolean') return value;
|
|
321
|
-
if (typeof value === 'string') {
|
|
322
|
-
// A reference/expanded value can arrive as a JSON-encoded object string —
|
|
323
|
-
// e.g. an unresolved external-id reference '{"externalId":"Website Relaunch"}'.
|
|
324
|
-
// Parse and extract a human label instead of leaking raw JSON into the cell.
|
|
325
|
-
const s = value.trim();
|
|
326
|
-
if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) {
|
|
327
|
-
try { return coerceToSafeValue(JSON.parse(s)); } catch { /* not JSON — fall through */ }
|
|
328
|
-
}
|
|
329
|
-
return value;
|
|
330
|
-
}
|
|
331
|
-
if (value instanceof Date) return value.toISOString();
|
|
332
|
-
if (Array.isArray(value)) {
|
|
333
|
-
return value.map((v) => {
|
|
334
|
-
if (v != null && typeof v === 'object') {
|
|
335
|
-
const obj = v as Record<string, unknown>;
|
|
336
|
-
return String(obj.name || obj.label || obj.externalId || obj.id || obj._id || '[Object]');
|
|
337
|
-
}
|
|
338
|
-
return String(v);
|
|
339
|
-
}).join(', ');
|
|
340
|
-
}
|
|
341
|
-
if (typeof value === 'object') {
|
|
342
|
-
const obj = value as Record<string, unknown>;
|
|
343
|
-
// MongoDB numeric wrapper: { $numberDecimal: "250000" }
|
|
344
|
-
if ('$numberDecimal' in obj) return Number(obj.$numberDecimal);
|
|
345
|
-
// MongoDB ObjectId wrapper: { $oid: "abc123" }
|
|
346
|
-
if ('$oid' in obj) return String(obj.$oid);
|
|
347
|
-
// MongoDB date wrapper: { $date: "2024-01-01T00:00:00Z" }
|
|
348
|
-
if ('$date' in obj) return String(obj.$date);
|
|
349
|
-
// Expanded reference / general object: extract name/label/externalId/id
|
|
350
|
-
return String(obj.name || obj.label || obj.externalId || obj.id || obj._id || '[Object]');
|
|
351
|
-
}
|
|
352
|
-
return String(value);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
/**
|
|
356
|
-
* Format currency value. When `currency` is undefined, falls back to a
|
|
357
|
-
* plain number with thousands separators (no symbol). Silently assuming
|
|
358
|
-
* USD for unconfigured currency fields was the #1 source of "why is my
|
|
359
|
-
* RMB amount showing as dollars?" bug reports.
|
|
360
|
-
*
|
|
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.
|
|
367
|
-
*/
|
|
368
|
-
import { resolveFieldCurrency, currencyFractionDigits } from './currency';
|
|
369
|
-
export { resolveFieldCurrency };
|
|
370
|
-
|
|
371
|
-
export function formatCurrency(value: number, currency?: string, locale?: string): string {
|
|
372
|
-
const isWhole = Number.isFinite(value) && value === Math.trunc(value);
|
|
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;
|
|
399
|
-
if (!currency) {
|
|
400
|
-
return formatNumber(value, fracDigits, locale);
|
|
401
|
-
}
|
|
402
|
-
try {
|
|
403
|
-
return formatDisplayNumber(value, {
|
|
404
|
-
locale,
|
|
405
|
-
currency,
|
|
406
|
-
minimumFractionDigits: fracDigits,
|
|
407
|
-
maximumFractionDigits: fracDigits,
|
|
408
|
-
});
|
|
409
|
-
} catch {
|
|
410
|
-
return `${currency} ${value.toFixed(fracDigits)}`;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* Format currency value in compact form for mobile display.
|
|
416
|
-
* E.g., $150,000 → $150K, $1,200,000 → $1.2M
|
|
417
|
-
* When `currency` is undefined, returns a compact number without symbol.
|
|
418
|
-
*/
|
|
419
|
-
export function formatCompactCurrency(value: number, currency?: string, locale?: string): string {
|
|
420
|
-
if (!currency) {
|
|
421
|
-
try {
|
|
422
|
-
const formatted = formatDisplayNumber(value, {
|
|
423
|
-
locale,
|
|
424
|
-
notation: 'compact',
|
|
425
|
-
maximumFractionDigits: 1,
|
|
426
|
-
});
|
|
427
|
-
return formatted.replace(/\.0(?=[KMBT])/, '');
|
|
428
|
-
} catch {
|
|
429
|
-
return String(value);
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
try {
|
|
433
|
-
const formatted = formatDisplayNumber(value, {
|
|
434
|
-
locale,
|
|
435
|
-
currency,
|
|
436
|
-
notation: 'compact',
|
|
437
|
-
maximumFractionDigits: 1,
|
|
438
|
-
});
|
|
439
|
-
// Strip trailing ".0" before compact suffix for consistent cross-environment output
|
|
440
|
-
// e.g. "$150.0K" → "$150K" while keeping "$1.5M" intact
|
|
441
|
-
return formatted.replace(/\.0(?=[KMBT])/, '');
|
|
442
|
-
} catch {
|
|
443
|
-
return `${currency} ${value}`;
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
/**
|
|
448
|
-
* Format a plain number with thousands separators, no currency symbol.
|
|
449
|
-
* Used as a safe fallback when a currency-typed field has no `currency`
|
|
450
|
-
* configured — we'd rather render `1,234.50` than silently assume USD.
|
|
451
|
-
*/
|
|
452
|
-
export function formatNumber(value: number, decimals: number = 2, locale?: string): string {
|
|
453
|
-
try {
|
|
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,
|
|
461
|
-
minimumFractionDigits: decimals,
|
|
462
|
-
maximumFractionDigits: decimals,
|
|
463
|
-
});
|
|
464
|
-
} catch {
|
|
465
|
-
return value.toFixed(decimals);
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
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.
|
|
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.
|
|
535
|
-
*/
|
|
536
|
-
export function formatPercent(value: number, precision: number = 0, locale?: string): string {
|
|
537
|
-
// Scale a fraction-stored percent (0.8 → 80%) via the shared core helper, so
|
|
538
|
-
// the list cell and the dashboard measure formatter (`formatMeasure`) agree.
|
|
539
|
-
const displayValue = percentDisplayValue(value);
|
|
540
|
-
return formatPercentBody(displayValue, precision, locale);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* Humanize a snake_case or kebab-case string into Title Case.
|
|
545
|
-
* Used as fallback label when no explicit option.label exists.
|
|
546
|
-
*
|
|
547
|
-
* Examples:
|
|
548
|
-
* "in_progress" → "In Progress"
|
|
549
|
-
* "high-priority" → "High Priority"
|
|
550
|
-
* "active" → "Active"
|
|
551
|
-
*/
|
|
552
|
-
export function humanizeLabel(value: string): string {
|
|
553
|
-
return value.replace(/[_-]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** Options shared by {@link formatDate} / {@link formatRelativeDate}. */
|
|
557
|
-
export interface DateDisplayOptions {
|
|
558
|
-
dueLike?: boolean;
|
|
559
|
-
/** BCP-47 display locale (ADR-0053 tenant default); falls back to the runtime locale. */
|
|
560
|
-
locale?: string;
|
|
561
|
-
/** i18n translate fn for phrases `Intl` can't produce (the "Overdue Nd" wording). */
|
|
562
|
-
t?: (key: string, params?: Record<string, unknown>) => string;
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* Localized day-granularity relative phrase ("Tomorrow", "3 days ago", "明天",
|
|
567
|
-
* "3天前"), sentence-cased for locales whose `Intl` output starts lowercase.
|
|
568
|
-
*/
|
|
569
|
-
function formatRelativeDays(diffDays: number, locale?: string): string {
|
|
570
|
-
try {
|
|
571
|
-
const phrase = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }).format(diffDays, 'day');
|
|
572
|
-
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
|
|
573
|
-
} catch {
|
|
574
|
-
// Invalid locale tag — degrade to English rather than crash the cell.
|
|
575
|
-
if (diffDays === 0) return 'Today';
|
|
576
|
-
if (diffDays === 1) return 'Tomorrow';
|
|
577
|
-
if (diffDays === -1) return 'Yesterday';
|
|
578
|
-
return diffDays > 0 ? `In ${diffDays} days` : `${Math.abs(diffDays)} days ago`;
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
/**
|
|
583
|
-
* Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
|
|
584
|
-
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
|
|
585
|
-
*
|
|
586
|
-
* `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
|
|
587
|
-
* isn't overdue, only a past due/deadline-semantic field is. Non-due-like
|
|
588
|
-
* past dates render as plain "N days ago" instead. The overdue phrase has no
|
|
589
|
-
* `Intl` equivalent, so it resolves through `options.t` (key
|
|
590
|
-
* `fields.relativeDate.overdue`) with an English fallback.
|
|
591
|
-
*/
|
|
592
|
-
export function formatRelativeDate(value: string | Date | number, options?: DateDisplayOptions): string {
|
|
593
|
-
if (value === null || value === undefined || value === '') return '—';
|
|
594
|
-
const date = value instanceof Date ? value : new Date(value as any);
|
|
595
|
-
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
596
|
-
|
|
597
|
-
const now = new Date();
|
|
598
|
-
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
599
|
-
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
600
|
-
const diffMs = startOfDate.getTime() - startOfToday.getTime();
|
|
601
|
-
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
602
|
-
|
|
603
|
-
// Beyond the ±7-day window, fall back to the absolute (already localized) form.
|
|
604
|
-
if (diffDays < -7 || diffDays > 7) return formatDate(date, undefined, options);
|
|
605
|
-
|
|
606
|
-
if (diffDays < -1 && options?.dueLike) {
|
|
607
|
-
const absDays = Math.abs(diffDays);
|
|
608
|
-
const key = 'fields.relativeDate.overdue';
|
|
609
|
-
const translated = options.t?.(key, { count: absDays });
|
|
610
|
-
return translated && translated !== key ? translated : `Overdue ${absDays}d`;
|
|
611
|
-
}
|
|
612
|
-
return formatRelativeDays(diffDays, options?.locale);
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Format date value
|
|
617
|
-
*/
|
|
618
|
-
export function formatDate(value: string | Date | number, style?: string, options?: DateDisplayOptions): string {
|
|
619
|
-
if (value === null || value === undefined || value === '') return '—';
|
|
620
|
-
const date = value instanceof Date ? value : new Date(value as any);
|
|
621
|
-
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
622
|
-
|
|
623
|
-
if (style === 'short') {
|
|
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' });
|
|
631
|
-
const day = date.getDate();
|
|
632
|
-
const year = String(date.getFullYear()).slice(-2);
|
|
633
|
-
return `${month} ${day}, '${year}`;
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
if (style === 'relative') {
|
|
637
|
-
return formatRelativeDate(date, options);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Default format: locale-aware human-readable. Drop the year when it
|
|
641
|
-
// matches the current year — Salesforce / HubSpot / Linear all do this
|
|
642
|
-
// because the year is rarely useful for in-progress records and the
|
|
643
|
-
// verbose "2026年7月21日" form crowds cards and table cells. Past- /
|
|
644
|
-
// future-year dates keep the year so users can disambiguate.
|
|
645
|
-
const isCurrentYear = date.getFullYear() === new Date().getFullYear();
|
|
646
|
-
return date.toLocaleDateString(options?.locale, {
|
|
647
|
-
year: isCurrentYear ? undefined : 'numeric',
|
|
648
|
-
month: 'short',
|
|
649
|
-
day: 'numeric',
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
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()`.
|
|
663
|
-
*/
|
|
664
|
-
export function formatDateTime(value: string | Date | number, options?: DateDisplayOptions): string {
|
|
665
|
-
if (value === null || value === undefined || value === '') return '—';
|
|
666
|
-
const date = value instanceof Date ? value : new Date(value as any);
|
|
667
|
-
if (!(date instanceof Date) || isNaN(date.getTime())) return '—';
|
|
668
|
-
|
|
669
|
-
return date.toLocaleDateString(options?.locale, {
|
|
670
|
-
year: 'numeric',
|
|
671
|
-
month: 'short',
|
|
672
|
-
day: 'numeric',
|
|
673
|
-
hour: '2-digit',
|
|
674
|
-
minute: '2-digit',
|
|
675
|
-
});
|
|
676
|
-
}
|
|
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
|
-
|
|
707
|
-
/**
|
|
708
|
-
* Text field cell renderer
|
|
709
|
-
*/
|
|
710
|
-
export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
711
|
-
const safe = coerceToSafeValue(value);
|
|
712
|
-
if (safe == null || safe === '') return <EmptyValue />;
|
|
713
|
-
return <TruncatedText text={String(safe)} />;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/**
|
|
717
|
-
* Number field cell renderer
|
|
718
|
-
*/
|
|
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();
|
|
724
|
-
if (value == null) return <EmptyValue />;
|
|
725
|
-
|
|
726
|
-
const safe = coerceToSafeValue(value);
|
|
727
|
-
const numField = field as any;
|
|
728
|
-
// Decimal places come from `scale` (the `s` in a `decimal(p, s)` column),
|
|
729
|
-
// NOT `precision` — `precision` is the TOTAL digit count (`p`), and reading
|
|
730
|
-
// it here padded every value out to that width (e.g. `1` from a
|
|
731
|
-
// decimal(10, 0) column rendered as "1.0000000000"). When `scale` is
|
|
732
|
-
// declared we pad to it so a fixed display is honoured (e.g. an amount with
|
|
733
|
-
// scale 2 → "16.00", a field with scale 3 → "3.140"); when it is absent we
|
|
734
|
-
// keep the minimum at 0 so trailing zeros are trimmed and only cap the
|
|
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.
|
|
743
|
-
const scale = typeof numField.scale === 'number' ? numField.scale : undefined;
|
|
744
|
-
const num = Number(safe);
|
|
745
|
-
const formatted = !isNaN(num)
|
|
746
|
-
? formatDisplayNumber(num, {
|
|
747
|
-
locale,
|
|
748
|
-
scale,
|
|
749
|
-
minimumFractionDigits: scale ?? 0,
|
|
750
|
-
maximumFractionDigits: scale ?? 20,
|
|
751
|
-
})
|
|
752
|
-
: String(safe);
|
|
753
|
-
|
|
754
|
-
return <span className="tabular-nums">{formatted}</span>;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
/**
|
|
758
|
-
* Currency field cell renderer
|
|
759
|
-
*/
|
|
760
|
-
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
761
|
-
// Hooks before the empty-value early return — a value flipping between
|
|
762
|
-
// null and set must not change the hook count between renders.
|
|
763
|
-
const { currency: tenantCurrency } = useLocalization();
|
|
764
|
-
const locale = useDisplayLocale();
|
|
765
|
-
if (value == null) return <EmptyValue />;
|
|
766
|
-
|
|
767
|
-
const safe = coerceToSafeValue(value);
|
|
768
|
-
// Resolve the display currency via the shared precedence: field `currency` →
|
|
769
|
-
// `currencyConfig.defaultCurrency` → the tenant default (ADR-0053). When none
|
|
770
|
-
// is known, render a plain number — never a guessed symbol (silently assuming
|
|
771
|
-
// USD mis-displays non-USD orgs, e.g. RMB amounts shown as $).
|
|
772
|
-
const currency = resolveFieldCurrency(field as any, tenantCurrency);
|
|
773
|
-
const num = Number(safe);
|
|
774
|
-
const formatted = !isNaN(num)
|
|
775
|
-
? formatCurrency(num, currency, locale)
|
|
776
|
-
: String(safe);
|
|
777
|
-
|
|
778
|
-
return <span className="tabular-nums font-medium whitespace-nowrap">{formatted}</span>;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
// Fields that store percentage values as whole numbers (0-100) rather than fractions (0-1)
|
|
782
|
-
const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
|
|
783
|
-
|
|
784
|
-
/**
|
|
785
|
-
* Percent field cell renderer with mini progress bar
|
|
786
|
-
*/
|
|
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();
|
|
792
|
-
if (value == null) return <EmptyValue />;
|
|
793
|
-
|
|
794
|
-
const safe = coerceToSafeValue(value);
|
|
795
|
-
const percentField = field as any;
|
|
796
|
-
const precision = percentField.precision ?? 0;
|
|
797
|
-
const numValue = Number(safe);
|
|
798
|
-
if (isNaN(numValue)) {
|
|
799
|
-
return <span className="tabular-nums whitespace-nowrap">{String(safe)}</span>;
|
|
800
|
-
}
|
|
801
|
-
// Use field name to disambiguate 0-1 fraction vs 0-100 whole number:
|
|
802
|
-
// Fields like "progress" or "completion" store values as 0-100, not 0-1
|
|
803
|
-
const isWholePercentField = WHOLE_PERCENT_FIELD_PATTERN.test(field?.name?.toLowerCase() || '');
|
|
804
|
-
const barValue = isWholePercentField
|
|
805
|
-
? numValue
|
|
806
|
-
: (numValue > -1 && numValue < 1) ? numValue * 100 : numValue;
|
|
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);
|
|
817
|
-
const clampedBar = Math.max(0, Math.min(100, barValue));
|
|
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.
|
|
832
|
-
return (
|
|
833
|
-
<div className="flex min-w-0 items-center gap-2">
|
|
834
|
-
<div
|
|
835
|
-
className="h-1.5 w-16 min-w-0 shrink rounded-full bg-muted ring-1 ring-inset ring-border/60 overflow-hidden"
|
|
836
|
-
role="progressbar"
|
|
837
|
-
aria-valuenow={clampedBar}
|
|
838
|
-
aria-valuemin={0}
|
|
839
|
-
aria-valuemax={100}
|
|
840
|
-
>
|
|
841
|
-
<div
|
|
842
|
-
className="h-full rounded-full bg-primary transition-all"
|
|
843
|
-
style={{ width: `${clampedBar}%` }}
|
|
844
|
-
/>
|
|
845
|
-
</div>
|
|
846
|
-
<span className="shrink-0 tabular-nums whitespace-nowrap">{formatted}</span>
|
|
847
|
-
</div>
|
|
848
|
-
);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
/** Field names that trigger warning badge when boolean value is false */
|
|
852
|
-
const STATUS_FIELD_NAMES = new Set([
|
|
853
|
-
'active', 'is_active', 'enabled', 'is_enabled', 'verified', 'is_verified',
|
|
854
|
-
]);
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Boolean field cell renderer (Airtable-style checkbox)
|
|
858
|
-
* Supports semantic rendering for completion fields (green indicator)
|
|
859
|
-
* and warning badge for active/enabled fields when false.
|
|
860
|
-
*/
|
|
861
|
-
export function BooleanCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
862
|
-
if (value == null) {
|
|
863
|
-
return <span className="flex items-center justify-center"><EmptyValue /></span>;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// Semantic rendering for completion fields (green circle indicator)
|
|
867
|
-
// Only match exact field names to avoid false positives
|
|
868
|
-
const fieldName = field?.name?.toLowerCase() || '';
|
|
869
|
-
const isCompletionField = fieldName === 'completed' || fieldName === 'is_completed'
|
|
870
|
-
|| fieldName === 'done' || fieldName === 'is_done';
|
|
871
|
-
|
|
872
|
-
if (isCompletionField) {
|
|
873
|
-
return (
|
|
874
|
-
<div className="flex items-center justify-center">
|
|
875
|
-
{value ? (
|
|
876
|
-
<div className="size-5 rounded-full bg-green-500 flex items-center justify-center" role="img" aria-label="Completed" data-testid="completion-indicator">
|
|
877
|
-
<Check className="size-3 text-white" />
|
|
878
|
-
</div>
|
|
879
|
-
) : (
|
|
880
|
-
<div className="size-5 rounded-full border-2 border-muted-foreground/30" role="img" aria-label="Not completed" data-testid="completion-indicator" />
|
|
881
|
-
)}
|
|
882
|
-
</div>
|
|
883
|
-
);
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
// Warning badge for active/enabled fields when false
|
|
887
|
-
if (STATUS_FIELD_NAMES.has(fieldName) && !value) {
|
|
888
|
-
return (
|
|
889
|
-
<Badge variant="destructive" className="text-xs" data-testid="boolean-warning-badge">
|
|
890
|
-
{field?.label || humanizeLabel(fieldName)} — Off
|
|
891
|
-
</Badge>
|
|
892
|
-
);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
return (
|
|
896
|
-
<div className="flex items-center justify-start">
|
|
897
|
-
<Checkbox checked={!!value} disabled className="pointer-events-none" />
|
|
898
|
-
</div>
|
|
899
|
-
);
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
/**
|
|
903
|
-
* Date field cell renderer
|
|
904
|
-
*/
|
|
905
|
-
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
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();
|
|
914
|
-
const t = useFieldTranslate();
|
|
915
|
-
if (!value) return <EmptyValue />;
|
|
916
|
-
const safe = coerceToSafeValue(value);
|
|
917
|
-
const dateField = field as any;
|
|
918
|
-
const style = dateField.format || 'relative';
|
|
919
|
-
|
|
920
|
-
// A date is only *semantically* a due/deadline when the field says so — a
|
|
921
|
-
// plain "start_date" or "created_at" in the past is neither overdue text
|
|
922
|
-
// nor red, even though it renders in the same relative-time style.
|
|
923
|
-
const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
|
|
924
|
-
const dueLike =
|
|
925
|
-
dateField?.dueLike === true ||
|
|
926
|
-
/(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
|
|
927
|
-
const formatted = formatDate(safe as string | Date, style, { dueLike, locale, t });
|
|
928
|
-
|
|
929
|
-
const date = safe != null ? new Date(safe as string | number) : null;
|
|
930
|
-
const isValidDate = date !== null && !isNaN(date.getTime());
|
|
931
|
-
const startOfToday = new Date();
|
|
932
|
-
startOfToday.setHours(0, 0, 0, 0);
|
|
933
|
-
const isOverdue = dueLike && isValidDate && date! < startOfToday;
|
|
934
|
-
const isoString = isValidDate ? date!.toISOString() : String(safe);
|
|
935
|
-
|
|
936
|
-
return (
|
|
937
|
-
<span
|
|
938
|
-
className={`tabular-nums${isOverdue ? ' text-red-600' : ''}`}
|
|
939
|
-
title={isoString}
|
|
940
|
-
>
|
|
941
|
-
{formatted}
|
|
942
|
-
</span>
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
/**
|
|
947
|
-
* DateTime field cell renderer (Airtable-style with date and time visually separated)
|
|
948
|
-
*/
|
|
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();
|
|
956
|
-
if (!value) return <EmptyValue />;
|
|
957
|
-
const safe = coerceToSafeValue(value);
|
|
958
|
-
const date = safe != null ? new Date(safe as string | number) : null;
|
|
959
|
-
if (date === null || isNaN(date.getTime())) return <EmptyValue />;
|
|
960
|
-
|
|
961
|
-
const datePart = date.toLocaleDateString(locale, {
|
|
962
|
-
month: 'numeric',
|
|
963
|
-
day: 'numeric',
|
|
964
|
-
year: 'numeric',
|
|
965
|
-
});
|
|
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, {
|
|
970
|
-
hour: 'numeric',
|
|
971
|
-
minute: '2-digit',
|
|
972
|
-
hour12: true,
|
|
973
|
-
}).toLowerCase();
|
|
974
|
-
|
|
975
|
-
return (
|
|
976
|
-
<span className="tabular-nums text-sm whitespace-nowrap">
|
|
977
|
-
<span>{datePart}</span>
|
|
978
|
-
<span className="ml-2 text-muted-foreground">{timePart}</span>
|
|
979
|
-
</span>
|
|
980
|
-
);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
// Semantic color mapping (auto-detect from value text for priority & status fields)
|
|
984
|
-
// Keys use underscore notation; lookup normalizes spaces/hyphens to underscores automatically.
|
|
985
|
-
// Chinese keys are stored as-is and matched directly (no normalization side-effects).
|
|
986
|
-
const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
987
|
-
// Priority values (en)
|
|
988
|
-
critical: 'red',
|
|
989
|
-
urgent: 'red',
|
|
990
|
-
high: 'orange',
|
|
991
|
-
medium: 'yellow',
|
|
992
|
-
normal: 'blue',
|
|
993
|
-
low: 'gray',
|
|
994
|
-
none: 'gray',
|
|
995
|
-
// Status values (en)
|
|
996
|
-
paid: 'green',
|
|
997
|
-
completed: 'green',
|
|
998
|
-
done: 'green',
|
|
999
|
-
active: 'green',
|
|
1000
|
-
approved: 'green',
|
|
1001
|
-
resolved: 'green',
|
|
1002
|
-
pending: 'yellow',
|
|
1003
|
-
waiting: 'yellow',
|
|
1004
|
-
on_hold: 'yellow',
|
|
1005
|
-
shipped: 'blue',
|
|
1006
|
-
in_progress: 'blue',
|
|
1007
|
-
open: 'blue',
|
|
1008
|
-
processing: 'blue',
|
|
1009
|
-
draft: 'gray',
|
|
1010
|
-
new: 'gray',
|
|
1011
|
-
inactive: 'gray',
|
|
1012
|
-
closed: 'gray',
|
|
1013
|
-
cancelled: 'red',
|
|
1014
|
-
canceled: 'red',
|
|
1015
|
-
rejected: 'red',
|
|
1016
|
-
failed: 'red',
|
|
1017
|
-
overdue: 'red',
|
|
1018
|
-
delivered: 'purple',
|
|
1019
|
-
archived: 'indigo',
|
|
1020
|
-
// CRM lifecycle values (en)
|
|
1021
|
-
contacted: 'blue',
|
|
1022
|
-
qualified: 'purple',
|
|
1023
|
-
converted: 'green',
|
|
1024
|
-
won: 'green',
|
|
1025
|
-
lost: 'red',
|
|
1026
|
-
// Priority values (zh)
|
|
1027
|
-
紧急: 'red',
|
|
1028
|
-
严重: 'red',
|
|
1029
|
-
高: 'orange',
|
|
1030
|
-
中: 'yellow',
|
|
1031
|
-
普通: 'blue',
|
|
1032
|
-
低: 'gray',
|
|
1033
|
-
无: 'gray',
|
|
1034
|
-
// Status values (zh)
|
|
1035
|
-
新建: 'gray',
|
|
1036
|
-
草稿: 'gray',
|
|
1037
|
-
待处理: 'yellow',
|
|
1038
|
-
待审核: 'yellow',
|
|
1039
|
-
待联系: 'yellow',
|
|
1040
|
-
挂起: 'yellow',
|
|
1041
|
-
进行中: 'blue',
|
|
1042
|
-
处理中: 'blue',
|
|
1043
|
-
已联系: 'blue',
|
|
1044
|
-
跟进中: 'blue',
|
|
1045
|
-
已发货: 'blue',
|
|
1046
|
-
打开: 'blue',
|
|
1047
|
-
已确认: 'green',
|
|
1048
|
-
已审核: 'green',
|
|
1049
|
-
已通过: 'green',
|
|
1050
|
-
已完成: 'green',
|
|
1051
|
-
已支付: 'green',
|
|
1052
|
-
已签收: 'green',
|
|
1053
|
-
已转化: 'green',
|
|
1054
|
-
成单: 'green',
|
|
1055
|
-
赢得: 'green',
|
|
1056
|
-
已签约: 'green',
|
|
1057
|
-
已交付: 'purple',
|
|
1058
|
-
已归档: 'indigo',
|
|
1059
|
-
已关闭: 'gray',
|
|
1060
|
-
已取消: 'red',
|
|
1061
|
-
已拒绝: 'red',
|
|
1062
|
-
失败: 'red',
|
|
1063
|
-
逾期: 'red',
|
|
1064
|
-
流失: 'red',
|
|
1065
|
-
丢失: 'red',
|
|
1066
|
-
};
|
|
1067
|
-
|
|
1068
|
-
// Color to Tailwind class mapping for custom Badge styling
|
|
1069
|
-
// Color → Tailwind class mapping for status-style badges.
|
|
1070
|
-
// Uses the modern "soft pill" pattern (Tailwind UI style): -50 background,
|
|
1071
|
-
// -700 text, hairline -200 border. Dark mode mirrors with -950/40 surface
|
|
1072
|
-
// and -300 text. This keeps status fields readable without the heavy,
|
|
1073
|
-
// candy-colored look of the older -100/-300/-800 combination.
|
|
1074
|
-
const BADGE_COLOR_MAP: Record<string, string> = {
|
|
1075
|
-
gray: 'bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800/50 dark:text-gray-200 dark:border-gray-700/60',
|
|
1076
|
-
red: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900/60',
|
|
1077
|
-
orange: 'bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-300 dark:border-orange-900/60',
|
|
1078
|
-
yellow: 'bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-300 dark:border-yellow-900/60',
|
|
1079
|
-
green: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-300 dark:border-green-900/60',
|
|
1080
|
-
blue: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900/60',
|
|
1081
|
-
indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-300 dark:border-indigo-900/60',
|
|
1082
|
-
purple: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900/60',
|
|
1083
|
-
pink: 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-950/40 dark:text-pink-300 dark:border-pink-900/60',
|
|
1084
|
-
};
|
|
1085
|
-
|
|
1086
|
-
// Solid color → Tailwind background class for the small dot used by the
|
|
1087
|
-
// `appearance: 'dot'` rendering of select/status fields. Uses the -500 shade
|
|
1088
|
-
// for both light and dark modes so the dot remains a clear visual anchor
|
|
1089
|
-
// without becoming a heavy color block.
|
|
1090
|
-
const DOT_COLOR_MAP: Record<string, string> = {
|
|
1091
|
-
gray: 'bg-gray-400 dark:bg-gray-500',
|
|
1092
|
-
red: 'bg-red-500',
|
|
1093
|
-
orange: 'bg-orange-500',
|
|
1094
|
-
yellow: 'bg-yellow-500',
|
|
1095
|
-
green: 'bg-green-500',
|
|
1096
|
-
blue: 'bg-blue-500',
|
|
1097
|
-
indigo: 'bg-indigo-500',
|
|
1098
|
-
purple: 'bg-purple-500',
|
|
1099
|
-
pink: 'bg-pink-500',
|
|
1100
|
-
};
|
|
1101
|
-
|
|
1102
|
-
// Color palette used by the deterministic fallback when no schema/semantic
|
|
1103
|
-
// color matches. Excludes 'gray' to ensure visual contrast between values.
|
|
1104
|
-
const BADGE_FALLBACK_PALETTE: readonly string[] = [
|
|
1105
|
-
'blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red',
|
|
1106
|
-
];
|
|
1107
|
-
|
|
1108
|
-
/**
|
|
1109
|
-
* Stable string hash (djb2-ish) → palette index.
|
|
1110
|
-
* Same value always yields the same color across renders/sessions.
|
|
1111
|
-
*/
|
|
1112
|
-
function hashToColor(value: string): string {
|
|
1113
|
-
let h = 5381;
|
|
1114
|
-
for (let i = 0; i < value.length; i++) {
|
|
1115
|
-
h = ((h << 5) + h) ^ value.charCodeAt(i);
|
|
1116
|
-
}
|
|
1117
|
-
const idx = Math.abs(h) % BADGE_FALLBACK_PALETTE.length;
|
|
1118
|
-
return BADGE_FALLBACK_PALETTE[idx];
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
/**
|
|
1122
|
-
* Map a hex color (e.g. '#8B5CF6') to the nearest named palette color the
|
|
1123
|
-
* badge/dot maps understand. Object field options almost always declare colors
|
|
1124
|
-
* as HEX, so without this the explicit author color is ignored and a semantic/
|
|
1125
|
-
* hash heuristic takes over (e.g. a purple 'In Review' rendered alarming-red).
|
|
1126
|
-
* Low-saturation hexes resolve to 'gray'; otherwise bucket by hue.
|
|
1127
|
-
*/
|
|
1128
|
-
function hexToPaletteName(hex: string): string | undefined {
|
|
1129
|
-
const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
|
|
1130
|
-
if (!m) return undefined;
|
|
1131
|
-
let h = m[1];
|
|
1132
|
-
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
1133
|
-
const r = parseInt(h.slice(0, 2), 16) / 255;
|
|
1134
|
-
const g = parseInt(h.slice(2, 4), 16) / 255;
|
|
1135
|
-
const b = parseInt(h.slice(4, 6), 16) / 255;
|
|
1136
|
-
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
|
|
1137
|
-
const l = (max + min) / 2;
|
|
1138
|
-
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
|
1139
|
-
if (sat < 0.22) return 'gray';
|
|
1140
|
-
let hue: number;
|
|
1141
|
-
if (max === r) hue = (((g - b) / d) % 6 + 6) % 6;
|
|
1142
|
-
else if (max === g) hue = (b - r) / d + 2;
|
|
1143
|
-
else hue = (r - g) / d + 4;
|
|
1144
|
-
hue *= 60;
|
|
1145
|
-
if (hue >= 345 || hue < 15) return 'red';
|
|
1146
|
-
if (hue < 30) return 'orange';
|
|
1147
|
-
if (hue < 60) return 'yellow';
|
|
1148
|
-
if (hue < 170) return 'green';
|
|
1149
|
-
if (hue < 238) return 'blue';
|
|
1150
|
-
if (hue < 250) return 'indigo';
|
|
1151
|
-
if (hue < 295) return 'purple';
|
|
1152
|
-
return 'pink';
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
/** Normalize an option color to a named palette key: pass known names through,
|
|
1156
|
-
* resolve hex to the nearest palette color, else undefined. */
|
|
1157
|
-
function resolveColorName(color?: string): string | undefined {
|
|
1158
|
-
if (!color) return undefined;
|
|
1159
|
-
if (BADGE_COLOR_MAP[color]) return color;
|
|
1160
|
-
if (color.charAt(0) === '#') return hexToPaletteName(color);
|
|
1161
|
-
return undefined;
|
|
1162
|
-
}
|
|
1163
|
-
|
|
1164
|
-
export function getBadgeColorClasses(color?: string, val?: unknown): string {
|
|
1165
|
-
const named = resolveColorName(color);
|
|
1166
|
-
if (named && BADGE_COLOR_MAP[named]) return BADGE_COLOR_MAP[named];
|
|
1167
|
-
if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
|
|
1168
|
-
const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
|
|
1169
|
-
const semantic = SEMANTIC_COLOR_MAP[key];
|
|
1170
|
-
if (semantic && BADGE_COLOR_MAP[semantic]) return BADGE_COLOR_MAP[semantic];
|
|
1171
|
-
// Deterministic fallback so distinct values are visually distinguishable
|
|
1172
|
-
// even when metadata declares no colors.
|
|
1173
|
-
return BADGE_COLOR_MAP[hashToColor(key)];
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
|
-
/**
|
|
1177
|
-
* Resolve a semantic color name (e.g. "red", "green") for a value, suitable
|
|
1178
|
-
* for callers that need a raw color token rather than CSS classes (for
|
|
1179
|
-
* example, the Gantt renderer paints bars via inline styles).
|
|
1180
|
-
*
|
|
1181
|
-
* Resolution order: explicit option color → semantic value mapping →
|
|
1182
|
-
* deterministic hash fallback. Returns `undefined` only when no value is
|
|
1183
|
-
* supplied so the caller can fall back to its own default.
|
|
1184
|
-
*/
|
|
1185
|
-
export function getSemanticColorName(color?: string, val?: unknown): string | undefined {
|
|
1186
|
-
const named = resolveColorName(color);
|
|
1187
|
-
if (named && BADGE_COLOR_MAP[named]) return named;
|
|
1188
|
-
if (val == null || val === '') return undefined;
|
|
1189
|
-
const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
|
|
1190
|
-
const semantic = SEMANTIC_COLOR_MAP[key];
|
|
1191
|
-
if (semantic) return semantic;
|
|
1192
|
-
return hashToColor(key);
|
|
1193
|
-
}
|
|
1194
|
-
|
|
1195
|
-
// Resolved hex values for the -500 shade of each palette color. Mirrors
|
|
1196
|
-
// `DOT_COLOR_MAP` and is consumed by callers that paint via inline styles
|
|
1197
|
-
// (e.g. Gantt task bars, where Tailwind classes can't be applied to dynamic
|
|
1198
|
-
// `style={}` values).
|
|
1199
|
-
const COLOR_NAME_HEX: Record<string, string> = {
|
|
1200
|
-
gray: '#6b7280',
|
|
1201
|
-
red: '#ef4444',
|
|
1202
|
-
orange: '#f97316',
|
|
1203
|
-
yellow: '#eab308',
|
|
1204
|
-
green: '#22c55e',
|
|
1205
|
-
blue: '#3b82f6',
|
|
1206
|
-
indigo: '#6366f1',
|
|
1207
|
-
purple: '#a855f7',
|
|
1208
|
-
pink: '#ec4899',
|
|
1209
|
-
};
|
|
1210
|
-
|
|
1211
|
-
/**
|
|
1212
|
-
* Map a semantic color name to its Tailwind -500 hex value. Used by
|
|
1213
|
-
* inline-style consumers (Gantt bars). Falls back to the supplied default
|
|
1214
|
-
* (or the platform default blue) when the name is unrecognized.
|
|
1215
|
-
*/
|
|
1216
|
-
export function getSemanticHex(name?: string, fallback: string = '#3b82f6'): string {
|
|
1217
|
-
if (!name) return fallback;
|
|
1218
|
-
return COLOR_NAME_HEX[name] ?? fallback;
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
/**
|
|
1222
|
-
* Select field cell renderer.
|
|
1223
|
-
*
|
|
1224
|
-
* Two visual styles, controlled by `field.appearance` (renderer-level option,
|
|
1225
|
-
* not part of the `@objectstack/spec` field schema):
|
|
1226
|
-
* - `'badge'` (default for spec compatibility): soft-pill colored badge.
|
|
1227
|
-
* - `'dot'`: a small colored dot followed by the option label. Used by
|
|
1228
|
-
* dense list/grid contexts to keep the table visually quiet — repeated
|
|
1229
|
-
* filled badges across many rows create heavy visual noise.
|
|
1230
|
-
*
|
|
1231
|
-
* Metadata always wins: callers can pass `appearance: 'badge'` on the field
|
|
1232
|
-
* descriptor to force the legacy badge in any context.
|
|
1233
|
-
*/
|
|
1234
|
-
export function SelectCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
1235
|
-
const selectField = field as any;
|
|
1236
|
-
const options: SelectOptionMetadata[] = selectField.options || [];
|
|
1237
|
-
const appearance: 'badge' | 'dot' = selectField.appearance === 'dot' ? 'dot' : 'badge';
|
|
1238
|
-
|
|
1239
|
-
if (value == null || value === '') return <EmptyValue />;
|
|
1240
|
-
|
|
1241
|
-
// Match a stored value to a configured option, falling back to a
|
|
1242
|
-
// case-insensitive comparison so seed data with mixed case
|
|
1243
|
-
// (e.g. "Referral" stored, "referral" defined) still resolves to the
|
|
1244
|
-
// localized option label.
|
|
1245
|
-
const findOption = (val: any): SelectOptionMetadata | undefined => {
|
|
1246
|
-
const exact = options.find(opt => opt.value === val);
|
|
1247
|
-
if (exact) return exact;
|
|
1248
|
-
const norm = String(val).toLowerCase();
|
|
1249
|
-
return options.find(opt => String(opt.value).toLowerCase() === norm);
|
|
1250
|
-
};
|
|
1251
|
-
|
|
1252
|
-
const renderOne = (val: any, key?: number): React.ReactElement => {
|
|
1253
|
-
const option = findOption(val);
|
|
1254
|
-
const label = option?.label || humanizeLabel(String(val));
|
|
1255
|
-
|
|
1256
|
-
if (appearance === 'dot') {
|
|
1257
|
-
// Resolve a real CSS color for the dot. Prefer explicit option color,
|
|
1258
|
-
// then semantic mapping for the value, then deterministic palette.
|
|
1259
|
-
const colorName = resolveColorName(option?.color)
|
|
1260
|
-
|| SEMANTIC_COLOR_MAP[String(val).toLowerCase().replace(/[\s-]/g, '_')]
|
|
1261
|
-
|| hashToColor(String(val).toLowerCase().replace(/[\s-]/g, '_'));
|
|
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).
|
|
1266
|
-
return (
|
|
1267
|
-
<span key={key} className="inline-flex max-w-full items-center gap-1.5 text-sm" title={label}>
|
|
1268
|
-
<span className={cn('h-1.5 w-1.5 rounded-full shrink-0', dotClass)} aria-hidden="true" />
|
|
1269
|
-
<span className="min-w-0 truncate">{label}</span>
|
|
1270
|
-
</span>
|
|
1271
|
-
);
|
|
1272
|
-
}
|
|
1273
|
-
|
|
1274
|
-
const colorClasses = getBadgeColorClasses(option?.color, val);
|
|
1275
|
-
// max-w-full + inner truncate: in bounded containers (detail highlight
|
|
1276
|
-
// strip columns, grid cells) an overlong label used to clip mid-glyph at
|
|
1277
|
-
// the container edge; now the badge shrinks and ellipsizes, with the full
|
|
1278
|
-
// label on hover.
|
|
1279
|
-
return (
|
|
1280
|
-
<Badge
|
|
1281
|
-
key={key}
|
|
1282
|
-
variant="outline"
|
|
1283
|
-
className={cn('max-w-full min-w-0', colorClasses)}
|
|
1284
|
-
title={label}
|
|
1285
|
-
>
|
|
1286
|
-
<span className="truncate">{label}</span>
|
|
1287
|
-
</Badge>
|
|
1288
|
-
);
|
|
1289
|
-
};
|
|
1290
|
-
|
|
1291
|
-
// Handle multiple values
|
|
1292
|
-
if (Array.isArray(value)) {
|
|
1293
|
-
return (
|
|
1294
|
-
<div className={cn('flex flex-wrap', appearance === 'dot' ? 'gap-x-3 gap-y-1' : 'gap-1')}>
|
|
1295
|
-
{value.map((val, idx) => renderOne(val, idx))}
|
|
1296
|
-
</div>
|
|
1297
|
-
);
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
return renderOne(value);
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
/**
|
|
1304
|
-
* Email field cell renderer
|
|
1305
|
-
*/
|
|
1306
|
-
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1307
|
-
// Hooks before the empty-value early return (rules-of-hooks).
|
|
1308
|
-
const label = useFieldLabel();
|
|
1309
|
-
const [copied, setCopied] = React.useState(false);
|
|
1310
|
-
if (!value) return <EmptyValue />;
|
|
1311
|
-
|
|
1312
|
-
const safe = String(coerceToSafeValue(value) ?? '');
|
|
1313
|
-
|
|
1314
|
-
const handleCopy = (e: React.MouseEvent) => {
|
|
1315
|
-
e.stopPropagation();
|
|
1316
|
-
e.preventDefault();
|
|
1317
|
-
navigator.clipboard.writeText(safe).then(() => {
|
|
1318
|
-
setCopied(true);
|
|
1319
|
-
setTimeout(() => setCopied(false), 2000);
|
|
1320
|
-
}).catch(() => { /* clipboard not available */ });
|
|
1321
|
-
};
|
|
1322
|
-
|
|
1323
|
-
return (
|
|
1324
|
-
<span className="inline-flex items-center gap-1 group/email">
|
|
1325
|
-
<Button
|
|
1326
|
-
variant="link"
|
|
1327
|
-
className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
|
|
1328
|
-
asChild
|
|
1329
|
-
>
|
|
1330
|
-
<a
|
|
1331
|
-
href={`mailto:${safe}`}
|
|
1332
|
-
onClick={(e) => e.stopPropagation()}
|
|
1333
|
-
>
|
|
1334
|
-
{safe}
|
|
1335
|
-
</a>
|
|
1336
|
-
</Button>
|
|
1337
|
-
<button
|
|
1338
|
-
type="button"
|
|
1339
|
-
className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
|
1340
|
-
onClick={handleCopy}
|
|
1341
|
-
aria-label={label('detail.copyEmail', 'Copy email')}
|
|
1342
|
-
>
|
|
1343
|
-
{copied ? (
|
|
1344
|
-
<Check className="h-3 w-3 text-green-600" />
|
|
1345
|
-
) : (
|
|
1346
|
-
<Copy className="h-3 w-3 text-muted-foreground" />
|
|
1347
|
-
)}
|
|
1348
|
-
</button>
|
|
1349
|
-
</span>
|
|
1350
|
-
);
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
/**
|
|
1354
|
-
* URL field cell renderer
|
|
1355
|
-
*/
|
|
1356
|
-
export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1357
|
-
if (!value) return <EmptyValue />;
|
|
1358
|
-
|
|
1359
|
-
const safe = String(coerceToSafeValue(value) ?? '');
|
|
1360
|
-
return (
|
|
1361
|
-
<Button
|
|
1362
|
-
variant="link"
|
|
1363
|
-
className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
|
|
1364
|
-
asChild
|
|
1365
|
-
>
|
|
1366
|
-
<a
|
|
1367
|
-
href={safe}
|
|
1368
|
-
target="_blank"
|
|
1369
|
-
rel="noopener noreferrer"
|
|
1370
|
-
onClick={(e) => e.stopPropagation()}
|
|
1371
|
-
>
|
|
1372
|
-
{safe}
|
|
1373
|
-
</a>
|
|
1374
|
-
</Button>
|
|
1375
|
-
);
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
/**
|
|
1379
|
-
* Phone field cell renderer
|
|
1380
|
-
*/
|
|
1381
|
-
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1382
|
-
// Hooks before the empty-value early return (rules-of-hooks).
|
|
1383
|
-
const label = useFieldLabel();
|
|
1384
|
-
const [copied, setCopied] = React.useState(false);
|
|
1385
|
-
if (!value) return <EmptyValue />;
|
|
1386
|
-
|
|
1387
|
-
const safe = String(coerceToSafeValue(value) ?? '');
|
|
1388
|
-
|
|
1389
|
-
const handleCopy = (e: React.MouseEvent) => {
|
|
1390
|
-
e.stopPropagation();
|
|
1391
|
-
e.preventDefault();
|
|
1392
|
-
navigator.clipboard.writeText(safe).then(() => {
|
|
1393
|
-
setCopied(true);
|
|
1394
|
-
setTimeout(() => setCopied(false), 2000);
|
|
1395
|
-
}).catch(() => { /* clipboard not available */ });
|
|
1396
|
-
};
|
|
1397
|
-
|
|
1398
|
-
return (
|
|
1399
|
-
<span className="inline-flex items-center gap-1 group/phone">
|
|
1400
|
-
<a
|
|
1401
|
-
href={`tel:${safe}`}
|
|
1402
|
-
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
|
|
1403
|
-
onClick={(e) => e.stopPropagation()}
|
|
1404
|
-
>
|
|
1405
|
-
<PhoneIcon className="h-3 w-3" />
|
|
1406
|
-
{safe}
|
|
1407
|
-
</a>
|
|
1408
|
-
<button
|
|
1409
|
-
type="button"
|
|
1410
|
-
className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
|
|
1411
|
-
onClick={handleCopy}
|
|
1412
|
-
aria-label={label('detail.copyPhone', 'Copy phone number')}
|
|
1413
|
-
>
|
|
1414
|
-
{copied ? (
|
|
1415
|
-
<Check className="h-3 w-3 text-green-600" />
|
|
1416
|
-
) : (
|
|
1417
|
-
<Copy className="h-3 w-3 text-muted-foreground" />
|
|
1418
|
-
)}
|
|
1419
|
-
</button>
|
|
1420
|
-
</span>
|
|
1421
|
-
);
|
|
1422
|
-
}
|
|
1423
|
-
|
|
1424
|
-
/**
|
|
1425
|
-
* File field cell renderer
|
|
1426
|
-
*/
|
|
1427
|
-
export function FileCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
1428
|
-
if (!value) return <EmptyValue />;
|
|
1429
|
-
|
|
1430
|
-
const fileField = field as any;
|
|
1431
|
-
const isMultiple = fileField.multiple;
|
|
1432
|
-
|
|
1433
|
-
if (Array.isArray(value)) {
|
|
1434
|
-
const count = value.length;
|
|
1435
|
-
return (
|
|
1436
|
-
<span className="text-sm text-gray-600">
|
|
1437
|
-
{count} {count === 1 ? 'file' : 'files'}
|
|
1438
|
-
</span>
|
|
1439
|
-
);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
const fileName = value.name || value.original_name || 'File';
|
|
1443
|
-
return <TruncatedText text={String(fileName)} className="text-sm" />;
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
/**
|
|
1447
|
-
* Image field cell renderer (with thumbnails + click-to-zoom).
|
|
1448
|
-
*
|
|
1449
|
-
* An image value may be a plain URL string, an object ({ url | src | href … }),
|
|
1450
|
-
* a bare `sys_file` id, or an array of any of those. Normalising through
|
|
1451
|
-
* `readFileValues` (which resolves a bare id to its stable download URL) means
|
|
1452
|
-
* a string-URL field, a CDN link, and an unexpanded reference all render a
|
|
1453
|
-
* thumbnail instead of a broken `<img src="">` placeholder.
|
|
1454
|
-
*
|
|
1455
|
-
* Clicking a thumbnail opens a full-screen lightbox (single or gallery). The
|
|
1456
|
-
* click is `stopPropagation`-guarded so, inside a grid row, enlarging an image
|
|
1457
|
-
* doesn't also trigger row navigation.
|
|
1458
|
-
*/
|
|
1459
|
-
export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1460
|
-
const { t } = useObjectTranslation();
|
|
1461
|
-
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
|
|
1462
|
-
|
|
1463
|
-
const imgs = React.useMemo(
|
|
1464
|
-
() =>
|
|
1465
|
-
readFileValues(value, 'Image')
|
|
1466
|
-
.filter((v) => v.url)
|
|
1467
|
-
.map((v) => ({ url: v.url as string, name: v.name })),
|
|
1468
|
-
[value],
|
|
1469
|
-
);
|
|
1470
|
-
|
|
1471
|
-
if (!value || imgs.length === 0) return <EmptyValue />;
|
|
1472
|
-
|
|
1473
|
-
const imageAlt = (idx: number, name?: string) =>
|
|
1474
|
-
name || t('fields.image.imageAlt', { index: idx + 1 });
|
|
1475
|
-
const open = (idx: number) => (e: React.MouseEvent) => {
|
|
1476
|
-
e.stopPropagation();
|
|
1477
|
-
setLightboxIndex(idx);
|
|
1478
|
-
};
|
|
1479
|
-
const multiple = imgs.length > 1;
|
|
1480
|
-
|
|
1481
|
-
const lightbox = lightboxIndex !== null && (
|
|
1482
|
-
<ImageLightbox
|
|
1483
|
-
images={imgs}
|
|
1484
|
-
index={lightboxIndex}
|
|
1485
|
-
open
|
|
1486
|
-
onOpenChange={(o) => !o && setLightboxIndex(null)}
|
|
1487
|
-
onIndexChange={setLightboxIndex}
|
|
1488
|
-
/>
|
|
1489
|
-
);
|
|
1490
|
-
|
|
1491
|
-
if (multiple) {
|
|
1492
|
-
return (
|
|
1493
|
-
<>
|
|
1494
|
-
<div className="flex -space-x-2">
|
|
1495
|
-
{imgs.slice(0, 3).map((img, idx) => (
|
|
1496
|
-
<img
|
|
1497
|
-
key={idx}
|
|
1498
|
-
src={img.url}
|
|
1499
|
-
alt={imageAlt(idx, img.name)}
|
|
1500
|
-
onClick={open(idx)}
|
|
1501
|
-
className="size-8 cursor-zoom-in rounded-md border-2 border-background object-cover transition-transform hover:scale-110 hover:z-10"
|
|
1502
|
-
/>
|
|
1503
|
-
))}
|
|
1504
|
-
{imgs.length > 3 && (
|
|
1505
|
-
<button
|
|
1506
|
-
type="button"
|
|
1507
|
-
onClick={open(3)}
|
|
1508
|
-
className="size-8 rounded-md border-2 border-background bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground hover:bg-muted/80"
|
|
1509
|
-
>
|
|
1510
|
-
+{imgs.length - 3}
|
|
1511
|
-
</button>
|
|
1512
|
-
)}
|
|
1513
|
-
</div>
|
|
1514
|
-
{lightbox}
|
|
1515
|
-
</>
|
|
1516
|
-
);
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
return (
|
|
1520
|
-
<>
|
|
1521
|
-
<img
|
|
1522
|
-
src={imgs[0].url}
|
|
1523
|
-
alt={imageAlt(0, imgs[0].name)}
|
|
1524
|
-
onClick={open(0)}
|
|
1525
|
-
className="size-10 cursor-zoom-in rounded-md object-cover transition-transform hover:scale-105"
|
|
1526
|
-
/>
|
|
1527
|
-
{lightbox}
|
|
1528
|
-
</>
|
|
1529
|
-
);
|
|
1530
|
-
}
|
|
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
|
-
|
|
1615
|
-
/**
|
|
1616
|
-
* Lookup/Master-Detail field cell renderer.
|
|
1617
|
-
*
|
|
1618
|
-
* Display order:
|
|
1619
|
-
* 1. Embedded record object (`{ id, name, ... }` from `$expand`) → use its name
|
|
1620
|
-
* 2. Static `field.options[]` (e.g. when the lookup is a closed enum) → look up label
|
|
1621
|
-
* 3. Fetch-on-demand: when the value is a primitive ID and `field.reference_to`
|
|
1622
|
-
* is known, resolve via dataSource and show the related record's display name.
|
|
1623
|
-
* Falls back to a muted placeholder while pending and on failure.
|
|
1624
|
-
*
|
|
1625
|
-
* Record → name resolution (1 and 3) goes through the referenced object's
|
|
1626
|
-
* schema when the data source exposes it (`displayField` → nameField/titleFormat
|
|
1627
|
-
* → derivation, see {@link resolveLookupRecordName}), so the chip and the
|
|
1628
|
-
* picker agree (issue #2357).
|
|
1629
|
-
*/
|
|
1630
|
-
export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
1631
|
-
// ObjectStack object metadata uses `reference` for the lookup target while the
|
|
1632
|
-
// objectui types call it `reference_to`. Every other reader (LookupField,
|
|
1633
|
-
// UserField, DetailSection, RelatedList, …) accepts both; this read cell must
|
|
1634
|
-
// too, or a picked/opaque id never resolves to a name and the cell shows the
|
|
1635
|
-
// muted "—" placeholder forever (e.g. after inline-editing a lookup).
|
|
1636
|
-
const referenceTo =
|
|
1637
|
-
(field as { reference_to?: string }).reference_to ||
|
|
1638
|
-
(field as { reference?: string }).reference;
|
|
1639
|
-
|
|
1640
|
-
// Explicit author-chosen display field on the lookup — beats every resolver.
|
|
1641
|
-
// ObjectGrid forwards `display_field` on the column meta (RELATIONAL_META_KEYS)
|
|
1642
|
-
// the same way it forwards `reference` (#2926 ⑧).
|
|
1643
|
-
const displayField =
|
|
1644
|
-
(field as { display_field?: string }).display_field ||
|
|
1645
|
-
(field as { displayField?: string }).displayField ||
|
|
1646
|
-
(field as { reference_field?: string }).reference_field ||
|
|
1647
|
-
undefined;
|
|
1648
|
-
|
|
1649
|
-
// Referenced object's schema (nameField / titleFormat) so expanded records
|
|
1650
|
-
// resolve through the same unified resolver as the picker (issue #2357).
|
|
1651
|
-
const refSchema = useRefObjectSchema(referenceTo);
|
|
1652
|
-
|
|
1653
|
-
// Pick the FIRST primitive id we see (for arrays, only the first one is auto-resolved
|
|
1654
|
-
// to keep the cell cheap; multi-value lookups should generally be expanded server-side).
|
|
1655
|
-
const primaryPrimitiveId = (() => {
|
|
1656
|
-
if (Array.isArray(value)) {
|
|
1657
|
-
const firstPrimitive = value.find(
|
|
1658
|
-
(v) => v != null && (typeof v === 'string' || typeof v === 'number') && v !== '',
|
|
1659
|
-
);
|
|
1660
|
-
return firstPrimitive;
|
|
1661
|
-
}
|
|
1662
|
-
if (
|
|
1663
|
-
value != null &&
|
|
1664
|
-
value !== '' &&
|
|
1665
|
-
(typeof value === 'string' || typeof value === 'number') &&
|
|
1666
|
-
typeof value !== 'object'
|
|
1667
|
-
) {
|
|
1668
|
-
return value;
|
|
1669
|
-
}
|
|
1670
|
-
return undefined;
|
|
1671
|
-
})();
|
|
1672
|
-
|
|
1673
|
-
// Always call the hook (rules of hooks). It safely no-ops when inputs are missing.
|
|
1674
|
-
const resolvedName = useLookupName(referenceTo, primaryPrimitiveId, displayField);
|
|
1675
|
-
|
|
1676
|
-
if (value == null || value === '') return <EmptyValue />;
|
|
1677
|
-
|
|
1678
|
-
// A reference can arrive as a JSON-encoded object string — e.g. an
|
|
1679
|
-
// unresolved external-id reference '{"externalId":"Website Relaunch"}'.
|
|
1680
|
-
// Parse it and render a label instead of leaking raw JSON into the cell.
|
|
1681
|
-
if (typeof value === 'string') {
|
|
1682
|
-
const s = value.trim();
|
|
1683
|
-
if (s.startsWith('{') && s.endsWith('}')) {
|
|
1684
|
-
// Compute inside try, render outside — constructing JSX in a try/catch
|
|
1685
|
-
// doesn't catch its render errors anyway (react-hooks/error-boundaries).
|
|
1686
|
-
let parsedDisplay = '';
|
|
1687
|
-
let parsedId: string | number | undefined;
|
|
1688
|
-
try {
|
|
1689
|
-
const parsed = JSON.parse(s) as Record<string, unknown>;
|
|
1690
|
-
if (parsed && typeof parsed === 'object') {
|
|
1691
|
-
parsedDisplay =
|
|
1692
|
-
resolveLookupRecordName(parsed, refSchema, displayField) ||
|
|
1693
|
-
String(parsed.externalId ?? parsed.id ?? parsed._id ?? '');
|
|
1694
|
-
// An external-id reference has no record id yet — stays unlinked.
|
|
1695
|
-
parsedId = referencedRecordId(parsed);
|
|
1696
|
-
}
|
|
1697
|
-
} catch { /* not JSON — fall through to normal resolution */ }
|
|
1698
|
-
if (parsedDisplay) {
|
|
1699
|
-
return (
|
|
1700
|
-
<ReferencedRecordLink objectName={referenceTo} recordId={parsedId}>
|
|
1701
|
-
<TruncatedText text={parsedDisplay} />
|
|
1702
|
-
</ReferencedRecordLink>
|
|
1703
|
-
);
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
// Server-side $expand returns the related record as a nested object
|
|
1709
|
-
// (e.g. { id, name }). Render its display name directly — no fetch needed.
|
|
1710
|
-
if (!Array.isArray(value) && typeof value === 'object') {
|
|
1711
|
-
const obj = value as Record<string, unknown>;
|
|
1712
|
-
const display =
|
|
1713
|
-
resolveLookupRecordName(obj, refSchema, displayField) || String(obj.id || obj._id || '');
|
|
1714
|
-
if (display) {
|
|
1715
|
-
return (
|
|
1716
|
-
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(obj)}>
|
|
1717
|
-
<TruncatedText text={display} />
|
|
1718
|
-
</ReferencedRecordLink>
|
|
1719
|
-
);
|
|
1720
|
-
}
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
const options: Array<{ value: unknown; label: string }> =
|
|
1724
|
-
(field as { options?: Array<{ value: unknown; label: string }> }).options || [];
|
|
1725
|
-
|
|
1726
|
-
// Resolve a primitive ID to a label. Order:
|
|
1727
|
-
// options → server-resolved name (via useLookupName) → muted placeholder for opaque IDs → raw value
|
|
1728
|
-
const resolveLabel = (val: unknown): { text: string; muted: boolean } => {
|
|
1729
|
-
if (options.length > 0) {
|
|
1730
|
-
const found = options.find((opt) => String(opt.value) === String(val));
|
|
1731
|
-
if (found) return { text: found.label, muted: false };
|
|
1732
|
-
}
|
|
1733
|
-
if (val === primaryPrimitiveId && resolvedName) {
|
|
1734
|
-
return { text: resolvedName, muted: false };
|
|
1735
|
-
}
|
|
1736
|
-
if (isLikelyOpaqueId(val)) {
|
|
1737
|
-
// Don't dump a random-looking ID at the user. Show a soft placeholder
|
|
1738
|
-
// that conveys "this is a reference, name unavailable".
|
|
1739
|
-
return { text: '—', muted: true };
|
|
1740
|
-
}
|
|
1741
|
-
return { text: String(val), muted: false };
|
|
1742
|
-
};
|
|
1743
|
-
|
|
1744
|
-
if (Array.isArray(value)) {
|
|
1745
|
-
return (
|
|
1746
|
-
<div className="flex flex-wrap gap-1">
|
|
1747
|
-
{value.map((item, idx) => {
|
|
1748
|
-
let label: string;
|
|
1749
|
-
let muted = false;
|
|
1750
|
-
if (item != null && typeof item === 'object') {
|
|
1751
|
-
label = resolveLookupRecordName(item as Record<string, unknown>, refSchema, displayField) || String((item as any).id || (item as any)._id || '[Object]');
|
|
1752
|
-
} else {
|
|
1753
|
-
const r = resolveLabel(item);
|
|
1754
|
-
label = r.text;
|
|
1755
|
-
muted = r.muted;
|
|
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.
|
|
1759
|
-
return (
|
|
1760
|
-
<ReferencedRecordLink
|
|
1761
|
-
key={idx}
|
|
1762
|
-
objectName={referenceTo}
|
|
1763
|
-
recordId={referencedRecordId(item)}
|
|
1764
|
-
className="text-inherit"
|
|
1765
|
-
>
|
|
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>
|
|
1777
|
-
);
|
|
1778
|
-
})}
|
|
1779
|
-
</div>
|
|
1780
|
-
);
|
|
1781
|
-
}
|
|
1782
|
-
|
|
1783
|
-
if (typeof value === 'object' && value !== null) {
|
|
1784
|
-
const label =
|
|
1785
|
-
resolveLookupRecordName(value as Record<string, unknown>, refSchema, displayField) ||
|
|
1786
|
-
String((value as any).id || (value as any)._id || '[Object]');
|
|
1787
|
-
return (
|
|
1788
|
-
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
|
|
1789
|
-
<TruncatedText text={label} />
|
|
1790
|
-
</ReferencedRecordLink>
|
|
1791
|
-
);
|
|
1792
|
-
}
|
|
1793
|
-
|
|
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.
|
|
1798
|
-
const { text, muted } = resolveLabel(value);
|
|
1799
|
-
return (
|
|
1800
|
-
<ReferencedRecordLink objectName={referenceTo} recordId={referencedRecordId(value)}>
|
|
1801
|
-
<TruncatedText text={text} className={muted ? 'text-muted-foreground' : undefined} />
|
|
1802
|
-
</ReferencedRecordLink>
|
|
1803
|
-
);
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
/**
|
|
1807
|
-
* Formula field cell renderer (read-only)
|
|
1808
|
-
*/
|
|
1809
|
-
export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1810
|
-
const safe = coerceToSafeValue(value);
|
|
1811
|
-
if (safe == null || safe === '') return <EmptyValue />;
|
|
1812
|
-
return (
|
|
1813
|
-
<span className="text-gray-700 font-mono text-sm">
|
|
1814
|
-
{String(safe)}
|
|
1815
|
-
</span>
|
|
1816
|
-
);
|
|
1817
|
-
}
|
|
1818
|
-
|
|
1819
|
-
/**
|
|
1820
|
-
* User/Owner field cell renderer (with avatars)
|
|
1821
|
-
*/
|
|
1822
|
-
export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1823
|
-
if (!value) return <EmptyValue />;
|
|
1824
|
-
|
|
1825
|
-
// Primitive value: just display the ID/username as text
|
|
1826
|
-
if (typeof value !== 'object') {
|
|
1827
|
-
return <TruncatedText text={String(value)} />;
|
|
1828
|
-
}
|
|
1829
|
-
|
|
1830
|
-
if (Array.isArray(value)) {
|
|
1831
|
-
return (
|
|
1832
|
-
<div className="flex -space-x-2">
|
|
1833
|
-
{value.slice(0, 3).map((user, idx) => {
|
|
1834
|
-
// Primitive user in array
|
|
1835
|
-
if (typeof user !== 'object' || user === null) {
|
|
1836
|
-
return <TruncatedText key={idx} text={String(user)} className="text-sm" />;
|
|
1837
|
-
}
|
|
1838
|
-
const name = user.name || user.username || 'User';
|
|
1839
|
-
const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
|
|
1840
|
-
|
|
1841
|
-
return (
|
|
1842
|
-
<Avatar
|
|
1843
|
-
key={idx}
|
|
1844
|
-
className="size-8 border-2 border-white"
|
|
1845
|
-
title={name}
|
|
1846
|
-
>
|
|
1847
|
-
{user.image && <AvatarImage src={user.image} alt={name} />}
|
|
1848
|
-
<AvatarFallback className="bg-blue-500 text-white text-xs">
|
|
1849
|
-
{initials}
|
|
1850
|
-
</AvatarFallback>
|
|
1851
|
-
</Avatar>
|
|
1852
|
-
);
|
|
1853
|
-
})}
|
|
1854
|
-
{value.length > 3 && (
|
|
1855
|
-
<Avatar className="size-8 border-2 border-white">
|
|
1856
|
-
<AvatarFallback className="bg-gray-200 text-gray-600 text-xs">
|
|
1857
|
-
+{value.length - 3}
|
|
1858
|
-
</AvatarFallback>
|
|
1859
|
-
</Avatar>
|
|
1860
|
-
)}
|
|
1861
|
-
</div>
|
|
1862
|
-
);
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
const name = value.name || value.username || 'User';
|
|
1866
|
-
const initials = name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2);
|
|
1867
|
-
|
|
1868
|
-
return (
|
|
1869
|
-
<div className="flex items-center gap-2">
|
|
1870
|
-
<Avatar className="size-8">
|
|
1871
|
-
{value.image && <AvatarImage src={value.image} alt={name} />}
|
|
1872
|
-
<AvatarFallback className="bg-blue-500 text-white text-xs">
|
|
1873
|
-
{initials}
|
|
1874
|
-
</AvatarFallback>
|
|
1875
|
-
</Avatar>
|
|
1876
|
-
<TruncatedText text={String(name)} />
|
|
1877
|
-
</div>
|
|
1878
|
-
);
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
/**
|
|
1882
|
-
* Field Registry
|
|
1883
|
-
* Stores mapping between field types and their renderers.
|
|
1884
|
-
*/
|
|
1885
|
-
const fieldRegistry = new Map<string, React.FC<CellRendererProps>>();
|
|
1886
|
-
|
|
1887
|
-
/**
|
|
1888
|
-
* Register a custom field renderer
|
|
1889
|
-
* @param type Field type (e.g. 'text', 'location', 'my-custom-type')
|
|
1890
|
-
* @param renderer React component to render the field
|
|
1891
|
-
*/
|
|
1892
|
-
export function registerFieldRenderer(type: string, renderer: React.FC<CellRendererProps>) {
|
|
1893
|
-
fieldRegistry.set(type, renderer);
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
/**
|
|
1897
|
-
* Format hints (e.g. `{ type: 'text', format: 'phone' }`) that should
|
|
1898
|
-
* map to a richer cell renderer than the bare type would imply. The
|
|
1899
|
-
* canonical ObjectStack pattern uses `Field.text({ format: 'phone' })`
|
|
1900
|
-
* for plain text columns that should still render with a tel: link,
|
|
1901
|
-
* `Field.text({ format: 'url' })` for clickable links, etc.
|
|
1902
|
-
*
|
|
1903
|
-
* Only applies when the field's base type is a generic text type;
|
|
1904
|
-
* explicit types like 'phone'/'email'/'currency' already win without
|
|
1905
|
-
* any format hint.
|
|
1906
|
-
*/
|
|
1907
|
-
const FORMAT_TO_RENDERER: Record<string, string> = {
|
|
1908
|
-
phone: 'phone',
|
|
1909
|
-
tel: 'phone',
|
|
1910
|
-
telephone: 'phone',
|
|
1911
|
-
email: 'email',
|
|
1912
|
-
url: 'url',
|
|
1913
|
-
uri: 'url',
|
|
1914
|
-
link: 'url',
|
|
1915
|
-
currency: 'currency',
|
|
1916
|
-
money: 'currency',
|
|
1917
|
-
percent: 'percent',
|
|
1918
|
-
percentage: 'percent',
|
|
1919
|
-
};
|
|
1920
|
-
|
|
1921
|
-
const TEXTUAL_BASE_TYPES = new Set(['text', 'textarea', 'string', 'longtext', '']);
|
|
1922
|
-
|
|
1923
|
-
/**
|
|
1924
|
-
* Resolve the canonical cell-renderer key for a field. Accepts either a
|
|
1925
|
-
* raw type string (back-compat) or a field-metadata object so that
|
|
1926
|
-
* format hints (`format: 'phone'` etc.) can promote a plain `text`
|
|
1927
|
-
* field to its richer renderer counterpart.
|
|
1928
|
-
*/
|
|
1929
|
-
export function resolveCellRendererType(fieldOrType: string | { type?: string; format?: string } | null | undefined): string {
|
|
1930
|
-
if (!fieldOrType) return 'text';
|
|
1931
|
-
if (typeof fieldOrType === 'string') return fieldOrType;
|
|
1932
|
-
const baseType = (fieldOrType.type || '').toLowerCase();
|
|
1933
|
-
const formatRaw = fieldOrType.format;
|
|
1934
|
-
const format = typeof formatRaw === 'string' ? formatRaw.toLowerCase() : '';
|
|
1935
|
-
if (format && FORMAT_TO_RENDERER[format] && TEXTUAL_BASE_TYPES.has(baseType)) {
|
|
1936
|
-
return FORMAT_TO_RENDERER[format];
|
|
1937
|
-
}
|
|
1938
|
-
return fieldOrType.type || 'text';
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
/**
|
|
1942
|
-
* Renders structured/embedded values (json, object, composite, record,
|
|
1943
|
-
* address, geolocation) as compact, readable JSON. Objects and arrays are
|
|
1944
|
-
* stringified; primitives fall through to their string form.
|
|
1945
|
-
*/
|
|
1946
|
-
export function JsonCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1947
|
-
if (value == null || value === '') return <EmptyValue />;
|
|
1948
|
-
let text: string;
|
|
1949
|
-
if (typeof value === 'object') {
|
|
1950
|
-
try {
|
|
1951
|
-
text = JSON.stringify(value);
|
|
1952
|
-
} catch {
|
|
1953
|
-
text = String(value);
|
|
1954
|
-
}
|
|
1955
|
-
} else {
|
|
1956
|
-
text = String(value);
|
|
1957
|
-
}
|
|
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" />;
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
/**
|
|
1965
|
-
* Renders a `color` value as a swatch alongside its hex/string value.
|
|
1966
|
-
*/
|
|
1967
|
-
export function ColorSwatchCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1968
|
-
if (value == null || value === '') return <EmptyValue />;
|
|
1969
|
-
const color = String(value);
|
|
1970
|
-
return (
|
|
1971
|
-
<span className="inline-flex items-center gap-1.5 text-sm">
|
|
1972
|
-
<span
|
|
1973
|
-
className="h-3.5 w-3.5 rounded border border-black/10 shrink-0"
|
|
1974
|
-
style={{ backgroundColor: color }}
|
|
1975
|
-
aria-hidden="true"
|
|
1976
|
-
/>
|
|
1977
|
-
<span className="font-mono text-xs">{color}</span>
|
|
1978
|
-
</span>
|
|
1979
|
-
);
|
|
1980
|
-
}
|
|
1981
|
-
|
|
1982
|
-
const LazyMarkdownContent = React.lazy(() => import('./widgets/MarkdownContent'));
|
|
1983
|
-
|
|
1984
|
-
/**
|
|
1985
|
-
* Renders `markdown` / `richtext` values as formatted GFM markdown (lazy-loaded,
|
|
1986
|
-
* sanitized) instead of the raw markup string.
|
|
1987
|
-
*/
|
|
1988
|
-
export function MarkdownCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1989
|
-
if (value == null || value === '') return <EmptyValue />;
|
|
1990
|
-
return (
|
|
1991
|
-
<React.Suspense fallback={<span className="text-sm text-muted-foreground">{String(value).slice(0, 80)}</span>}>
|
|
1992
|
-
<LazyMarkdownContent value={String(value)} />
|
|
1993
|
-
</React.Suspense>
|
|
1994
|
-
);
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
/**
|
|
1998
|
-
* Minimal HTML sanitizer for display: drops <script>/<style>/<iframe> blocks,
|
|
1999
|
-
* inline event handlers, and javascript: URLs. Defense-in-depth — stored HTML
|
|
2000
|
-
* is authored by users with write access, but is never trusted blindly.
|
|
2001
|
-
*/
|
|
2002
|
-
function sanitizeHtml(html: string): string {
|
|
2003
|
-
return html
|
|
2004
|
-
.replace(/<\s*(script|style|iframe|object|embed)[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
|
2005
|
-
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
2006
|
-
.replace(/(href|src)\s*=\s*("javascript:[^"]*"|'javascript:[^']*')/gi, '$1="#"');
|
|
2007
|
-
}
|
|
2008
|
-
|
|
2009
|
-
/**
|
|
2010
|
-
* Renders an `html` value as sanitized, formatted HTML instead of raw markup.
|
|
2011
|
-
*/
|
|
2012
|
-
export function HtmlCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
2013
|
-
if (value == null || value === '') return <EmptyValue />;
|
|
2014
|
-
return (
|
|
2015
|
-
<div
|
|
2016
|
-
className="prose prose-sm max-w-none dark:prose-invert break-words"
|
|
2017
|
-
dangerouslySetInnerHTML={{ __html: sanitizeHtml(String(value)) }}
|
|
2018
|
-
/>
|
|
2019
|
-
);
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
/**
|
|
2023
|
-
* Renders a `location`/`geolocation` value as readable coordinates with a pin.
|
|
2024
|
-
* Accepts `{ lat, lng }` / `{ latitude, longitude }`, a `"lat,lng"` string,
|
|
2025
|
-
* or a `[lat, lng]` array. Falls back to compact JSON for anything else.
|
|
2026
|
-
*/
|
|
2027
|
-
export function LocationCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
2028
|
-
if (value == null || value === '') return <EmptyValue />;
|
|
2029
|
-
let lat: number | undefined;
|
|
2030
|
-
let lng: number | undefined;
|
|
2031
|
-
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
2032
|
-
const v = value as Record<string, any>;
|
|
2033
|
-
lat = typeof v.lat === 'number' ? v.lat : typeof v.latitude === 'number' ? v.latitude : undefined;
|
|
2034
|
-
lng = typeof v.lng === 'number' ? v.lng : typeof v.lon === 'number' ? v.lon : typeof v.longitude === 'number' ? v.longitude : undefined;
|
|
2035
|
-
} else if (Array.isArray(value) && value.length === 2) {
|
|
2036
|
-
lat = Number(value[0]);
|
|
2037
|
-
lng = Number(value[1]);
|
|
2038
|
-
} else if (typeof value === 'string') {
|
|
2039
|
-
const parts = value.split(',').map((s) => parseFloat(s.trim()));
|
|
2040
|
-
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
|
|
2041
|
-
[lat, lng] = parts;
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
if (typeof lat === 'number' && typeof lng === 'number' && !isNaN(lat) && !isNaN(lng)) {
|
|
2045
|
-
return (
|
|
2046
|
-
<span className="inline-flex items-center gap-1 text-sm tabular-nums">
|
|
2047
|
-
<MapPin className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
2048
|
-
{lat.toFixed(4)}, {lng.toFixed(4)}
|
|
2049
|
-
</span>
|
|
2050
|
-
);
|
|
2051
|
-
}
|
|
2052
|
-
return <JsonCellRenderer value={value} field={{} as any} />;
|
|
2053
|
-
}
|
|
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
|
-
|
|
2088
|
-
/**
|
|
2089
|
-
* Get the appropriate cell renderer for a field type
|
|
2090
|
-
*/
|
|
2091
|
-
export function getCellRenderer(fieldType: string): React.FC<CellRendererProps> {
|
|
2092
|
-
// 1. Try exact match in registry
|
|
2093
|
-
if (fieldRegistry.has(fieldType)) {
|
|
2094
|
-
return fieldRegistry.get(fieldType)!;
|
|
2095
|
-
}
|
|
2096
|
-
|
|
2097
|
-
// 2. Fallback to standard mappings if not overridden
|
|
2098
|
-
const standardMap: Record<string, React.FC<CellRendererProps>> = {
|
|
2099
|
-
text: TextCellRenderer,
|
|
2100
|
-
textarea: TextCellRenderer,
|
|
2101
|
-
markdown: MarkdownCellRenderer,
|
|
2102
|
-
html: HtmlCellRenderer,
|
|
2103
|
-
richtext: MarkdownCellRenderer,
|
|
2104
|
-
code: TextCellRenderer,
|
|
2105
|
-
qrcode: TextCellRenderer,
|
|
2106
|
-
number: NumberCellRenderer,
|
|
2107
|
-
currency: CurrencyCellRenderer,
|
|
2108
|
-
percent: PercentCellRenderer,
|
|
2109
|
-
progress: PercentCellRenderer,
|
|
2110
|
-
slider: NumberCellRenderer,
|
|
2111
|
-
rating: NumberCellRenderer,
|
|
2112
|
-
boolean: BooleanCellRenderer,
|
|
2113
|
-
toggle: BooleanCellRenderer,
|
|
2114
|
-
date: DateCellRenderer,
|
|
2115
|
-
datetime: DateTimeCellRenderer,
|
|
2116
|
-
time: TextCellRenderer,
|
|
2117
|
-
select: SelectCellRenderer,
|
|
2118
|
-
status: SelectCellRenderer,
|
|
2119
|
-
multiselect: SelectCellRenderer,
|
|
2120
|
-
radio: SelectCellRenderer,
|
|
2121
|
-
checkboxes: SelectCellRenderer,
|
|
2122
|
-
tags: SelectCellRenderer,
|
|
2123
|
-
lookup: LookupCellRenderer,
|
|
2124
|
-
master_detail: LookupCellRenderer,
|
|
2125
|
-
tree: LookupCellRenderer,
|
|
2126
|
-
email: EmailCellRenderer,
|
|
2127
|
-
url: UrlCellRenderer,
|
|
2128
|
-
phone: PhoneCellRenderer,
|
|
2129
|
-
file: FileCellRenderer,
|
|
2130
|
-
video: FileCellRenderer,
|
|
2131
|
-
audio: FileCellRenderer,
|
|
2132
|
-
image: ImageCellRenderer,
|
|
2133
|
-
avatar: ImageCellRenderer,
|
|
2134
|
-
signature: ImageCellRenderer,
|
|
2135
|
-
formula: FormulaCellRenderer,
|
|
2136
|
-
summary: FormulaCellRenderer,
|
|
2137
|
-
auto_number: TextCellRenderer,
|
|
2138
|
-
user: UserCellRenderer,
|
|
2139
|
-
owner: UserCellRenderer,
|
|
2140
|
-
password: () => <span>••••••</span>,
|
|
2141
|
-
secret: () => <span>••••••</span>,
|
|
2142
|
-
location: LocationCellRenderer,
|
|
2143
|
-
geolocation: LocationCellRenderer,
|
|
2144
|
-
address: AddressCellRenderer,
|
|
2145
|
-
color: ColorSwatchCellRenderer,
|
|
2146
|
-
json: JsonCellRenderer,
|
|
2147
|
-
object: JsonCellRenderer,
|
|
2148
|
-
composite: JsonCellRenderer,
|
|
2149
|
-
record: JsonCellRenderer,
|
|
2150
|
-
repeater: ({ value }: CellRendererProps) => {
|
|
2151
|
-
const n = Array.isArray(value) ? value.length : 0;
|
|
2152
|
-
return n > 0
|
|
2153
|
-
? <span className="text-gray-500 italic">{n} 项</span>
|
|
2154
|
-
: <EmptyValue />;
|
|
2155
|
-
},
|
|
2156
|
-
vector: () => <span className="text-gray-500 italic">[Vector]</span>,
|
|
2157
|
-
grid: () => <span className="text-gray-500 italic">[Grid]</span>,
|
|
2158
|
-
};
|
|
2159
|
-
|
|
2160
|
-
// 3. Register standard renderers implicitly if not present
|
|
2161
|
-
// This ensures that if we call registerFieldRenderer('text', Custom), it works,
|
|
2162
|
-
// but if we don't, we get the standard one.
|
|
2163
|
-
return standardMap[fieldType] || TextCellRenderer;
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
// Register standard renderers immediately
|
|
2167
|
-
registerFieldRenderer('lookup', LookupCellRenderer);
|
|
2168
|
-
registerFieldRenderer('master_detail', LookupCellRenderer);
|
|
2169
|
-
registerFieldRenderer('select', SelectCellRenderer);
|
|
2170
|
-
registerFieldRenderer('status', SelectCellRenderer);
|
|
2171
|
-
registerFieldRenderer('user', UserCellRenderer);
|
|
2172
|
-
registerFieldRenderer('owner', UserCellRenderer);
|
|
2173
|
-
|
|
2174
|
-
// Register getCellRenderer in the bridge so RecordPickerDialog can access it
|
|
2175
|
-
// via LookupField without circular imports.
|
|
2176
|
-
import { setCellRendererResolver } from './widgets/_cell-renderer-bridge';
|
|
2177
|
-
setCellRendererResolver(getCellRenderer);
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
// `mapFieldTypeToFormType` moved to './field-type-alias' (re-exported below) so
|
|
2182
|
-
// FieldEditWidget can resolve spec aliases without importing this barrel.
|
|
2183
|
-
export { mapFieldTypeToFormType } from './field-type-alias';
|
|
2184
|
-
import { mapFieldTypeToFormType } from './field-type-alias';
|
|
2185
|
-
|
|
2186
|
-
/**
|
|
2187
|
-
* Formats file size in bytes to human-readable string
|
|
2188
|
-
* @param bytes - File size in bytes (must be non-negative)
|
|
2189
|
-
* @returns Formatted string (e.g., "5 MB", "1.5 GB")
|
|
2190
|
-
*/
|
|
2191
|
-
export function formatFileSize(bytes: number): string {
|
|
2192
|
-
if (bytes < 0 || !Number.isFinite(bytes)) {
|
|
2193
|
-
return '0 B';
|
|
2194
|
-
}
|
|
2195
|
-
|
|
2196
|
-
if (bytes === 0) {
|
|
2197
|
-
return '0 B';
|
|
2198
|
-
}
|
|
2199
|
-
|
|
2200
|
-
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
2201
|
-
let size = bytes;
|
|
2202
|
-
let unitIndex = 0;
|
|
2203
|
-
|
|
2204
|
-
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
2205
|
-
size /= 1024;
|
|
2206
|
-
unitIndex++;
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
return `${size.toFixed(unitIndex > 0 ? 1 : 0)} ${units[unitIndex]}`;
|
|
2210
|
-
}
|
|
2211
|
-
|
|
2212
|
-
/**
|
|
2213
|
-
* Build validation rules from field metadata
|
|
2214
|
-
* @param field - Field metadata from ObjectStack
|
|
2215
|
-
* @returns Validation rule object compatible with react-hook-form
|
|
2216
|
-
*/
|
|
2217
|
-
export function buildValidationRules(field: any): any {
|
|
2218
|
-
const rules: any = {};
|
|
2219
|
-
|
|
2220
|
-
// Required validation. Emit a bare `true` for the auto-generated case rather
|
|
2221
|
-
// than baking an English message here: the form renderer localizes it via
|
|
2222
|
-
// `t('validation.required', { field })`. A field-authored `required_message`
|
|
2223
|
-
// still wins and is passed through verbatim.
|
|
2224
|
-
if (field.required) {
|
|
2225
|
-
rules.required = typeof field.required_message === 'string'
|
|
2226
|
-
? field.required_message
|
|
2227
|
-
: true;
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
// Standard validation messages are localized by the form renderer, which has
|
|
2231
|
-
// an i18n `t` in scope. We therefore emit `message: undefined` for the
|
|
2232
|
-
// auto-generated case and tag each rule with a `messageKey` (+ any interp
|
|
2233
|
-
// vars); a field-authored `*_message` is a string and passes through as-is,
|
|
2234
|
-
// still winning over the localized default. See form.tsx `localizeRule`.
|
|
2235
|
-
|
|
2236
|
-
// Length validation for text fields. The spec-canonical keys are camelCase
|
|
2237
|
-
// (`minLength`/`maxLength`, @objectstack/spec FieldSchema — what the server
|
|
2238
|
-
// record-validator enforces); the snake_case pair is the legacy objectui
|
|
2239
|
-
// spelling, kept as fallback (framework#1878/#1891 naming-drift closeout).
|
|
2240
|
-
const minLength = (field as any).minLength ?? field.min_length;
|
|
2241
|
-
if (minLength) {
|
|
2242
|
-
rules.minLength = {
|
|
2243
|
-
value: minLength,
|
|
2244
|
-
message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
|
|
2245
|
-
messageKey: 'validation.minLength',
|
|
2246
|
-
};
|
|
2247
|
-
}
|
|
2248
|
-
|
|
2249
|
-
const maxLength = (field as any).maxLength ?? field.max_length;
|
|
2250
|
-
if (maxLength) {
|
|
2251
|
-
rules.maxLength = {
|
|
2252
|
-
value: maxLength,
|
|
2253
|
-
message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
|
|
2254
|
-
messageKey: 'validation.maxLength',
|
|
2255
|
-
};
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
// Number range validation
|
|
2259
|
-
if (field.min !== undefined) {
|
|
2260
|
-
rules.min = {
|
|
2261
|
-
value: field.min,
|
|
2262
|
-
message: typeof field.min_message === 'string' ? field.min_message : undefined,
|
|
2263
|
-
messageKey: 'validation.min',
|
|
2264
|
-
};
|
|
2265
|
-
}
|
|
2266
|
-
|
|
2267
|
-
if (field.max !== undefined) {
|
|
2268
|
-
rules.max = {
|
|
2269
|
-
value: field.max,
|
|
2270
|
-
message: typeof field.max_message === 'string' ? field.max_message : undefined,
|
|
2271
|
-
messageKey: 'validation.max',
|
|
2272
|
-
};
|
|
2273
|
-
}
|
|
2274
|
-
|
|
2275
|
-
// Pattern validation
|
|
2276
|
-
if (field.pattern) {
|
|
2277
|
-
rules.pattern = {
|
|
2278
|
-
value: typeof field.pattern === 'string' ? new RegExp(field.pattern) : field.pattern,
|
|
2279
|
-
message: typeof field.pattern_message === 'string' ? field.pattern_message : undefined,
|
|
2280
|
-
messageKey: 'validation.pattern',
|
|
2281
|
-
};
|
|
2282
|
-
}
|
|
2283
|
-
|
|
2284
|
-
// Email validation
|
|
2285
|
-
if (field.type === 'email') {
|
|
2286
|
-
rules.pattern = {
|
|
2287
|
-
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
2288
|
-
message: undefined,
|
|
2289
|
-
messageKey: 'validation.email',
|
|
2290
|
-
};
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
// URL validation
|
|
2294
|
-
if (field.type === 'url') {
|
|
2295
|
-
rules.pattern = {
|
|
2296
|
-
value: /^https?:\/\/.+/,
|
|
2297
|
-
message: undefined,
|
|
2298
|
-
messageKey: 'validation.url',
|
|
2299
|
-
};
|
|
2300
|
-
}
|
|
2301
|
-
|
|
2302
|
-
// Custom validation function
|
|
2303
|
-
if (field.validate) {
|
|
2304
|
-
rules.validate = field.validate;
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
return Object.keys(rules).length > 0 ? rules : undefined;
|
|
2308
|
-
}
|
|
2309
|
-
|
|
2310
|
-
/**
|
|
2311
|
-
* Evaluate a conditional expression for field visibility
|
|
2312
|
-
* @param condition - Condition object from field metadata
|
|
2313
|
-
* @param formData - Current form values
|
|
2314
|
-
* @returns Whether the condition is met
|
|
2315
|
-
*/
|
|
2316
|
-
export function evaluateCondition(condition: any, formData: any): boolean {
|
|
2317
|
-
if (!condition) return true;
|
|
2318
|
-
|
|
2319
|
-
// Simple field equality check
|
|
2320
|
-
if (condition.field && condition.value !== undefined) {
|
|
2321
|
-
const fieldValue = formData[condition.field];
|
|
2322
|
-
if (condition.operator === '=' || condition.operator === '==') {
|
|
2323
|
-
return fieldValue === condition.value;
|
|
2324
|
-
} else if (condition.operator === '!=') {
|
|
2325
|
-
return fieldValue !== condition.value;
|
|
2326
|
-
} else if (condition.operator === '>') {
|
|
2327
|
-
return fieldValue > condition.value;
|
|
2328
|
-
} else if (condition.operator === '>=') {
|
|
2329
|
-
return fieldValue >= condition.value;
|
|
2330
|
-
} else if (condition.operator === '<') {
|
|
2331
|
-
return fieldValue < condition.value;
|
|
2332
|
-
} else if (condition.operator === '<=') {
|
|
2333
|
-
return fieldValue <= condition.value;
|
|
2334
|
-
} else if (condition.operator === 'in') {
|
|
2335
|
-
return Array.isArray(condition.value) && condition.value.includes(fieldValue);
|
|
2336
|
-
}
|
|
2337
|
-
}
|
|
2338
|
-
|
|
2339
|
-
// AND/OR logic
|
|
2340
|
-
if (condition.and && Array.isArray(condition.and)) {
|
|
2341
|
-
return condition.and.every((c: any) => evaluateCondition(c, formData));
|
|
2342
|
-
}
|
|
2343
|
-
|
|
2344
|
-
if (condition.or && Array.isArray(condition.or)) {
|
|
2345
|
-
return condition.or.some((c: any) => evaluateCondition(c, formData));
|
|
2346
|
-
}
|
|
2347
|
-
|
|
2348
|
-
// Default to true if condition format is unknown
|
|
2349
|
-
return true;
|
|
2350
|
-
}
|
|
2351
|
-
|
|
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).
|
|
2362
|
-
|
|
2363
|
-
/**
|
|
2364
|
-
* Field widget map for lazy loading
|
|
2365
|
-
* Maps field type to widget component
|
|
2366
|
-
*/
|
|
2367
|
-
const fieldWidgetMap: Record<string, () => Promise<{ default: React.ComponentType<any> }>> = {
|
|
2368
|
-
// Basic fields
|
|
2369
|
-
'text': () => import('./widgets/TextField').then(m => ({ default: m.TextField })),
|
|
2370
|
-
'textarea': () => import('./widgets/TextAreaField').then(m => ({ default: m.TextAreaField })),
|
|
2371
|
-
'number': () => import('./widgets/NumberField').then(m => ({ default: m.NumberField })),
|
|
2372
|
-
'boolean': () => import('./widgets/BooleanField').then(m => ({ default: m.BooleanField })),
|
|
2373
|
-
'select': () => import('./widgets/SelectField').then(m => ({ default: m.SelectField })),
|
|
2374
|
-
'date': () => import('./widgets/DateField').then(m => ({ default: m.DateField })),
|
|
2375
|
-
'datetime': () => import('./widgets/DateTimeField').then(m => ({ default: m.DateTimeField })),
|
|
2376
|
-
'time': () => import('./widgets/TimeField').then(m => ({ default: m.TimeField })),
|
|
2377
|
-
|
|
2378
|
-
// Contact fields
|
|
2379
|
-
'email': () => import('./widgets/EmailField').then(m => ({ default: m.EmailField })),
|
|
2380
|
-
'phone': () => import('./widgets/PhoneField').then(m => ({ default: m.PhoneField })),
|
|
2381
|
-
'url': () => import('./widgets/UrlField').then(m => ({ default: m.UrlField })),
|
|
2382
|
-
|
|
2383
|
-
// Selection fields (multi-value / option groups)
|
|
2384
|
-
'multiselect': () => import('./widgets/MultiSelectField').then(m => ({ default: m.MultiSelectField })),
|
|
2385
|
-
'radio': () => import('./widgets/RadioField').then(m => ({ default: m.RadioField })),
|
|
2386
|
-
'checkboxes': () => import('./widgets/CheckboxesField').then(m => ({ default: m.CheckboxesField })),
|
|
2387
|
-
'tags': () => import('./widgets/TagsField').then(m => ({ default: m.TagsField })),
|
|
2388
|
-
|
|
2389
|
-
// Specialized fields
|
|
2390
|
-
'currency': () => import('./widgets/CurrencyField').then(m => ({ default: m.CurrencyField })),
|
|
2391
|
-
'percent': () => import('./widgets/PercentField').then(m => ({ default: m.PercentField })),
|
|
2392
|
-
'password': () => import('./widgets/PasswordField').then(m => ({ default: m.PasswordField })),
|
|
2393
|
-
'markdown': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
|
|
2394
|
-
'html': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
|
|
2395
|
-
'richtext': () => import('./widgets/RichTextField').then(m => ({ default: m.RichTextField })),
|
|
2396
|
-
'lookup': () => import('./widgets/LookupField').then(m => ({ default: m.LookupField })),
|
|
2397
|
-
// master_detail represents the child-side FK to its parent. In create/edit forms it
|
|
2398
|
-
// must render as a single-value lookup picker (it is typically NOT NULL). The legacy
|
|
2399
|
-
// MasterDetailField widget modelled this as a one-to-many list, which is incorrect
|
|
2400
|
-
// for the child-side and prevented users from filling the required parent reference.
|
|
2401
|
-
'master_detail': () => import('./widgets/LookupField').then(m => ({ default: m.LookupField })),
|
|
2402
|
-
|
|
2403
|
-
// File fields
|
|
2404
|
-
'file': () => import('./widgets/FileField').then(m => ({ default: m.FileField })),
|
|
2405
|
-
'image': () => import('./widgets/ImageField').then(m => ({ default: m.ImageField })),
|
|
2406
|
-
|
|
2407
|
-
// Location field
|
|
2408
|
-
'location': () => import('./widgets/LocationField').then(m => ({ default: m.LocationField })),
|
|
2409
|
-
|
|
2410
|
-
// Computed/Read-only fields
|
|
2411
|
-
'formula': () => import('./widgets/FormulaField').then(m => ({ default: m.FormulaField })),
|
|
2412
|
-
'summary': () => import('./widgets/SummaryField').then(m => ({ default: m.SummaryField })),
|
|
2413
|
-
'auto_number': () => import('./widgets/AutoNumberField').then(m => ({ default: m.AutoNumberField })),
|
|
2414
|
-
|
|
2415
|
-
// User fields
|
|
2416
|
-
'user': () => import('./widgets/UserField').then(m => ({ default: m.UserField })),
|
|
2417
|
-
'owner': () => import('./widgets/UserField').then(m => ({ default: m.UserField })),
|
|
2418
|
-
|
|
2419
|
-
// Complex data types
|
|
2420
|
-
'object': () => import('./widgets/ObjectField').then(m => ({ default: m.ObjectField })),
|
|
2421
|
-
'vector': () => import('./widgets/VectorField').then(m => ({ default: m.VectorField })),
|
|
2422
|
-
'grid': () => import('./widgets/GridField').then(m => ({ default: m.GridField })),
|
|
2423
|
-
|
|
2424
|
-
// Additional field types from @objectstack/spec
|
|
2425
|
-
'color': () => import('./widgets/ColorField').then(m => ({ default: m.ColorField })),
|
|
2426
|
-
'slider': () => import('./widgets/SliderField').then(m => ({ default: m.SliderField })),
|
|
2427
|
-
'rating': () => import('./widgets/RatingField').then(m => ({ default: m.RatingField })),
|
|
2428
|
-
'code': () => import('./widgets/CodeField').then(m => ({ default: m.CodeField })),
|
|
2429
|
-
'avatar': () => import('./widgets/AvatarField').then(m => ({ default: m.AvatarField })),
|
|
2430
|
-
'address': () => import('./widgets/AddressField').then(m => ({ default: m.AddressField })),
|
|
2431
|
-
'geolocation': () => import('./widgets/GeolocationField').then(m => ({ default: m.GeolocationField })),
|
|
2432
|
-
'signature': () => import('./widgets/SignatureField').then(m => ({ default: m.SignatureField })),
|
|
2433
|
-
'qrcode': () => import('./widgets/QRCodeField').then(m => ({ default: m.QRCodeField })),
|
|
2434
|
-
|
|
2435
|
-
// Widget-hint-only pickers (reached via a field `widget:` override, never a
|
|
2436
|
-
// bare field `type`). They render a *picker* over machine data an admin would
|
|
2437
|
-
// otherwise have to type — used by sys_sharing_rule (ADR-0056 P2 pattern):
|
|
2438
|
-
// object-ref → choose a registered object by name
|
|
2439
|
-
// filter-condition → visual criteria builder scoped to the chosen object
|
|
2440
|
-
// recipient-picker → record picker whose target follows a sibling type
|
|
2441
|
-
'object-ref': () => import('./widgets/ObjectRefField').then(m => ({ default: m.ObjectRefField })),
|
|
2442
|
-
'filter-condition': () => import('./widgets/FilterConditionField').then(m => ({ default: m.FilterConditionField })),
|
|
2443
|
-
'recipient-picker': () => import('./widgets/RecipientPickerField').then(m => ({ default: m.RecipientPickerField })),
|
|
2444
|
-
};
|
|
2445
|
-
|
|
2446
|
-
/**
|
|
2447
|
-
* Every field type the form can render (the canonical list of supported types).
|
|
2448
|
-
* Exported so inline editing can be checked against it — a form type must
|
|
2449
|
-
* either have an inline editor or be explicitly excluded, see
|
|
2450
|
-
* `INLINE_EXCLUDED_FIELD_TYPES` and its drift-guard test.
|
|
2451
|
-
*/
|
|
2452
|
-
export const FORM_FIELD_TYPES: readonly string[] = Object.freeze(Object.keys(fieldWidgetMap));
|
|
2453
|
-
|
|
2454
|
-
/**
|
|
2455
|
-
* Resolve an arbitrary field-type spelling to the widget key the form uses.
|
|
2456
|
-
* A key already present in the widget map resolves to itself; spec aliases
|
|
2457
|
-
* (`toggle`, `json`, `repeater`, `secret`, …) resolve through
|
|
2458
|
-
* {@link mapFieldTypeToFormType}; anything unknown falls back to `text` —
|
|
2459
|
-
* the same fallback the form renderer applies.
|
|
2460
|
-
*
|
|
2461
|
-
* Consumers that render field widgets outside the form (e.g. the app-shell
|
|
2462
|
-
* `ActionParamDialog`) use this + {@link getLazyFieldWidget} so their type
|
|
2463
|
-
* support can never drift behind the form surface (ADR-0059).
|
|
2464
|
-
*/
|
|
2465
|
-
export function resolveFormWidgetType(fieldType: string): string {
|
|
2466
|
-
if (fieldWidgetMap[fieldType]) return fieldType;
|
|
2467
|
-
const mapped = mapFieldTypeToFormType(fieldType).replace(/^field:/, '');
|
|
2468
|
-
return fieldWidgetMap[mapped] ? mapped : 'text';
|
|
2469
|
-
}
|
|
2470
|
-
|
|
2471
|
-
/** Cache so each widget type creates one lazy component (and one chunk request). */
|
|
2472
|
-
const lazyFieldWidgets = new Map<string, React.ComponentType<any>>();
|
|
2473
|
-
|
|
2474
|
-
/**
|
|
2475
|
-
* Lazily-loaded form field widget for a field type. Shares the exact loaders
|
|
2476
|
-
* of {@link fieldWidgetMap} (the same components `registerField` registers for
|
|
2477
|
-
* forms), wrapped in `React.lazy` and cached per type — so a consumer can
|
|
2478
|
-
* render any form-supported field type without eagerly bundling every widget.
|
|
2479
|
-
* Render inside a `<Suspense>` boundary. Unknown types resolve to the `text`
|
|
2480
|
-
* widget via {@link resolveFormWidgetType}.
|
|
2481
|
-
*/
|
|
2482
|
-
export function getLazyFieldWidget(fieldType: string): React.ComponentType<any> {
|
|
2483
|
-
const key = resolveFormWidgetType(fieldType);
|
|
2484
|
-
let Widget = lazyFieldWidgets.get(key);
|
|
2485
|
-
if (!Widget) {
|
|
2486
|
-
Widget = React.lazy(fieldWidgetMap[key]);
|
|
2487
|
-
lazyFieldWidgets.set(key, Widget);
|
|
2488
|
-
}
|
|
2489
|
-
return Widget;
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
|
-
/**
|
|
2493
|
-
* Register a specific field type lazily
|
|
2494
|
-
* @param fieldType - The field type to register (e.g., 'text', 'number')
|
|
2495
|
-
*
|
|
2496
|
-
* @example
|
|
2497
|
-
* // Register only the text field
|
|
2498
|
-
* registerField('text');
|
|
2499
|
-
*/
|
|
2500
|
-
// Field types whose short name collides with a display/ui/view/plugin component
|
|
2501
|
-
// registered elsewhere (e.g. the display widgets and form-input primitives in
|
|
2502
|
-
// @object-ui/components, or the markdown display plugin). These remain
|
|
2503
|
-
// accessible via the namespaced `field:<type>` key — which is how forms resolve
|
|
2504
|
-
// them (see form.tsx renderFieldComponent + mapFieldTypeToFormType) — but must
|
|
2505
|
-
// not overwrite the bare `<type>` fallback, which the display/ui primitive owns
|
|
2506
|
-
// and which page schemas expect (e.g. `{ type: 'markdown', content }` → the
|
|
2507
|
-
// markdown renderer, not the RichText editor). Without skipFallback each of
|
|
2508
|
-
// these logged a "bare-name fallback is being overwritten" warning at boot.
|
|
2509
|
-
const FIELD_TYPES_SKIP_FALLBACK = new Set([
|
|
2510
|
-
// Display widgets (text/html/image/avatar/grid live in @object-ui/components
|
|
2511
|
-
// or @object-ui/layout as the bare-name owners).
|
|
2512
|
-
'text',
|
|
2513
|
-
'html',
|
|
2514
|
-
'image',
|
|
2515
|
-
'avatar',
|
|
2516
|
-
'grid',
|
|
2517
|
-
// Form-input primitives owned by `ui:*` in @object-ui/components.
|
|
2518
|
-
'textarea',
|
|
2519
|
-
'select',
|
|
2520
|
-
'email',
|
|
2521
|
-
'password',
|
|
2522
|
-
'slider',
|
|
2523
|
-
// Display renderer owned by `plugin-markdown:markdown`.
|
|
2524
|
-
'markdown',
|
|
2525
|
-
// No other package owns the bare `time`/`address` key, but `registerField`
|
|
2526
|
-
// wraps each call in a fresh `React.lazy(...)`, so re-registration (HMR,
|
|
2527
|
-
// re-import) fails the registry's identity check every time and logs the
|
|
2528
|
-
// same "bare-name fallback overwritten" warning at every boot regardless.
|
|
2529
|
-
'time',
|
|
2530
|
-
'address',
|
|
2531
|
-
// Widget-hint-only pickers — resolved solely via `field:<widget>`, so the
|
|
2532
|
-
// bare-key fallback is never wanted.
|
|
2533
|
-
'object-ref',
|
|
2534
|
-
'filter-condition',
|
|
2535
|
-
'recipient-picker',
|
|
2536
|
-
]);
|
|
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
|
-
|
|
2582
|
-
export function registerField(fieldType: string): void {
|
|
2583
|
-
const loader = fieldWidgetMap[fieldType];
|
|
2584
|
-
if (!loader) {
|
|
2585
|
-
console.warn(`Unknown field type: ${fieldType}`);
|
|
2586
|
-
return;
|
|
2587
|
-
}
|
|
2588
|
-
|
|
2589
|
-
// Create lazy component
|
|
2590
|
-
const LazyFieldWidget = React.lazy(loader);
|
|
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), {
|
|
2597
|
-
namespace: 'field',
|
|
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),
|
|
2603
|
-
});
|
|
2604
|
-
}
|
|
2605
|
-
|
|
2606
|
-
/**
|
|
2607
|
-
* Register all field types (for backward compatibility)
|
|
2608
|
-
* This function auto-registers all field widgets on import.
|
|
2609
|
-
*
|
|
2610
|
-
* For better tree-shaking, use registerField() to register only needed fields.
|
|
2611
|
-
*
|
|
2612
|
-
* @example
|
|
2613
|
-
* // Register all fields at once
|
|
2614
|
-
* registerAllFields();
|
|
2615
|
-
*/
|
|
2616
|
-
export function registerAllFields(): void {
|
|
2617
|
-
Object.keys(fieldWidgetMap).forEach(fieldType => {
|
|
2618
|
-
registerField(fieldType);
|
|
2619
|
-
});
|
|
2620
|
-
}
|
|
2621
|
-
|
|
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).
|
|
2653
|
-
|
|
2654
|
-
export * from './widgets/types';
|
|
2655
|
-
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
|
|
2656
|
-
// arbiter of reference vs expanded vs legacy-blob form, shared by the upload
|
|
2657
|
-
// widgets and by action-param serialization.
|
|
2658
|
-
export * from './widgets/file-value';
|
|
2659
|
-
export * from './FieldEditWidget';
|
|
2660
|
-
export * from './widgets/TextField';
|
|
2661
|
-
export * from './widgets/NumberField';
|
|
2662
|
-
export * from './widgets/BooleanField';
|
|
2663
|
-
export * from './widgets/SelectField';
|
|
2664
|
-
export * from './widgets/DateField';
|
|
2665
|
-
export * from './widgets/DateTimeField';
|
|
2666
|
-
export * from './widgets/TimeField';
|
|
2667
|
-
export * from './widgets/EmailField';
|
|
2668
|
-
export * from './widgets/PhoneField';
|
|
2669
|
-
export * from './widgets/UrlField';
|
|
2670
|
-
export * from './widgets/CurrencyField';
|
|
2671
|
-
export * from './widgets/PercentField';
|
|
2672
|
-
export * from './widgets/PasswordField';
|
|
2673
|
-
export * from './widgets/TextAreaField';
|
|
2674
|
-
export * from './widgets/RichTextField';
|
|
2675
|
-
export * from './widgets/LookupField';
|
|
2676
|
-
export * from './widgets/CapabilityMultiSelectField';
|
|
2677
|
-
export * from './widgets/ObjectRefField';
|
|
2678
|
-
export * from './widgets/FilterConditionField';
|
|
2679
|
-
export * from './widgets/RecipientPickerField';
|
|
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';
|
|
2686
|
-
export * from './widgets/FileField';
|
|
2687
|
-
export * from './widgets/ImageField';
|
|
2688
|
-
export { ImageCropperDialog } from './widgets/ImageCropperDialog';
|
|
2689
|
-
export type { ImageCropperDialogProps } from './widgets/ImageCropperDialog';
|
|
2690
|
-
export * from './widgets/LocationField';
|
|
2691
|
-
export * from './widgets/FormulaField';
|
|
2692
|
-
export * from './widgets/SummaryField';
|
|
2693
|
-
export * from './widgets/AutoNumberField';
|
|
2694
|
-
export * from './widgets/UserField';
|
|
2695
|
-
export * from './widgets/ObjectField';
|
|
2696
|
-
export * from './widgets/VectorField';
|
|
2697
|
-
export * from './widgets/GridField';
|
|
2698
|
-
// New widgets according to @objectstack/spec
|
|
2699
|
-
export * from './widgets/ColorField';
|
|
2700
|
-
export * from './widgets/SliderField';
|
|
2701
|
-
export * from './widgets/RatingField';
|
|
2702
|
-
export * from './widgets/CodeField';
|
|
2703
|
-
export * from './widgets/AvatarField';
|
|
2704
|
-
export * from './widgets/AddressField';
|
|
2705
|
-
export * from './widgets/GeolocationField';
|
|
2706
|
-
export * from './widgets/SignatureField';
|
|
2707
|
-
export * from './widgets/QRCodeField';
|
|
2708
|
-
export * from './widgets/MasterDetailField';
|
|
2709
|
-
export * from './widgets/MultiSelectField';
|
|
2710
|
-
export * from './widgets/RadioField';
|
|
2711
|
-
export * from './widgets/CheckboxesField';
|
|
2712
|
-
export * from './widgets/TagsField';
|
|
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
|
-
|
|
2744
|
-
// Initialize registry
|
|
2745
|
-
registerAllFields();
|