@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,182 +0,0 @@
|
|
|
1
|
-
import React, { useId } from 'react';
|
|
2
|
-
import { Input, Label, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
import { toDomProps } from './toDomProps';
|
|
5
|
-
import { toHostGroupProps } from './toHostGroupProps';
|
|
6
|
-
// The value shape and the single-line formatting rule now live in a pure module
|
|
7
|
-
// so the display (read) cell renderer formats a stored address exactly the way
|
|
8
|
-
// this widget's readonly branch does — one rule, not two copies that drift
|
|
9
|
-
// (objectui#4037). Behaviour here is unchanged; only the definition moved.
|
|
10
|
-
import {
|
|
11
|
-
formatAddress,
|
|
12
|
-
readPostalCode,
|
|
13
|
-
type AddressValue,
|
|
14
|
-
type LegacyAddressValue,
|
|
15
|
-
} from './address-format';
|
|
16
|
-
|
|
17
|
-
// Re-exported through its declaring module rather than bare, so the spec-symbol
|
|
18
|
-
// guard resolves the name to where it is actually defined: `address-format`
|
|
19
|
-
// imports `AddressValue` from `@objectstack/spec/data` (objectui#4167), and a
|
|
20
|
-
// bare `export type { AddressValue }` here would read to that guard as a second,
|
|
21
|
-
// local declaration of a name the spec owns.
|
|
22
|
-
export type { AddressValue } from './address-format';
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Address field widget - provides a structured address input
|
|
26
|
-
* Supports street, city, state, postal code, and country
|
|
27
|
-
*/
|
|
28
|
-
export function AddressField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<AddressValue>) {
|
|
29
|
-
const address = value || {};
|
|
30
|
-
// DOM pass-through (objectui#3318): the whitelist spread goes onto the FIRST
|
|
31
|
-
// sub-input (street) — one carrier for the form renderer's aria-describedby;
|
|
32
|
-
// the composite's validation state goes onto EVERY focusable sub-input via
|
|
33
|
-
// `aria-invalid={!!error}` (a form-level failure means the whole address is
|
|
34
|
-
// missing/invalid, and whichever sub-input the user reaches must announce it).
|
|
35
|
-
//
|
|
36
|
-
// Two keys are held back from that spread and land on the group CONTAINER
|
|
37
|
-
// instead (objectui#3961), because they address the field as a WHOLE:
|
|
38
|
-
//
|
|
39
|
-
// - `id` — the host's control id. It never reached the DOM at all: it was
|
|
40
|
-
// spread here and then overwritten one line later by `id={subId('street')}`
|
|
41
|
-
// (objectui#3343), which is why the form's "Shipping Address" label pointed
|
|
42
|
-
// at an id no element carried. The sub-input ids stay exactly as they are;
|
|
43
|
-
// only the host id moves out to the element it was always meant to name.
|
|
44
|
-
// - `aria-labelledby` — the host's label, associated by IDREF. On the street
|
|
45
|
-
// input it would be actively wrong: `aria-labelledby` OVERRIDES a control's
|
|
46
|
-
// `<label for>`, so the first sub-input would be announced as "Shipping
|
|
47
|
-
// Address" and its own "Street Address" label would vanish.
|
|
48
|
-
//
|
|
49
|
-
// Everything else (`aria-describedby`, `name`, focus handlers …) deliberately
|
|
50
|
-
// stays on the first sub-input: a description or error must be announced when
|
|
51
|
-
// focus lands on something focusable, and a container is not.
|
|
52
|
-
const {
|
|
53
|
-
id: _hostId,
|
|
54
|
-
'aria-labelledby': _hostLabelledBy,
|
|
55
|
-
...domProps
|
|
56
|
-
} = toDomProps(props);
|
|
57
|
-
// The pair held back above, in the one spelling every group-labelled widget
|
|
58
|
-
// uses for it — and computed HERE, before the readonly early return below,
|
|
59
|
-
// because that return used to drop both keys on the floor: a published label
|
|
60
|
-
// id with no consumer in the document (objectui#3990). See `toHostGroupProps`.
|
|
61
|
-
const hostGroupProps = toHostGroupProps(props);
|
|
62
|
-
// Sub-input ids (objectui#3343): `useId()` prefix + sub-field name — the
|
|
63
|
-
// `groupId` paradigm of RadioField / CheckboxesField. Hardcoded literals
|
|
64
|
-
// ("street", "city", …) collide as soon as a form renders two address
|
|
65
|
-
// fields, and every label's htmlFor then resolves to the FIRST match.
|
|
66
|
-
const groupId = useId();
|
|
67
|
-
const subId = (name: keyof AddressValue) => `${groupId}-${name}`;
|
|
68
|
-
|
|
69
|
-
// Read through the legacy key, write only the canonical one (objectstack#5143).
|
|
70
|
-
const postalCode = readPostalCode(address);
|
|
71
|
-
|
|
72
|
-
const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
|
|
73
|
-
// Normalize while we are here: the object we write back never carries
|
|
74
|
-
// `zipCode`, and a legacy postal code is carried forward under
|
|
75
|
-
// `postalCode`. Without the carry, editing an unrelated part of a
|
|
76
|
-
// legacy-shaped address would write the postal code straight back out of
|
|
77
|
-
// the record — the very data loss this fixes, one build later.
|
|
78
|
-
const canonical: LegacyAddressValue = { ...address };
|
|
79
|
-
delete canonical.zipCode;
|
|
80
|
-
onChange({
|
|
81
|
-
...canonical,
|
|
82
|
-
...(postalCode ? { postalCode } : null),
|
|
83
|
-
[fieldName]: fieldValue,
|
|
84
|
-
});
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
if (readonly) {
|
|
88
|
-
// Readonly the composite collapses to ONE formatted line — no sub-inputs and
|
|
89
|
-
// no sub-labels — so that line is the surface the host label names, and
|
|
90
|
-
// `EmptyValue` is the same surface with nothing in it (objectui#3990). The
|
|
91
|
-
// placeholder's own `aria-label` ("No value") is outranked by
|
|
92
|
-
// `aria-labelledby` per accname, and on its `generic` role an author name was
|
|
93
|
-
// never exposed anyway.
|
|
94
|
-
const formatted = formatAddress(address);
|
|
95
|
-
return formatted ? (
|
|
96
|
-
<span {...hostGroupProps} className="text-sm">{formatted}</span>
|
|
97
|
-
) : (
|
|
98
|
-
<EmptyValue {...hostGroupProps} />
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
// `role="group"` only when a host actually named this container
|
|
104
|
-
// (objectui#3961): an unnamed group adds nothing for assistive tech, and
|
|
105
|
-
// standalone rendering — the inline grid editor, a bare SDUI node — must stay
|
|
106
|
-
// byte-identical to what it was before. That condition now lives in
|
|
107
|
-
// `toHostGroupProps` so this container and the readonly line above cannot
|
|
108
|
-
// answer differently (objectui#3990).
|
|
109
|
-
<div className="space-y-3" {...hostGroupProps}>
|
|
110
|
-
<div>
|
|
111
|
-
<Label htmlFor={subId('street')} className="text-xs">Street Address</Label>
|
|
112
|
-
<Input
|
|
113
|
-
{...domProps}
|
|
114
|
-
id={subId('street')}
|
|
115
|
-
type="text"
|
|
116
|
-
value={address.street || ''}
|
|
117
|
-
onChange={(e) => handleFieldChange('street', e.target.value)}
|
|
118
|
-
placeholder="123 Main St"
|
|
119
|
-
disabled={readonly || props.disabled}
|
|
120
|
-
className={props.className}
|
|
121
|
-
aria-invalid={!!error}
|
|
122
|
-
/>
|
|
123
|
-
</div>
|
|
124
|
-
|
|
125
|
-
<div className="grid grid-cols-2 gap-3">
|
|
126
|
-
<div>
|
|
127
|
-
<Label htmlFor={subId('city')} className="text-xs">City</Label>
|
|
128
|
-
<Input
|
|
129
|
-
id={subId('city')}
|
|
130
|
-
type="text"
|
|
131
|
-
value={address.city || ''}
|
|
132
|
-
onChange={(e) => handleFieldChange('city', e.target.value)}
|
|
133
|
-
placeholder="San Francisco"
|
|
134
|
-
disabled={readonly || props.disabled}
|
|
135
|
-
aria-invalid={!!error}
|
|
136
|
-
/>
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<div>
|
|
140
|
-
<Label htmlFor={subId('state')} className="text-xs">State / Province</Label>
|
|
141
|
-
<Input
|
|
142
|
-
id={subId('state')}
|
|
143
|
-
type="text"
|
|
144
|
-
value={address.state || ''}
|
|
145
|
-
onChange={(e) => handleFieldChange('state', e.target.value)}
|
|
146
|
-
placeholder="CA"
|
|
147
|
-
disabled={readonly || props.disabled}
|
|
148
|
-
aria-invalid={!!error}
|
|
149
|
-
/>
|
|
150
|
-
</div>
|
|
151
|
-
</div>
|
|
152
|
-
|
|
153
|
-
<div className="grid grid-cols-2 gap-3">
|
|
154
|
-
<div>
|
|
155
|
-
<Label htmlFor={subId('postalCode')} className="text-xs">ZIP / Postal Code</Label>
|
|
156
|
-
<Input
|
|
157
|
-
id={subId('postalCode')}
|
|
158
|
-
type="text"
|
|
159
|
-
value={postalCode || ''}
|
|
160
|
-
onChange={(e) => handleFieldChange('postalCode', e.target.value)}
|
|
161
|
-
placeholder="94102"
|
|
162
|
-
disabled={readonly || props.disabled}
|
|
163
|
-
aria-invalid={!!error}
|
|
164
|
-
/>
|
|
165
|
-
</div>
|
|
166
|
-
|
|
167
|
-
<div>
|
|
168
|
-
<Label htmlFor={subId('country')} className="text-xs">Country</Label>
|
|
169
|
-
<Input
|
|
170
|
-
id={subId('country')}
|
|
171
|
-
type="text"
|
|
172
|
-
value={address.country || ''}
|
|
173
|
-
onChange={(e) => handleFieldChange('country', e.target.value)}
|
|
174
|
-
placeholder="United States"
|
|
175
|
-
disabled={readonly || props.disabled}
|
|
176
|
-
aria-invalid={!!error}
|
|
177
|
-
/>
|
|
178
|
-
</div>
|
|
179
|
-
</div>
|
|
180
|
-
</div>
|
|
181
|
-
);
|
|
182
|
-
}
|
|
@@ -1,93 +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
|
-
* Unique sub-input ids for the composite `address` widget (objectui#3343).
|
|
11
|
-
*
|
|
12
|
-
* The widget used to hardcode literal ids ("street", "city", "state",
|
|
13
|
-
* "postalCode" — spelled "zipCode" until objectstack#5143 —, "country") on its
|
|
14
|
-
* sub-inputs. Two address fields in one form —
|
|
15
|
-
* e.g. billing + shipping — then produced duplicate DOM ids, and every
|
|
16
|
-
* `Label htmlFor` resolved to the FIRST match in the document: each sub-label
|
|
17
|
-
* of the second field clicked/announced the first field's input.
|
|
18
|
-
*
|
|
19
|
-
* The fix follows the `groupId` paradigm of RadioField / CheckboxesField:
|
|
20
|
-
* a `useId()` prefix + the sub-field name. These tests pin it by rendering
|
|
21
|
-
* TWO instances in one form:
|
|
22
|
-
*
|
|
23
|
-
* 1. every `[id]` in the document is globally unique (red again if anyone
|
|
24
|
-
* reverts to hardcoded literals — both instances would emit id="street");
|
|
25
|
-
* 2. each sub-label of the SECOND instance resolves — via the exact
|
|
26
|
-
* mechanism browsers use for label activation, `for` → getElementById —
|
|
27
|
-
* to an input inside its OWN instance, and focusing it lands there.
|
|
28
|
-
*/
|
|
29
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
30
|
-
import React from 'react';
|
|
31
|
-
import { render, within } from '@testing-library/react';
|
|
32
|
-
import '@testing-library/jest-dom';
|
|
33
|
-
import { AddressField } from './AddressField';
|
|
34
|
-
|
|
35
|
-
const field = { name: 'address', type: 'address' } as any;
|
|
36
|
-
|
|
37
|
-
const SUB_LABELS = [
|
|
38
|
-
'Street Address',
|
|
39
|
-
'City',
|
|
40
|
-
'State / Province',
|
|
41
|
-
'ZIP / Postal Code',
|
|
42
|
-
'Country',
|
|
43
|
-
];
|
|
44
|
-
|
|
45
|
-
function renderTwoInOneForm() {
|
|
46
|
-
return render(
|
|
47
|
-
<form>
|
|
48
|
-
<div data-testid="address-a">
|
|
49
|
-
<AddressField value={{}} onChange={vi.fn()} field={field} />
|
|
50
|
-
</div>
|
|
51
|
-
<div data-testid="address-b">
|
|
52
|
-
<AddressField value={{}} onChange={vi.fn()} field={field} />
|
|
53
|
-
</div>
|
|
54
|
-
</form>,
|
|
55
|
-
);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
describe('AddressField — unique sub-input ids (objectui#3343)', () => {
|
|
59
|
-
it('two address fields in one form produce globally unique DOM ids', () => {
|
|
60
|
-
const { baseElement } = renderTwoInOneForm();
|
|
61
|
-
const ids = Array.from(baseElement.querySelectorAll('[id]')).map((el) => el.id);
|
|
62
|
-
// 5 sub-inputs per instance — both instances must actually render ids.
|
|
63
|
-
expect(ids.length).toBeGreaterThanOrEqual(10);
|
|
64
|
-
const duplicates = ids.filter((id, i) => ids.indexOf(id) !== i);
|
|
65
|
-
expect(duplicates).toEqual([]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("each sub-label of the SECOND field focuses that field's own input", () => {
|
|
69
|
-
const { getByTestId } = renderTwoInOneForm();
|
|
70
|
-
const first = getByTestId('address-a');
|
|
71
|
-
const second = getByTestId('address-b');
|
|
72
|
-
|
|
73
|
-
for (const text of SUB_LABELS) {
|
|
74
|
-
const label = within(second).getByText(text);
|
|
75
|
-
const forId = label.getAttribute('for');
|
|
76
|
-
expect(forId, `label "${text}" must carry htmlFor`).toBeTruthy();
|
|
77
|
-
|
|
78
|
-
// Exactly what the browser does on label click / SR announcement:
|
|
79
|
-
// resolve `for` against the document.
|
|
80
|
-
const control = document.getElementById(forId!);
|
|
81
|
-
expect(control, `htmlFor of "${text}" must resolve`).not.toBeNull();
|
|
82
|
-
expect(control!.tagName).toBe('INPUT');
|
|
83
|
-
expect(
|
|
84
|
-
second.contains(control),
|
|
85
|
-
`"${text}" must resolve into its OWN field, not the first one`,
|
|
86
|
-
).toBe(true);
|
|
87
|
-
expect(first.contains(control)).toBe(false);
|
|
88
|
-
|
|
89
|
-
(control as HTMLInputElement).focus();
|
|
90
|
-
expect(control).toHaveFocus();
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
});
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* AutoNumberField - Read-only auto-generated field
|
|
7
|
-
* Values are automatically generated by the system and cannot be edited
|
|
8
|
-
*/
|
|
9
|
-
export function AutoNumberField({ value, field, ...props }: FieldWidgetComponentProps<string | number>) {
|
|
10
|
-
if (value == null) {
|
|
11
|
-
return <EmptyValue className={props.className} />;
|
|
12
|
-
}
|
|
13
|
-
return (
|
|
14
|
-
<span className={`text-sm font-mono text-gray-700 ${props.className || ''}`}>
|
|
15
|
-
{String(value)}
|
|
16
|
-
</span>
|
|
17
|
-
);
|
|
18
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
|
|
3
|
-
import { Upload, X } from 'lucide-react';
|
|
4
|
-
import { FieldWidgetComponentProps } from './types';
|
|
5
|
-
import { toDomProps } from './toDomProps';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Avatar field widget - provides an avatar/profile picture uploader
|
|
9
|
-
* Supports image URLs or file uploads
|
|
10
|
-
*/
|
|
11
|
-
export function AvatarField({ value, onChange, field, readonly, error, ...props }: FieldWidgetComponentProps<string>) {
|
|
12
|
-
const [isHovered, setIsHovered] = React.useState(false);
|
|
13
|
-
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
14
|
-
|
|
15
|
-
const avatarField = field as any;
|
|
16
|
-
|
|
17
|
-
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
18
|
-
const file = event.target.files?.[0];
|
|
19
|
-
if (!file) return;
|
|
20
|
-
|
|
21
|
-
// Check file type
|
|
22
|
-
if (!file.type.startsWith('image/')) {
|
|
23
|
-
console.error('Please select an image file');
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Check file size (max 5MB)
|
|
28
|
-
if (file.size > 5 * 1024 * 1024) {
|
|
29
|
-
console.error('File size must be less than 5MB');
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Convert to base64 or upload to server
|
|
34
|
-
const reader = new FileReader();
|
|
35
|
-
reader.onloadend = () => {
|
|
36
|
-
onChange(reader.result as string);
|
|
37
|
-
};
|
|
38
|
-
reader.readAsDataURL(file);
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const handleRemove = () => {
|
|
42
|
-
onChange('');
|
|
43
|
-
};
|
|
44
|
-
|
|
45
|
-
// Extract initials for fallback
|
|
46
|
-
const getInitials = (): string => {
|
|
47
|
-
const name = avatarField?.defaultName || avatarField?.label || 'User';
|
|
48
|
-
return name
|
|
49
|
-
.split(' ')
|
|
50
|
-
.map((word: string) => word[0])
|
|
51
|
-
.join('')
|
|
52
|
-
.toUpperCase()
|
|
53
|
-
.slice(0, 2);
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
if (readonly) {
|
|
57
|
-
return (
|
|
58
|
-
<Avatar className="w-16 h-16">
|
|
59
|
-
{value && <AvatarImage src={value} alt={avatarField?.label} />}
|
|
60
|
-
<AvatarFallback>{getInitials()}</AvatarFallback>
|
|
61
|
-
</Avatar>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return (
|
|
66
|
-
<div className="flex items-center gap-4">
|
|
67
|
-
<div
|
|
68
|
-
className="relative"
|
|
69
|
-
onMouseEnter={() => setIsHovered(true)}
|
|
70
|
-
onMouseLeave={() => setIsHovered(false)}
|
|
71
|
-
>
|
|
72
|
-
<Avatar className="w-16 h-16">
|
|
73
|
-
{value && <AvatarImage src={value} alt={avatarField?.label} />}
|
|
74
|
-
<AvatarFallback>{getInitials()}</AvatarFallback>
|
|
75
|
-
</Avatar>
|
|
76
|
-
{!readonly && isHovered && value && (
|
|
77
|
-
<button
|
|
78
|
-
type="button"
|
|
79
|
-
onClick={handleRemove}
|
|
80
|
-
className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full p-1 hover:bg-destructive/90"
|
|
81
|
-
>
|
|
82
|
-
<X className="w-3 h-3" />
|
|
83
|
-
</button>
|
|
84
|
-
)}
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<div className="flex flex-col gap-2">
|
|
88
|
-
<input
|
|
89
|
-
ref={fileInputRef}
|
|
90
|
-
type="file"
|
|
91
|
-
accept="image/*"
|
|
92
|
-
onChange={handleFileSelect}
|
|
93
|
-
className="hidden"
|
|
94
|
-
/>
|
|
95
|
-
<Button
|
|
96
|
-
// DOM pass-through onto the widget's real focusable control — the
|
|
97
|
-
// upload button is the keyboard path to the hidden file input
|
|
98
|
-
// (objectui#3318).
|
|
99
|
-
{...toDomProps(props)}
|
|
100
|
-
type="button"
|
|
101
|
-
variant="outline"
|
|
102
|
-
size="sm"
|
|
103
|
-
onClick={() => fileInputRef.current?.click()}
|
|
104
|
-
disabled={readonly || props.disabled}
|
|
105
|
-
// AFTER the spread so this widget's own computation wins (#3222).
|
|
106
|
-
aria-invalid={!!error}
|
|
107
|
-
>
|
|
108
|
-
<Upload className="w-4 h-4 mr-2" />
|
|
109
|
-
{value ? 'Change' : 'Upload'} Avatar
|
|
110
|
-
</Button>
|
|
111
|
-
<p className="text-xs text-muted-foreground">
|
|
112
|
-
PNG, JPG up to 5MB
|
|
113
|
-
</p>
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import React, { useId } from 'react';
|
|
2
|
-
import { Switch, Checkbox, Label } from '@object-ui/components';
|
|
3
|
-
import { FieldWidgetComponentProps } from './types';
|
|
4
|
-
import { toDomProps } from './toDomProps';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* BooleanField - Toggle input supporting switch and checkbox variants
|
|
8
|
-
* Renders as Switch or Checkbox based on field widget configuration
|
|
9
|
-
*/
|
|
10
|
-
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
|
|
11
|
-
const config = field as any;
|
|
12
|
-
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
|
|
13
|
-
const widget = config?.widget;
|
|
14
|
-
// Generate unique ID using React's useId hook - must be before early returns (rules of hooks)
|
|
15
|
-
const generatedId = useId();
|
|
16
|
-
/**
|
|
17
|
-
* The HOST owns the control's id (objectui#3952).
|
|
18
|
-
*
|
|
19
|
-
* `<FormControl>` is a Radix `Slot` that hands its child the id its
|
|
20
|
-
* `<FormLabel htmlFor>` already points at (`_r_N_-form-item`). This widget
|
|
21
|
-
* used to REPLACE it with the field name, so the form's visible label
|
|
22
|
-
* referenced an id no element carried: `label for="_r_3_-form-item"` against
|
|
23
|
-
* `all ids in form = ["notifications"]`. Clicking the label — the normal
|
|
24
|
-
* affordance for a switch/checkbox row — toggled nothing, on every generated
|
|
25
|
-
* form in every app.
|
|
26
|
-
*
|
|
27
|
-
* `id` is a DECLARED key of the widget contract (`FieldWidgetDomProps`) and
|
|
28
|
-
* `toDomProps` forwards it, so honouring it here is the contract's own
|
|
29
|
-
* direction: the fix belongs at the consumer that discarded a delivered
|
|
30
|
-
* value, not in the form renderer (AGENTS.md #0.1).
|
|
31
|
-
*
|
|
32
|
-
* The fallback chain stays for the STANDALONE hosts that hand down no id —
|
|
33
|
-
* `FieldEditWidget` (the grid's inline cell editor) and a bare SDUI node.
|
|
34
|
-
*/
|
|
35
|
-
const hostId = props.id;
|
|
36
|
-
const id = hostId || config?.name || generatedId;
|
|
37
|
-
const label = config?.label || 'Checkbox';
|
|
38
|
-
/**
|
|
39
|
-
* A host that supplies the id is a host that renders its own label for it
|
|
40
|
-
* (that is what the id is FOR), so the widget's `sr-only` label would be the
|
|
41
|
-
* second copy of one name — the other half of #3952, and why a naive
|
|
42
|
-
* `getByText(label)` on a boolean field returned two nodes.
|
|
43
|
-
*
|
|
44
|
-
* Standalone it is the control's ONLY accessible name (`FieldEditWidget`
|
|
45
|
-
* renders no label of its own), so there it stays. Deliberately keyed on the
|
|
46
|
-
* host id rather than on `config.label`: inside a form the naming is the
|
|
47
|
-
* host's job for EVERY widget — a text field in a label-less form row is
|
|
48
|
-
* equally unnamed — and the generic `'Checkbox'` fallback emitted there was
|
|
49
|
-
* an actively WRONG accessible name, not a safety net.
|
|
50
|
-
*/
|
|
51
|
-
const emitOwnLabel = !hostId;
|
|
52
|
-
|
|
53
|
-
if (readonly) {
|
|
54
|
-
return <span className="text-sm">{value ? 'Yes' : 'No'}</span>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const domProps = toDomProps(props);
|
|
58
|
-
|
|
59
|
-
if (widget === 'checkbox') {
|
|
60
|
-
return (
|
|
61
|
-
<div className="flex items-center space-x-2">
|
|
62
|
-
<Checkbox
|
|
63
|
-
{...domProps}
|
|
64
|
-
id={id}
|
|
65
|
-
checked={!!value}
|
|
66
|
-
onCheckedChange={(checked) => onChange(!!checked)}
|
|
67
|
-
disabled={readonly || domProps.disabled}
|
|
68
|
-
/>
|
|
69
|
-
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
|
|
70
|
-
</div>
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return (
|
|
75
|
-
<div className="flex items-center space-x-2">
|
|
76
|
-
<Switch
|
|
77
|
-
{...domProps}
|
|
78
|
-
id={id}
|
|
79
|
-
checked={!!value}
|
|
80
|
-
onCheckedChange={onChange}
|
|
81
|
-
disabled={readonly || domProps.disabled}
|
|
82
|
-
/>
|
|
83
|
-
{emitOwnLabel && <Label htmlFor={id} className="sr-only">{label}</Label>}
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
}
|
|
@@ -1,168 +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
|
-
* CapabilityMultiSelectField (ADR-0056 P2, epic #2398).
|
|
11
|
-
*
|
|
12
|
-
* Proves the two properties P2 depends on:
|
|
13
|
-
* 1. the stored value parses tolerantly into a capability-name list, and
|
|
14
|
-
* 2. every edit emits a JSON-**string** array of names — byte-equivalent to
|
|
15
|
-
* the `sys_permission_set.system_permissions` textarea storage, so the
|
|
16
|
-
* round-trip through the picker never changes the on-disk shape.
|
|
17
|
-
*/
|
|
18
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
19
|
-
import React from 'react';
|
|
20
|
-
import { render, screen, fireEvent } from '@testing-library/react';
|
|
21
|
-
import '@testing-library/jest-dom';
|
|
22
|
-
import { CapabilityMultiSelectField, parseCapabilityNames } from './CapabilityMultiSelectField';
|
|
23
|
-
|
|
24
|
-
describe('parseCapabilityNames', () => {
|
|
25
|
-
it('parses the canonical JSON-string array', () => {
|
|
26
|
-
expect(parseCapabilityNames('["setup.access","studio.access"]')).toEqual([
|
|
27
|
-
'setup.access',
|
|
28
|
-
'studio.access',
|
|
29
|
-
]);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('treats empty / null / undefined as no selection', () => {
|
|
33
|
-
expect(parseCapabilityNames('')).toEqual([]);
|
|
34
|
-
expect(parseCapabilityNames(' ')).toEqual([]);
|
|
35
|
-
expect(parseCapabilityNames(null)).toEqual([]);
|
|
36
|
-
expect(parseCapabilityNames(undefined)).toEqual([]);
|
|
37
|
-
expect(parseCapabilityNames('[]')).toEqual([]);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('tolerates an already-parsed array', () => {
|
|
41
|
-
expect(parseCapabilityNames(['a', 'b'])).toEqual(['a', 'b']);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('falls back to comma-splitting a non-JSON legacy string', () => {
|
|
45
|
-
expect(parseCapabilityNames('setup.access, studio.access')).toEqual([
|
|
46
|
-
'setup.access',
|
|
47
|
-
'studio.access',
|
|
48
|
-
]);
|
|
49
|
-
});
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
const CAPS = [
|
|
53
|
-
{ name: 'manage_users', label: 'Manage Users', description: 'Manage users', scope: 'platform', active: true },
|
|
54
|
-
{ name: 'studio.access', label: 'Studio Access', description: 'Enter Studio', scope: 'platform', active: true },
|
|
55
|
-
{ name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
|
|
56
|
-
];
|
|
57
|
-
|
|
58
|
-
function mockDataSource(rows = CAPS) {
|
|
59
|
-
return { find: vi.fn().mockResolvedValue({ data: rows }) } as any;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
describe('CapabilityMultiSelectField', () => {
|
|
63
|
-
it('renders capabilities grouped by scope with human labels', async () => {
|
|
64
|
-
render(
|
|
65
|
-
<CapabilityMultiSelectField
|
|
66
|
-
value={'["studio.access"]'}
|
|
67
|
-
onChange={vi.fn()}
|
|
68
|
-
field={{ name: 'system_permissions' } as any}
|
|
69
|
-
dataSource={mockDataSource()}
|
|
70
|
-
/>,
|
|
71
|
-
);
|
|
72
|
-
// group headers
|
|
73
|
-
expect(await screen.findByText('Platform')).toBeInTheDocument();
|
|
74
|
-
expect(screen.getByText('Organization')).toBeInTheDocument();
|
|
75
|
-
// labels, not raw names
|
|
76
|
-
expect(screen.getByRole('button', { name: 'Studio Access' })).toHaveAttribute('aria-pressed', 'true');
|
|
77
|
-
expect(screen.getByRole('button', { name: 'Manage Users' })).toHaveAttribute('aria-pressed', 'false');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('emits a JSON-string array when a capability is toggled on', async () => {
|
|
81
|
-
const onChange = vi.fn();
|
|
82
|
-
render(
|
|
83
|
-
<CapabilityMultiSelectField
|
|
84
|
-
value={'["studio.access"]'}
|
|
85
|
-
onChange={onChange}
|
|
86
|
-
field={{ name: 'system_permissions' } as any}
|
|
87
|
-
dataSource={mockDataSource()}
|
|
88
|
-
/>,
|
|
89
|
-
);
|
|
90
|
-
fireEvent.click(await screen.findByRole('button', { name: 'Manage Users' }));
|
|
91
|
-
expect(onChange).toHaveBeenCalledTimes(1);
|
|
92
|
-
const emitted = onChange.mock.calls[0][0];
|
|
93
|
-
// The emitted value must be a JSON *string* (not a raw array), byte-equal
|
|
94
|
-
// to the textarea storage contract.
|
|
95
|
-
expect(typeof emitted).toBe('string');
|
|
96
|
-
expect(JSON.parse(emitted)).toEqual(['studio.access', 'manage_users']);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('removes a capability (and keeps emitting a JSON string) when toggled off', async () => {
|
|
100
|
-
const onChange = vi.fn();
|
|
101
|
-
render(
|
|
102
|
-
<CapabilityMultiSelectField
|
|
103
|
-
value={'["studio.access","manage_users"]'}
|
|
104
|
-
onChange={onChange}
|
|
105
|
-
field={{ name: 'system_permissions' } as any}
|
|
106
|
-
dataSource={mockDataSource()}
|
|
107
|
-
/>,
|
|
108
|
-
);
|
|
109
|
-
// Chip labels now resolve through the i18n bundle (objectui#2600 B5), so an
|
|
110
|
-
// async translation-settle can re-render the chips between findBy and the
|
|
111
|
-
// click — re-query at click time so we act on the live node, not a detached one.
|
|
112
|
-
await screen.findByRole('button', { name: 'Studio Access' });
|
|
113
|
-
fireEvent.click(screen.getByRole('button', { name: 'Studio Access' }));
|
|
114
|
-
const emitted = onChange.mock.calls[0][0];
|
|
115
|
-
expect(typeof emitted).toBe('string');
|
|
116
|
-
expect(JSON.parse(emitted)).toEqual(['manage_users']);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('preserves an unknown/legacy selected name not in the registry', async () => {
|
|
120
|
-
render(
|
|
121
|
-
<CapabilityMultiSelectField
|
|
122
|
-
value={'["studio.access","legacy.custom"]'}
|
|
123
|
-
onChange={vi.fn()}
|
|
124
|
-
field={{ name: 'system_permissions' } as any}
|
|
125
|
-
dataSource={mockDataSource()}
|
|
126
|
-
/>,
|
|
127
|
-
);
|
|
128
|
-
// The unknown name still renders as a (selected) chip so it is not dropped.
|
|
129
|
-
const legacy = await screen.findByRole('button', { name: 'legacy.custom' });
|
|
130
|
-
expect(legacy).toHaveAttribute('aria-pressed', 'true');
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
it('renders selected labels as read-only badges when readonly', async () => {
|
|
134
|
-
render(
|
|
135
|
-
<CapabilityMultiSelectField
|
|
136
|
-
value={'["studio.access"]'}
|
|
137
|
-
onChange={vi.fn()}
|
|
138
|
-
field={{ name: 'system_permissions' } as any}
|
|
139
|
-
dataSource={mockDataSource()}
|
|
140
|
-
readonly
|
|
141
|
-
/>,
|
|
142
|
-
);
|
|
143
|
-
// No toggle buttons in readonly mode.
|
|
144
|
-
expect(await screen.findByText('Studio Access')).toBeInTheDocument();
|
|
145
|
-
expect(screen.queryByRole('button', { name: 'Manage Users' })).not.toBeInTheDocument();
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
// objectui#2600 B5 — curated platform caps get a localized label; package/
|
|
149
|
-
// admin-authored caps keep whatever label the registry served.
|
|
150
|
-
it('localizes curated capability labels but preserves registry labels for others', async () => {
|
|
151
|
-
render(
|
|
152
|
-
<CapabilityMultiSelectField
|
|
153
|
-
value={'[]'}
|
|
154
|
-
onChange={vi.fn()}
|
|
155
|
-
field={{ name: 'system_permissions' } as any}
|
|
156
|
-
dataSource={mockDataSource([
|
|
157
|
-
// Registry sends a shorter label; the curated client map wins.
|
|
158
|
-
{ name: 'manage_org_users', label: 'Manage Org Users', description: 'Org members', scope: 'org', active: true },
|
|
159
|
-
// Not a curated platform capability — its registry label is kept.
|
|
160
|
-
{ name: 'export_data', label: 'Export Data', description: 'Export', scope: 'org', active: true },
|
|
161
|
-
])}
|
|
162
|
-
/>,
|
|
163
|
-
);
|
|
164
|
-
expect(await screen.findByRole('button', { name: 'Manage Organization Users' })).toBeInTheDocument();
|
|
165
|
-
expect(screen.queryByRole('button', { name: 'Manage Org Users' })).not.toBeInTheDocument();
|
|
166
|
-
expect(screen.getByRole('button', { name: 'Export Data' })).toBeInTheDocument();
|
|
167
|
-
});
|
|
168
|
-
});
|