@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
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import ReactMarkdown from 'react-markdown';
|
|
3
|
-
import remarkGfm from 'remark-gfm';
|
|
4
|
-
import rehypeSanitize from 'rehype-sanitize';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Heavy markdown renderer, split into its own module so it can be lazy-loaded
|
|
8
|
-
* by the cell/detail renderers (react-markdown pulls in ~100-200 KB). GitHub
|
|
9
|
-
* Flavored Markdown + sanitization (defense against XSS in stored content).
|
|
10
|
-
*/
|
|
11
|
-
export default function MarkdownContent({ value }: { value: string }) {
|
|
12
|
-
return (
|
|
13
|
-
<div className="prose prose-sm max-w-none dark:prose-invert break-words">
|
|
14
|
-
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
|
|
15
|
-
{value}
|
|
16
|
-
</ReactMarkdown>
|
|
17
|
-
</div>
|
|
18
|
-
);
|
|
19
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Button, Badge, cn } from '@object-ui/components';
|
|
3
|
-
import { Plus, X, ExternalLink } from 'lucide-react';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Master-Detail relationship data structure
|
|
8
|
-
*/
|
|
9
|
-
export interface MasterDetailValue {
|
|
10
|
-
id: string;
|
|
11
|
-
label: string;
|
|
12
|
-
[key: string]: any;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Master-Detail field widget - manages one-to-many relationships
|
|
17
|
-
* Displays related records with add/remove capabilities
|
|
18
|
-
*/
|
|
19
|
-
export function MasterDetailField({
|
|
20
|
-
value,
|
|
21
|
-
onChange,
|
|
22
|
-
field,
|
|
23
|
-
readonly,
|
|
24
|
-
className,
|
|
25
|
-
...props
|
|
26
|
-
}: FieldWidgetComponentProps<MasterDetailValue[]>) {
|
|
27
|
-
const items = value || [];
|
|
28
|
-
const config = field;
|
|
29
|
-
|
|
30
|
-
const handleAdd = () => {
|
|
31
|
-
// This would typically open a dialog to select/create related records
|
|
32
|
-
// For now, we'll just show a placeholder
|
|
33
|
-
const newItem: MasterDetailValue = {
|
|
34
|
-
id: `new-${Date.now()}`,
|
|
35
|
-
label: 'New Related Record',
|
|
36
|
-
};
|
|
37
|
-
onChange([...items, newItem]);
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
const handleRemove = (id: string) => {
|
|
41
|
-
onChange(items.filter(item => item.id !== id));
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const handleView = (item: MasterDetailValue) => {
|
|
45
|
-
// This would typically navigate to the detail view
|
|
46
|
-
console.log('View detail:', item);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
if (readonly) {
|
|
50
|
-
return (
|
|
51
|
-
<div className={cn("space-y-2", className)}>
|
|
52
|
-
{items.length === 0 ? (
|
|
53
|
-
<span className="text-sm text-muted-foreground">No related records</span>
|
|
54
|
-
) : (
|
|
55
|
-
items.map((item) => (
|
|
56
|
-
<div
|
|
57
|
-
key={item.id}
|
|
58
|
-
className="flex items-center justify-between p-2 border rounded hover:bg-muted/50"
|
|
59
|
-
>
|
|
60
|
-
<span className="text-sm">{item.label}</span>
|
|
61
|
-
<Button
|
|
62
|
-
type="button"
|
|
63
|
-
variant="ghost"
|
|
64
|
-
size="sm"
|
|
65
|
-
onClick={() => handleView(item)}
|
|
66
|
-
>
|
|
67
|
-
<ExternalLink className="w-4 h-4" />
|
|
68
|
-
</Button>
|
|
69
|
-
</div>
|
|
70
|
-
))
|
|
71
|
-
)}
|
|
72
|
-
<p className="text-xs text-muted-foreground">
|
|
73
|
-
{items.length} {items.length === 1 ? 'record' : 'records'}
|
|
74
|
-
</p>
|
|
75
|
-
</div>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<div className={cn("space-y-3", className)}>
|
|
81
|
-
<div className="space-y-2">
|
|
82
|
-
{items.map((item) => (
|
|
83
|
-
<div
|
|
84
|
-
key={item.id}
|
|
85
|
-
className="flex items-center justify-between gap-2 p-2 border rounded hover:bg-muted/50"
|
|
86
|
-
>
|
|
87
|
-
<div className="flex items-center gap-2 flex-1">
|
|
88
|
-
<Badge variant="outline">{item.id}</Badge>
|
|
89
|
-
<span className="text-sm flex-1">{item.label}</span>
|
|
90
|
-
</div>
|
|
91
|
-
<div className="flex items-center gap-1">
|
|
92
|
-
<Button
|
|
93
|
-
type="button"
|
|
94
|
-
variant="ghost"
|
|
95
|
-
size="sm"
|
|
96
|
-
onClick={() => handleView(item)}
|
|
97
|
-
>
|
|
98
|
-
<ExternalLink className="w-4 h-4" />
|
|
99
|
-
</Button>
|
|
100
|
-
<Button
|
|
101
|
-
type="button"
|
|
102
|
-
variant="ghost"
|
|
103
|
-
size="sm"
|
|
104
|
-
onClick={() => handleRemove(item.id)}
|
|
105
|
-
disabled={props.disabled}
|
|
106
|
-
>
|
|
107
|
-
<X className="w-4 h-4" />
|
|
108
|
-
</Button>
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
))}
|
|
112
|
-
{items.length === 0 && (
|
|
113
|
-
<div className="text-sm text-muted-foreground text-center py-4 border border-dashed rounded bg-muted/20">
|
|
114
|
-
No related records
|
|
115
|
-
</div>
|
|
116
|
-
)}
|
|
117
|
-
</div>
|
|
118
|
-
|
|
119
|
-
<Button
|
|
120
|
-
type="button"
|
|
121
|
-
variant="outline"
|
|
122
|
-
className="w-full"
|
|
123
|
-
onClick={handleAdd}
|
|
124
|
-
disabled={props.disabled}
|
|
125
|
-
>
|
|
126
|
-
<Plus className="w-4 h-4 mr-2" />
|
|
127
|
-
Add {config?.label || 'Record'}
|
|
128
|
-
</Button>
|
|
129
|
-
</div>
|
|
130
|
-
);
|
|
131
|
-
}
|
|
@@ -1,143 +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
|
-
/**
|
|
10
|
-
* Cascading / role-gated `multiselect` options (#2715) — parity with the single
|
|
11
|
-
* `SelectField` (ADR-0058 / #2284).
|
|
12
|
-
*
|
|
13
|
-
* Exercises the observable widget behaviour: the dependency gate (a "select the
|
|
14
|
-
* parent first" empty-state), and the cascade clear — but here per-element: a
|
|
15
|
-
* selection dropped from the array when the offered set no longer includes it,
|
|
16
|
-
* while still-valid selections are kept. The per-option filtering itself is
|
|
17
|
-
* unit-tested in `@object-ui/core` (`optionRules.test.ts`); here we prove the
|
|
18
|
-
* chip picker wires `dependentValues` + predicate scope into that resolver via
|
|
19
|
-
* the shared `useCascadingOptions` hook.
|
|
20
|
-
*/
|
|
21
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
22
|
-
import React from 'react';
|
|
23
|
-
import { render, screen } from '@testing-library/react';
|
|
24
|
-
import '@testing-library/jest-dom';
|
|
25
|
-
import { PredicateScopeProvider } from '@object-ui/react';
|
|
26
|
-
import { MultiSelectField } from './MultiSelectField';
|
|
27
|
-
|
|
28
|
-
const provinceField = {
|
|
29
|
-
name: 'province',
|
|
30
|
-
type: 'multiselect',
|
|
31
|
-
dependsOn: 'country',
|
|
32
|
-
options: [
|
|
33
|
-
{ label: 'Zhejiang', value: 'zj', visibleWhen: "record.country == 'cn'" },
|
|
34
|
-
{ label: 'California', value: 'ca', visibleWhen: "record.country == 'us'" },
|
|
35
|
-
],
|
|
36
|
-
} as any;
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
vi.clearAllMocks();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
describe('MultiSelectField — dependency gating (#2715)', () => {
|
|
43
|
-
it('gates with a "select parent first" hint while the controlling field is empty', () => {
|
|
44
|
-
render(
|
|
45
|
-
<MultiSelectField
|
|
46
|
-
value={[]}
|
|
47
|
-
onChange={vi.fn()}
|
|
48
|
-
field={provinceField}
|
|
49
|
-
{...({ name: 'province', dependentValues: {} } as any)}
|
|
50
|
-
/>,
|
|
51
|
-
);
|
|
52
|
-
const gate = screen.getByTestId('multiselect-empty-province');
|
|
53
|
-
expect(gate).toHaveTextContent(/select country first/i);
|
|
54
|
-
// Gated → no chips offered.
|
|
55
|
-
expect(screen.queryAllByRole('button')).toHaveLength(0);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('unlocks the chips once the controlling field is set', () => {
|
|
59
|
-
render(
|
|
60
|
-
<MultiSelectField
|
|
61
|
-
value={[]}
|
|
62
|
-
onChange={vi.fn()}
|
|
63
|
-
field={provinceField}
|
|
64
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
65
|
-
/>,
|
|
66
|
-
);
|
|
67
|
-
expect(screen.queryByTestId('multiselect-empty-province')).not.toBeInTheDocument();
|
|
68
|
-
// Only the `cn` option is offered.
|
|
69
|
-
expect(screen.getByTestId('multiselect-option-zj')).toBeInTheDocument();
|
|
70
|
-
expect(screen.queryByTestId('multiselect-option-ca')).not.toBeInTheDocument();
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe('MultiSelectField — cascade clear (#2715)', () => {
|
|
75
|
-
it('drops only the selections the parent change no longer offers', () => {
|
|
76
|
-
const onChange = vi.fn();
|
|
77
|
-
render(
|
|
78
|
-
<MultiSelectField
|
|
79
|
-
value={['zj', 'ca']}
|
|
80
|
-
onChange={onChange}
|
|
81
|
-
field={provinceField}
|
|
82
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
83
|
-
/>,
|
|
84
|
-
);
|
|
85
|
-
// 'ca' is a US province — under country=cn it is not offered, so it is
|
|
86
|
-
// pruned; the still-valid 'zj' is kept.
|
|
87
|
-
expect(onChange).toHaveBeenCalledWith(['zj']);
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('keeps selections that are all still offered', () => {
|
|
91
|
-
const onChange = vi.fn();
|
|
92
|
-
render(
|
|
93
|
-
<MultiSelectField
|
|
94
|
-
value={['zj']}
|
|
95
|
-
onChange={onChange}
|
|
96
|
-
field={provinceField}
|
|
97
|
-
{...({ name: 'province', dependentValues: { country: 'cn' } } as any)}
|
|
98
|
-
/>,
|
|
99
|
-
);
|
|
100
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe('MultiSelectField — role / context gating (#2715)', () => {
|
|
105
|
-
const tierField = {
|
|
106
|
-
name: 'tiers',
|
|
107
|
-
type: 'multiselect',
|
|
108
|
-
options: [
|
|
109
|
-
{ label: 'Standard', value: 'standard' },
|
|
110
|
-
{ label: 'Admin only', value: 'admin_only', visibleWhen: "'admin' in current_user.positions" },
|
|
111
|
-
],
|
|
112
|
-
} as any;
|
|
113
|
-
|
|
114
|
-
it('prunes an admin-only value for a non-admin (offered set excludes it)', () => {
|
|
115
|
-
const onChange = vi.fn();
|
|
116
|
-
render(
|
|
117
|
-
<PredicateScopeProvider scope={{ current_user: { positions: ['sales'] } }}>
|
|
118
|
-
<MultiSelectField
|
|
119
|
-
value={['standard', 'admin_only']}
|
|
120
|
-
onChange={onChange}
|
|
121
|
-
field={tierField}
|
|
122
|
-
{...({ name: 'tiers', dependentValues: {} } as any)}
|
|
123
|
-
/>
|
|
124
|
-
</PredicateScopeProvider>,
|
|
125
|
-
);
|
|
126
|
-
expect(onChange).toHaveBeenCalledWith(['standard']);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('keeps an admin-only value for an admin', () => {
|
|
130
|
-
const onChange = vi.fn();
|
|
131
|
-
render(
|
|
132
|
-
<PredicateScopeProvider scope={{ current_user: { positions: ['admin'] } }}>
|
|
133
|
-
<MultiSelectField
|
|
134
|
-
value={['standard', 'admin_only']}
|
|
135
|
-
onChange={onChange}
|
|
136
|
-
field={tierField}
|
|
137
|
-
{...({ name: 'tiers', dependentValues: {} } as any)}
|
|
138
|
-
/>
|
|
139
|
-
</PredicateScopeProvider>,
|
|
140
|
-
);
|
|
141
|
-
expect(onChange).not.toHaveBeenCalled();
|
|
142
|
-
});
|
|
143
|
-
});
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import React, { useEffect } from 'react';
|
|
2
|
-
import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
3
|
-
import type { OptionLike } from '@object-ui/core';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
import { toDomProps } from './toDomProps';
|
|
6
|
-
import { toHostGroupProps } from './toHostGroupProps';
|
|
7
|
-
import { OptionsEmptyState } from './OptionsEmptyState';
|
|
8
|
-
import { useCascadingOptions } from './useCascadingOptions';
|
|
9
|
-
|
|
10
|
-
interface Option extends OptionLike { color?: string }
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* MultiSelectField - select zero or more values from a fixed option set.
|
|
14
|
-
*
|
|
15
|
-
* Renders the configured options as toggleable chips. The stored value is a
|
|
16
|
-
* string[] (the selected option values). Used for the `multiselect` field type
|
|
17
|
-
* and for a `select` field declared `multiple: true`.
|
|
18
|
-
*
|
|
19
|
-
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
|
|
20
|
-
* gating as the single `SelectField` (ADR-0058 / #2715), resolved through the
|
|
21
|
-
* shared {@link useCascadingOptions} hook: the offered chips narrow against the
|
|
22
|
-
* live form record + `current_user`, the control is gated behind a "select the
|
|
23
|
-
* parent first" hint while a dependency is empty, and selections no longer
|
|
24
|
-
* offered (parent changed / predicate flipped) are dropped from the array.
|
|
25
|
-
*/
|
|
26
|
-
export function MultiSelectField({
|
|
27
|
-
value,
|
|
28
|
-
onChange,
|
|
29
|
-
field,
|
|
30
|
-
readonly,
|
|
31
|
-
className,
|
|
32
|
-
dependentValues,
|
|
33
|
-
dependsOn: dependsOnProp,
|
|
34
|
-
emptyHint,
|
|
35
|
-
dataSource: _dataSource,
|
|
36
|
-
error,
|
|
37
|
-
...props
|
|
38
|
-
}: FieldWidgetComponentProps<string[]>) {
|
|
39
|
-
const config = field as any;
|
|
40
|
-
const rawOptions: Option[] = config?.options || [];
|
|
41
|
-
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
42
|
-
const fieldName = props.name || config?.name || props.id || '';
|
|
43
|
-
|
|
44
|
-
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
45
|
-
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
46
|
-
rawOptions,
|
|
47
|
-
dependsOn,
|
|
48
|
-
dependentValues,
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
// Cascade clear: drop selected values the offered set no longer includes
|
|
52
|
-
// (parent changed / predicate flipped), keeping the ones still valid — unlike
|
|
53
|
-
// the scalar case we prune per-element rather than clearing the whole field.
|
|
54
|
-
useEffect(() => {
|
|
55
|
-
if (readonly) return;
|
|
56
|
-
// An empty offered set has THREE very different causes, and only one of them
|
|
57
|
-
// is a decision to prune against. This is the canonical copy of the guards;
|
|
58
|
-
// the other three option widgets carry the same pair.
|
|
59
|
-
//
|
|
60
|
-
// 1. Nothing was ever CONFIGURED (objectui#4220) — a field authored with no
|
|
61
|
-
// `options` at all. No decision was made anywhere, and the widget renders
|
|
62
|
-
// its "unfillable" state below. Pruning here is not a cascade, it is
|
|
63
|
-
// deleting the stored value of a field the user was only ever shown a
|
|
64
|
-
// hint for — measured on the detail page's inline editor, which stages
|
|
65
|
-
// that empty array into the record draft the moment the row enters edit
|
|
66
|
-
// mode, and on the grid's inline cell editor, which has always taken this
|
|
67
|
-
// path.
|
|
68
|
-
if (rawOptions.length === 0) return;
|
|
69
|
-
// 2. The list is GATED (objectui#4247) — an authored list withheld because a
|
|
70
|
-
// declared `dependsOn` parent is still empty, so `resolveCascadingOptions`
|
|
71
|
-
// returns nothing at all. Gated means UNKNOWN, not invalid: the widget has
|
|
72
|
-
// no information about which stored values are valid, which is exactly
|
|
73
|
-
// what the `OptionsEmptyState` below tells the user ("select Country
|
|
74
|
-
// first"). Clearing here fired on MOUNT with no interaction — a record
|
|
75
|
-
// that merely ARRIVES with the parent empty (a later-cleared parent, an
|
|
76
|
-
// import, a partially-migrated row) had its picklist silently staged for
|
|
77
|
-
// deletion. Read from the resolver's own `gated` flag rather than
|
|
78
|
-
// re-derived from `options.length === 0`, which would collide with case 1.
|
|
79
|
-
if (gated) return;
|
|
80
|
-
// 3. The list RESOLVED and genuinely excludes the value — a real cascade
|
|
81
|
-
// (the parent changed, a predicate flipped). That is the ADR-0058
|
|
82
|
-
// contract and it still prunes, below.
|
|
83
|
-
if (selected.length === 0) return;
|
|
84
|
-
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
|
|
85
|
-
if (stillOffered.length !== selected.length) onChange(stillOffered);
|
|
86
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
87
|
-
}, [options, gated]);
|
|
88
|
-
|
|
89
|
-
// The host's group label has to be consumed on EVERY surface this widget can
|
|
90
|
-
// render, not only the editable one (objectui#3990): both branches below
|
|
91
|
-
// return before the editable container's `groupDomProps` spread, which is how
|
|
92
|
-
// a field-level `readonly: true` and a zero-option list ended up with a
|
|
93
|
-
// published label id and no consumer at all. See `toHostGroupProps`.
|
|
94
|
-
const hostGroupProps = toHostGroupProps(props);
|
|
95
|
-
|
|
96
|
-
if (readonly) {
|
|
97
|
-
// A readonly set of values is still a set, so it takes the same `group`
|
|
98
|
-
// answer the editable chip row gives. `EmptyValue` is that surface with
|
|
99
|
-
// nothing in it: it publishes an `aria-label` ("No value") of its own, and
|
|
100
|
-
// `aria-labelledby` outranks that per accname — correctly, because on the
|
|
101
|
-
// `generic` role that placeholder span carries, an author name is prohibited
|
|
102
|
-
// and never exposed, so the real choice is the field's name or no name.
|
|
103
|
-
if (selected.length === 0) return <EmptyValue {...hostGroupProps} />;
|
|
104
|
-
return (
|
|
105
|
-
<div {...hostGroupProps} className="flex flex-wrap gap-1">
|
|
106
|
-
{selected.map((v) => {
|
|
107
|
-
// Label from the raw set so a stored value hidden by `visibleWhen`
|
|
108
|
-
// still renders its label rather than a bare id.
|
|
109
|
-
const opt = rawOptions.find((o) => o.value === v);
|
|
110
|
-
return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
|
|
111
|
-
})}
|
|
112
|
-
</div>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// No offered options is unfillable — surface a legible state instead of an
|
|
117
|
-
// empty chip row: the host's `emptyHint` when it computed one, else this
|
|
118
|
-
// widget's own translated copy. Shared with the single select / radio /
|
|
119
|
-
// checkboxes so the four cannot drift again (objectui#3231).
|
|
120
|
-
if (options.length === 0) {
|
|
121
|
-
return (
|
|
122
|
-
<OptionsEmptyState
|
|
123
|
-
emptyHint={emptyHint}
|
|
124
|
-
gated={gated}
|
|
125
|
-
dependsOnFields={dependsOnFields}
|
|
126
|
-
testId={fieldName ? `multiselect-empty-${fieldName}` : undefined}
|
|
127
|
-
className="min-h-9"
|
|
128
|
-
// This box IS the field in this state, so it is what the host label
|
|
129
|
-
// names (objectui#3990).
|
|
130
|
-
hostGroupProps={hostGroupProps}
|
|
131
|
-
/>
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const toggle = (v: string) => {
|
|
136
|
-
const next = selected.includes(v) ? selected.filter((x) => x !== v) : [...selected, v];
|
|
137
|
-
onChange(next);
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
// DOM pass-through (objectui#3318): the container carries the form
|
|
141
|
-
// renderer's id / aria-describedby, but NOT its `aria-invalid` — a plain
|
|
142
|
-
// wrapper div is not where assistive tech reads the invalid state. That
|
|
143
|
-
// state goes onto every focusable chip button below, computed from the
|
|
144
|
-
// published `error` slot (#3222), so whichever chip the user tabs to
|
|
145
|
-
// announces the failure. `name` is withheld too: it is only DOM-legal on
|
|
146
|
-
// form controls, and on this div it is exactly the leak #3291 sweeps for.
|
|
147
|
-
const {
|
|
148
|
-
'aria-invalid': _hostAriaInvalid,
|
|
149
|
-
name: _domName,
|
|
150
|
-
...groupDomProps
|
|
151
|
-
} = toDomProps(props);
|
|
152
|
-
// When the host associated its visible label with this container by IDREF
|
|
153
|
-
// (`aria-labelledby`, objectui#3961 / #3975) the container IS the labelled
|
|
154
|
-
// group, so it answers with the matching role — without one, the name sits on
|
|
155
|
-
// a generic `div` and contributes nothing: `label for` pointing here was
|
|
156
|
-
// already inert (`HTMLLabelElement.control` is null for a div), which is the
|
|
157
|
-
// whole defect. Each chip keeps its own accessible name from its text content,
|
|
158
|
-
// which the group name does not override. Absent (standalone: the inline grid
|
|
159
|
-
// editor, a bare SDUI node) nothing is emitted and the markup is unchanged.
|
|
160
|
-
const isLabelledGroup = groupDomProps['aria-labelledby'] != null;
|
|
161
|
-
|
|
162
|
-
return (
|
|
163
|
-
<div
|
|
164
|
-
{...groupDomProps}
|
|
165
|
-
role={isLabelledGroup ? 'group' : undefined}
|
|
166
|
-
className={cn('flex flex-wrap gap-1.5', className)}
|
|
167
|
-
data-testid={fieldName ? `multiselect-${fieldName}` : undefined}
|
|
168
|
-
>
|
|
169
|
-
{options.map((opt) => {
|
|
170
|
-
// Multi-value fields store string arrays — stringify the (possibly
|
|
171
|
-
// numeric, #3090-widened) authored value at this boundary.
|
|
172
|
-
const value = String(opt.value);
|
|
173
|
-
const active = selected.includes(value);
|
|
174
|
-
return (
|
|
175
|
-
<button
|
|
176
|
-
type="button"
|
|
177
|
-
key={value}
|
|
178
|
-
onClick={() => toggle(value)}
|
|
179
|
-
disabled={props.disabled}
|
|
180
|
-
aria-pressed={active}
|
|
181
|
-
aria-invalid={!!error}
|
|
182
|
-
data-testid={`multiselect-option-${opt.value}`}
|
|
183
|
-
className={cn(
|
|
184
|
-
'rounded-full border px-3 py-1 text-sm transition-colors',
|
|
185
|
-
active
|
|
186
|
-
? 'border-primary bg-primary text-primary-foreground'
|
|
187
|
-
: 'border-input bg-background text-foreground hover:bg-accent',
|
|
188
|
-
)}
|
|
189
|
-
>
|
|
190
|
-
{opt.label}
|
|
191
|
-
</button>
|
|
192
|
-
);
|
|
193
|
-
})}
|
|
194
|
-
</div>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Input, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { NumberFieldMetadata } from '@object-ui/types';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
import { toDomProps } from './toDomProps';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* NumberField - Numeric input with optional decimal precision
|
|
9
|
-
* Supports min/max/step constraints and configurable decimal precision
|
|
10
|
-
*/
|
|
11
|
-
export function NumberField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<number>) {
|
|
12
|
-
if (readonly) {
|
|
13
|
-
return value == null ? <EmptyValue /> : <span className="text-sm">{value}</span>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const numberField = field as NumberFieldMetadata;
|
|
17
|
-
// Step follows `scale` (decimal places), not `precision` (total digit count):
|
|
18
|
-
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
|
|
19
|
-
// (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
|
|
20
|
-
// An explicit `step` in the metadata wins over the derived one.
|
|
21
|
-
const scale = numberField?.scale;
|
|
22
|
-
const step =
|
|
23
|
-
typeof numberField?.step === 'number'
|
|
24
|
-
? numberField.step
|
|
25
|
-
: typeof scale === 'number'
|
|
26
|
-
? Math.pow(10, -scale)
|
|
27
|
-
: 'any';
|
|
28
|
-
|
|
29
|
-
const domProps = toDomProps(props);
|
|
30
|
-
|
|
31
|
-
return (
|
|
32
|
-
<Input
|
|
33
|
-
{...domProps}
|
|
34
|
-
type="number"
|
|
35
|
-
value={value ?? ''}
|
|
36
|
-
onChange={(e) => {
|
|
37
|
-
const val = e.target.value;
|
|
38
|
-
onChange(val === '' ? (null as any) : Number(val));
|
|
39
|
-
}}
|
|
40
|
-
placeholder={numberField?.placeholder}
|
|
41
|
-
disabled={readonly || domProps.disabled}
|
|
42
|
-
// Surface the field's declared range so the browser's spinner/keyboard
|
|
43
|
-
// affordances respect it (server-side validation still owns enforcement).
|
|
44
|
-
min={typeof numberField?.min === 'number' ? numberField.min : undefined}
|
|
45
|
-
max={typeof numberField?.max === 'number' ? numberField.max : undefined}
|
|
46
|
-
step={step}
|
|
47
|
-
/>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { Textarea, cn, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
import { toDomProps } from './toDomProps';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* ObjectField - JSON object editor
|
|
8
|
-
* Allows editing structured JSON data
|
|
9
|
-
*/
|
|
10
|
-
export function ObjectField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<any>) {
|
|
11
|
-
const config = field;
|
|
12
|
-
|
|
13
|
-
// Initialize string state based on value
|
|
14
|
-
const getInitialJsonString = () => {
|
|
15
|
-
if (value === undefined || value === null) return '';
|
|
16
|
-
return JSON.stringify(value, null, 2);
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
const [jsonString, setJsonString] = useState(getInitialJsonString);
|
|
20
|
-
// Named `parseError`, NOT `error`: `error` is the published validation slot
|
|
21
|
-
// on the widget contract (#3222) and is destructured above.
|
|
22
|
-
const [parseError, setParseError] = useState<string | null>(null);
|
|
23
|
-
|
|
24
|
-
// Sync internal string state when value changes externally
|
|
25
|
-
// This is a controlled component pattern where we need to sync external changes
|
|
26
|
-
useEffect(() => {
|
|
27
|
-
try {
|
|
28
|
-
if (value === undefined || value === null) {
|
|
29
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
|
|
30
|
-
setJsonString('');
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
// Only update if the parsed internal state doesn't match the new value
|
|
34
|
-
// This prevents cursor jumping/reformatting while typing valid JSON
|
|
35
|
-
const currentParsed = jsonString ? JSON.parse(jsonString) : null;
|
|
36
|
-
if (JSON.stringify(currentParsed) !== JSON.stringify(value)) {
|
|
37
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
|
|
38
|
-
setJsonString(JSON.stringify(value, null, 2));
|
|
39
|
-
}
|
|
40
|
-
} catch {
|
|
41
|
-
// Fallback if internal state was invalid JSON
|
|
42
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- Required for controlled component sync
|
|
43
|
-
setJsonString(JSON.stringify(value, null, 2));
|
|
44
|
-
}
|
|
45
|
-
}, [value, jsonString]);
|
|
46
|
-
|
|
47
|
-
if (readonly) {
|
|
48
|
-
if (!value) return <EmptyValue />;
|
|
49
|
-
return (
|
|
50
|
-
<pre className={cn("text-xs bg-gray-50 p-2 rounded border border-gray-200 overflow-auto max-h-40", props.className)}>
|
|
51
|
-
{JSON.stringify(value, null, 2)}
|
|
52
|
-
</pre>
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
57
|
-
const str = e.target.value;
|
|
58
|
-
setJsonString(str);
|
|
59
|
-
setParseError(null);
|
|
60
|
-
|
|
61
|
-
if (!str.trim()) {
|
|
62
|
-
onChange(null);
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
const parsed = JSON.parse(str);
|
|
68
|
-
onChange(parsed);
|
|
69
|
-
} catch (e) {
|
|
70
|
-
// Invalid JSON - don't propagate change to parent, but keep local state
|
|
71
|
-
setParseError("Invalid JSON");
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
return (
|
|
76
|
-
<div className="space-y-1">
|
|
77
|
-
<Textarea
|
|
78
|
-
// DOM pass-through onto the real focusable control (objectui#3318).
|
|
79
|
-
{...toDomProps(props)}
|
|
80
|
-
value={jsonString}
|
|
81
|
-
onChange={handleChange}
|
|
82
|
-
placeholder={config?.placeholder || '{\n "key": "value"\n}'}
|
|
83
|
-
disabled={readonly || props.disabled}
|
|
84
|
-
className={cn("font-mono text-xs", parseError ? "border-red-500 focus-visible:ring-red-500" : "", props.className)}
|
|
85
|
-
rows={6}
|
|
86
|
-
// AFTER the spread so this widget's own computation wins (#3222).
|
|
87
|
-
// Invalid = the form's validation slot OR this widget's own unparsable
|
|
88
|
-
// draft (which already draws the red border above).
|
|
89
|
-
aria-invalid={!!error || !!parseError}
|
|
90
|
-
/>
|
|
91
|
-
{parseError && <p className="text-xs text-red-500">{parseError}</p>}
|
|
92
|
-
</div>
|
|
93
|
-
);
|
|
94
|
-
}
|