@object-ui/fields 17.2.0 → 17.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1533 -0
- package/README.md +1 -12
- package/dist/FieldEditWidget.d.ts +7 -1
- package/dist/currency.d.ts +46 -0
- package/dist/field-type-alias.d.ts +19 -1
- package/dist/index.cjs +5 -5
- package/dist/index.css +633 -0
- package/dist/index.d.ts +52 -12
- package/dist/index.js +5167 -4759
- package/dist/widgets/AddressField.d.ts +4 -12
- package/dist/widgets/AvatarField.d.ts +1 -1
- package/dist/widgets/CharacterCount.d.ts +56 -0
- package/dist/widgets/CheckboxesField.d.ts +1 -1
- package/dist/widgets/CodeField.d.ts +1 -1
- package/dist/widgets/ColorField.d.ts +1 -1
- package/dist/widgets/CurrencyField.d.ts +1 -1
- package/dist/widgets/EmailField.d.ts +1 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +1 -1
- package/dist/widgets/FullscreenFieldEditor.d.ts +45 -0
- package/dist/widgets/GeolocationField.d.ts +1 -1
- package/dist/widgets/GridField.d.ts +12 -2
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/LocationField.d.ts +1 -1
- package/dist/widgets/LookupField.d.ts +1 -1
- package/dist/widgets/MultiSelectField.d.ts +1 -1
- package/dist/widgets/ObjectField.d.ts +1 -1
- package/dist/widgets/ObjectRefField.d.ts +1 -1
- package/dist/widgets/OptionsEmptyState.d.ts +59 -0
- package/dist/widgets/PercentField.d.ts +1 -1
- package/dist/widgets/PhoneField.d.ts +1 -1
- package/dist/widgets/QRCodeField.d.ts +1 -1
- package/dist/widgets/RadioField.d.ts +1 -1
- package/dist/widgets/RatingField.d.ts +1 -1
- package/dist/widgets/RecipientPickerField.d.ts +1 -1
- package/dist/widgets/RecordPickerDialog.d.ts +49 -7
- package/dist/widgets/RichTextField.d.ts +19 -1
- package/dist/widgets/SelectField.d.ts +25 -5
- package/dist/widgets/TagsField.d.ts +1 -1
- package/dist/widgets/TextAreaField.d.ts +44 -5
- package/dist/widgets/UrlField.d.ts +1 -1
- package/dist/widgets/address-format.d.ts +68 -0
- package/dist/widgets/file-size-guard.d.ts +41 -0
- package/dist/widgets/nativeDateValue.d.ts +37 -0
- package/dist/widgets/toDomProps.d.ts +111 -0
- package/dist/widgets/toHostGroupProps.d.ts +91 -0
- package/dist/widgets/types.d.ts +128 -33
- package/dist/widgets/useRecordQuery.d.ts +7 -1
- package/dist/withFieldCarrier.d.ts +56 -0
- package/package.json +20 -16
- package/src/FieldEditWidget.tsx +17 -1
- package/src/__tests__/AddressCellRenderer.test.tsx +166 -0
- package/src/__tests__/CurrencyField.minorUnits.test.tsx +191 -0
- package/src/__tests__/CurrencyField.symbol.test.tsx +198 -0
- package/src/__tests__/DateCellRenderer.test.tsx +9 -0
- package/src/__tests__/NumberCellRenderer.grouping.test.tsx +146 -0
- package/src/__tests__/PercentCellRenderer.locale.test.tsx +152 -0
- package/src/__tests__/PercentCellRenderer.test.tsx +115 -0
- package/src/__tests__/boolean-label-association-e2e.test.tsx +298 -0
- package/src/__tests__/capability-multiselect-retired.test.ts +109 -0
- package/src/__tests__/cell-truncation.test.tsx +96 -0
- package/src/__tests__/composite-group-label-e2e.test.tsx +436 -0
- package/src/__tests__/composite-group-label-readonly-e2e.test.tsx +403 -0
- package/src/__tests__/date-formatter-residue-4272.test.ts +120 -0
- package/src/__tests__/date-locale-channel.test.tsx +305 -0
- package/src/__tests__/field-carrier-sdui.test.tsx +169 -0
- package/src/__tests__/formatCurrency.centsDigit.test.tsx +158 -0
- package/src/__tests__/formatCurrency.minorUnits.test.tsx +221 -0
- package/src/__tests__/group-labelling-declaration.test.ts +148 -0
- package/src/__tests__/lookupCellRecordLink.test.tsx +171 -0
- package/src/__tests__/percent-cell-vs-measure-4576.test.ts +115 -0
- package/src/__tests__/percent-formatter-locale-4553.test.ts +147 -0
- package/src/__tests__/percent-tie-halfup-4590.test.ts +224 -0
- package/src/__tests__/select-label-association-e2e.test.tsx +199 -0
- package/src/__tests__/spec-symbol-batch7.test.ts +29 -15
- package/src/__tests__/validation-feedback.test.tsx +22 -13
- package/src/__tests__/widget-aria-invalid-e2e.test.tsx +265 -0
- package/src/__tests__/widget-aria-invalid-registry-e2e.test.tsx +338 -0
- package/src/__tests__/widget-dom-leak-e2e.test.tsx +357 -0
- package/src/__tests__/widget-props-contract.test.tsx +22 -9
- package/src/complex-widgets.test.tsx +22 -6
- package/src/currency.ts +130 -0
- package/src/datetime-widgets.test.tsx +94 -2
- package/src/field-type-alias.multiple.test.ts +81 -0
- package/src/field-type-alias.ts +58 -2
- package/src/index.css +44 -0
- package/src/index.tsx +558 -268
- package/src/standard-widgets.test.tsx +8 -1
- package/src/widgets/AddressField.postalCode.test.tsx +257 -0
- package/src/widgets/AddressField.tsx +107 -38
- package/src/widgets/AddressField.uniqueIds.test.tsx +93 -0
- package/src/widgets/AvatarField.tsx +9 -2
- package/src/widgets/BooleanField.tsx +44 -10
- package/src/widgets/CapabilityMultiSelectField.tsx +17 -5
- package/src/widgets/CharacterCount.tsx +263 -0
- package/src/widgets/CheckboxesField.tsx +66 -16
- package/src/widgets/CodeField.tsx +9 -2
- package/src/widgets/ColorField.tsx +11 -2
- package/src/widgets/CurrencyField.tsx +60 -19
- package/src/widgets/DateField.tsx +13 -4
- package/src/widgets/DateTimeField.tsx +14 -5
- package/src/widgets/EmailField.tsx +5 -5
- package/src/widgets/FileField.tsx +39 -11
- package/src/widgets/FilterConditionField.tsx +11 -0
- package/src/widgets/FormulaField.tsx +7 -2
- package/src/widgets/FullscreenFieldEditor.tsx +60 -0
- package/src/widgets/GeolocationField.tsx +49 -8
- package/src/widgets/GeolocationField.uniqueIds.test.tsx +88 -0
- package/src/widgets/GridField.test.tsx +226 -0
- package/src/widgets/GridField.tsx +115 -11
- package/src/widgets/ImageField.maxSize.test.tsx +154 -0
- package/src/widgets/ImageField.tsx +63 -6
- package/src/widgets/LocationField.tsx +9 -2
- package/src/widgets/LookupField.gateHintLabel.test.tsx +135 -0
- package/src/widgets/LookupField.hydration.test.tsx +1 -1
- package/src/widgets/LookupField.tsx +49 -10
- package/src/widgets/MasterDetailField.tsx +1 -1
- package/src/widgets/MultiSelectField.tsx +86 -16
- package/src/widgets/NumberField.tsx +3 -3
- package/src/widgets/ObjectField.tsx +17 -8
- package/src/widgets/ObjectRefField.tsx +10 -0
- package/src/widgets/OptionsEmptyState.no-provider.test.tsx +88 -0
- package/src/widgets/OptionsEmptyState.test.tsx +169 -0
- package/src/widgets/OptionsEmptyState.tsx +99 -0
- package/src/widgets/PasswordField.tsx +3 -3
- package/src/widgets/PeoplePicker.tsx +7 -1
- package/src/widgets/PercentField.tsx +5 -4
- package/src/widgets/PhoneField.tsx +5 -5
- package/src/widgets/QRCodeField.tsx +9 -2
- package/src/widgets/RadioField.tsx +58 -16
- package/src/widgets/RatingField.tsx +32 -4
- package/src/widgets/RecipientPickerField.tsx +18 -0
- package/src/widgets/RecordPickerDialog.baseFilterShapes.test.tsx +191 -0
- package/src/widgets/RecordPickerDialog.dateLocale.test.tsx +124 -0
- package/src/widgets/RecordPickerDialog.filterOptions.test.tsx +611 -0
- package/src/widgets/RecordPickerDialog.selectLabel.test.tsx +112 -0
- package/src/widgets/RecordPickerDialog.tsx +235 -21
- package/src/widgets/RichTextField.tsx +151 -24
- package/src/widgets/SelectField.tsx +84 -22
- package/src/widgets/SliderField.tsx +1 -1
- package/src/widgets/SummaryField.tsx +1 -1
- package/src/widgets/TagsField.placeholder.no-provider.test.tsx +68 -0
- package/src/widgets/TagsField.placeholder.test.tsx +95 -0
- package/src/widgets/TagsField.tsx +23 -2
- package/src/widgets/TextAreaField.tsx +171 -93
- package/src/widgets/TextField.tsx +3 -3
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +5 -5
- package/src/widgets/UserField.tsx +4 -3
- package/src/widgets/VectorField.tsx +1 -1
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +25 -1
- package/src/widgets/__tests__/FullscreenFieldEditor.i18n.test.tsx +248 -0
- package/src/widgets/__tests__/FullscreenFieldEditor.no-provider.test.tsx +116 -0
- package/src/widgets/__tests__/RichTextField.fullscreenDisabled.test.tsx +244 -0
- package/src/widgets/__tests__/RichTextField.mobileFullscreen.test.tsx +195 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.announcements.test.tsx +417 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.i18n.test.tsx +287 -0
- package/src/widgets/__tests__/TextAreaField.characterCount.no-provider.test.tsx +160 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.i18n.test.tsx +161 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenCharacterCount.test.tsx +346 -0
- package/src/widgets/__tests__/TextAreaField.fullscreenDisabled.test.tsx +246 -0
- package/src/widgets/__tests__/TextAreaField.mobileFullscreen.test.tsx +175 -0
- package/src/widgets/address-format.ts +120 -0
- package/src/widgets/file-size-guard.ts +55 -0
- package/src/widgets/nativeDateValue.test.ts +115 -0
- package/src/widgets/nativeDateValue.ts +112 -0
- package/src/widgets/optionWidgets.gatedOptions.test.tsx +267 -0
- package/src/widgets/optionWidgets.unconfiguredOptions.test.tsx +150 -0
- package/src/widgets/toDomProps.ts +204 -0
- package/src/widgets/toHostGroupProps.ts +102 -0
- package/src/widgets/types.ts +135 -35
- package/src/widgets/useFieldTranslation.ts +46 -4
- package/src/widgets/useRecordQuery.ts +30 -3
- package/src/withFieldCarrier.tsx +75 -0
- package/dist/FieldEditWidget.test.d.ts +0 -8
- package/dist/__tests__/DateCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/NumberCellRenderer.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayField.test.d.ts +0 -0
- package/dist/__tests__/lookupCellDisplayName.test.d.ts +0 -0
- package/dist/__tests__/lookupCellReferenceKey.test.d.ts +0 -0
- package/dist/__tests__/registry-collision.test.d.ts +0 -0
- package/dist/__tests__/resolveFieldCurrency.test.d.ts +0 -1
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +0 -8
- package/dist/__tests__/validation-feedback.test.d.ts +0 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +0 -8
- package/dist/coerce-safe-value.test.d.ts +0 -1
- package/dist/complex-widgets.test.d.ts +0 -1
- package/dist/datetime-widgets.test.d.ts +0 -1
- package/dist/field-type-coverage.test.d.ts +0 -1
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/widgets/CapabilityMultiSelectField.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/GridField.test.d.ts +0 -1
- package/dist/widgets/ImageLightbox.test.d.ts +0 -1
- package/dist/widgets/LookupField.dependsOn.test.d.ts +0 -8
- package/dist/widgets/LookupField.hydration.test.d.ts +0 -1
- package/dist/widgets/LookupField.idField.test.d.ts +0 -1
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/PeoplePicker.test.d.ts +0 -8
- package/dist/widgets/PeoplePickerComponents.test.d.ts +0 -8
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/UserFieldPicker.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +0 -8
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +0 -8
- package/dist/widgets/deriveLookupColumns.test.d.ts +0 -1
- package/dist/widgets/file-value.test.d.ts +0 -1
- package/dist/widgets/personDisplay.test.d.ts +0 -8
- package/dist/widgets/recentLookups.test.d.ts +0 -1
- package/dist/widgets/useRecordQuery.test.d.ts +0 -8
- package/dist/widgets/useUploadingSignal.test.d.ts +0 -1
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
* `TextAreaField`'s fullscreen affordance has ONE source (objectui#3232).
|
|
11
|
+
*
|
|
12
|
+
* It used to be read from four places with a `??` chain — a `mobileFullscreen`
|
|
13
|
+
* (camelCase) prop, `field.mobile_fullscreen`, a `mobile_fullscreen` prop, and
|
|
14
|
+
* `schema.mobile_fullscreen`. Only the field-metadata flag was ever produced:
|
|
15
|
+
* `ObjectForm` stamps it on long-text fields from
|
|
16
|
+
* `ObjectFormSchema.mobile.fullscreenLongText`. The camelCase prop had no
|
|
17
|
+
* producer anywhere in the repo, and the snake_case prop could not arrive
|
|
18
|
+
* because `stripRegisteredFieldProps` (components/src/renderers/form/form.tsx)
|
|
19
|
+
* removes it from what registered widgets are forwarded.
|
|
20
|
+
*
|
|
21
|
+
* So this file pins both halves of that convergence:
|
|
22
|
+
*
|
|
23
|
+
* 1. **The live path still works.** The metadata flag drives the affordance
|
|
24
|
+
* end to end — button, dialog, and the committed edit. Deleting the dead
|
|
25
|
+
* reads must not have cost the working behaviour, and this is the test
|
|
26
|
+
* that fails if a future refactor drops the real read too.
|
|
27
|
+
* 2. **The retired prop spellings are gone**, at compile time (they are not
|
|
28
|
+
* on the closed `FieldWidgetComponentProps`, objectui#3221) and at runtime
|
|
29
|
+
* (a host that passes one anyway gets no affordance). Convergence means a
|
|
30
|
+
* misspelled flag is now inert and loud, not silently absorbed.
|
|
31
|
+
*
|
|
32
|
+
* Note the built-in (unregistered) `textarea` branch in `form.tsx` reads
|
|
33
|
+
* `mobile_fullscreen || fullscreen` off the form-field props and renders its
|
|
34
|
+
* own `FullscreenTextarea`. That is a separate, live path and is untouched
|
|
35
|
+
* here — this file is about the registered `field:textarea` widget only.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
39
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
40
|
+
import '@testing-library/jest-dom';
|
|
41
|
+
|
|
42
|
+
import { TextAreaField } from '../TextAreaField';
|
|
43
|
+
import type { FieldWidgetComponentProps } from '../types';
|
|
44
|
+
import type { FieldMetadata } from '@object-ui/types';
|
|
45
|
+
|
|
46
|
+
const fieldMeta = (extra: Record<string, unknown> = {}) =>
|
|
47
|
+
({
|
|
48
|
+
name: 'description',
|
|
49
|
+
label: 'Description',
|
|
50
|
+
type: 'textarea',
|
|
51
|
+
...extra,
|
|
52
|
+
}) as unknown as FieldMetadata;
|
|
53
|
+
|
|
54
|
+
describe('TextAreaField mobile fullscreen — the metadata flag is the only source', () => {
|
|
55
|
+
it('renders the expand affordance when the field metadata sets mobile_fullscreen', () => {
|
|
56
|
+
render(
|
|
57
|
+
<TextAreaField
|
|
58
|
+
value="hello"
|
|
59
|
+
onChange={() => {}}
|
|
60
|
+
field={fieldMeta({ mobile_fullscreen: true })}
|
|
61
|
+
/>,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(screen.getByTestId('textarea-fullscreen-toggle')).toBeInTheDocument();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('opens the fullscreen dialog and commits the edited draft', () => {
|
|
68
|
+
const onChange = vi.fn();
|
|
69
|
+
render(
|
|
70
|
+
<TextAreaField
|
|
71
|
+
value="hello"
|
|
72
|
+
onChange={onChange}
|
|
73
|
+
field={fieldMeta({ mobile_fullscreen: true })}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Closed until the affordance is used — the dialog is not just mounted.
|
|
78
|
+
expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
|
|
79
|
+
|
|
80
|
+
fireEvent.click(screen.getByTestId('textarea-fullscreen-toggle'));
|
|
81
|
+
expect(screen.getByTestId('textarea-fullscreen-dialog')).toBeInTheDocument();
|
|
82
|
+
|
|
83
|
+
const dialogInput = screen.getByTestId('textarea-fullscreen-input');
|
|
84
|
+
expect(dialogInput).toHaveValue('hello');
|
|
85
|
+
|
|
86
|
+
fireEvent.change(dialogInput, { target: { value: 'hello from fullscreen' } });
|
|
87
|
+
// The draft is local until "Done" — the host is not notified per keystroke.
|
|
88
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
89
|
+
|
|
90
|
+
fireEvent.click(screen.getByTestId('textarea-fullscreen-save'));
|
|
91
|
+
expect(onChange).toHaveBeenCalledWith('hello from fullscreen');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('no longer reads the flag off a `schema` prop (objectui#3233)', () => {
|
|
95
|
+
// This used to be the second carrier: `SchemaRenderer` passed the authored
|
|
96
|
+
// node as `schema` and the widget resolved `field || schema`. v17 converged
|
|
97
|
+
// that at the producer — the registry adapter (`withFieldCarrier`) maps the
|
|
98
|
+
// SDUI node onto `field` before any widget sees it, so the widget has one
|
|
99
|
+
// read. A host that still passes `schema` is now inert AND loud: a compile
|
|
100
|
+
// error on the closed props type, and no affordance at runtime.
|
|
101
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
102
|
+
try {
|
|
103
|
+
const legacyHostProps = {
|
|
104
|
+
schema: fieldMeta({ mobile_fullscreen: true }),
|
|
105
|
+
} as unknown as Partial<FieldWidgetComponentProps<string>>;
|
|
106
|
+
|
|
107
|
+
render(
|
|
108
|
+
<TextAreaField
|
|
109
|
+
value=""
|
|
110
|
+
onChange={() => {}}
|
|
111
|
+
field={fieldMeta()}
|
|
112
|
+
{...legacyHostProps}
|
|
113
|
+
/>,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
|
|
117
|
+
} finally {
|
|
118
|
+
consoleError.mockRestore();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('renders no affordance when the metadata does not opt in', () => {
|
|
123
|
+
render(<TextAreaField value="hello" onChange={() => {}} field={fieldMeta()} />);
|
|
124
|
+
|
|
125
|
+
expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
|
|
126
|
+
expect(screen.queryByTestId('textarea-fullscreen-dialog')).not.toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('TextAreaField mobile fullscreen — the retired prop spellings', () => {
|
|
131
|
+
it('does not declare them on the widget contract', () => {
|
|
132
|
+
const props = {} as FieldWidgetComponentProps<string>;
|
|
133
|
+
|
|
134
|
+
// Both were read by the widget and written by no host. With the closed
|
|
135
|
+
// props type (objectui#3221) they are now compile errors, so a future
|
|
136
|
+
// author reaches for the metadata flag instead of a silently ignored prop.
|
|
137
|
+
// @ts-expect-error `mobileFullscreen` is not part of this contract
|
|
138
|
+
void props.mobileFullscreen;
|
|
139
|
+
// @ts-expect-error `mobile_fullscreen` is a metadata key, not a widget prop
|
|
140
|
+
void props.mobile_fullscreen;
|
|
141
|
+
// …and the retired second METADATA carrier (objectui#3233). The flag has
|
|
142
|
+
// one source and that source now has one carrier.
|
|
143
|
+
// @ts-expect-error `schema` was retired from the widget contract in v17
|
|
144
|
+
void props.schema;
|
|
145
|
+
|
|
146
|
+
expect(true).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('ignores them at runtime when a host passes them anyway', () => {
|
|
150
|
+
// Untyped hosts (plain JS, `as any` spreads) can still get these onto the
|
|
151
|
+
// element. They must not resurrect the affordance: the metadata flag is
|
|
152
|
+
// the contract. Unknown props land on the DOM spread, so React's unknown
|
|
153
|
+
// -attribute warnings are expected noise here and are suppressed.
|
|
154
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
155
|
+
try {
|
|
156
|
+
const hostProps = {
|
|
157
|
+
mobileFullscreen: true,
|
|
158
|
+
mobile_fullscreen: true,
|
|
159
|
+
} as unknown as Partial<FieldWidgetComponentProps<string>>;
|
|
160
|
+
|
|
161
|
+
render(
|
|
162
|
+
<TextAreaField
|
|
163
|
+
value="hello"
|
|
164
|
+
onChange={() => {}}
|
|
165
|
+
field={fieldMeta()}
|
|
166
|
+
{...hostProps}
|
|
167
|
+
/>,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(screen.queryByTestId('textarea-fullscreen-toggle')).not.toBeInTheDocument();
|
|
171
|
+
} finally {
|
|
172
|
+
consoleError.mockRestore();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
* The address value shape, and the ONE formatting rule every address surface
|
|
11
|
+
* renders it with.
|
|
12
|
+
*
|
|
13
|
+
* This module exists because the rule had exactly one implementation and only
|
|
14
|
+
* one consumer could reach it (objectui#4037). `AddressField`'s readonly branch
|
|
15
|
+
* collapsed a stored address to a formatted line, while the display (read)
|
|
16
|
+
* registry the detail page uses mapped `address` straight to `JsonCellRenderer`
|
|
17
|
+
* — so the same stored value rendered as `Street, City, State ZIP, Country` in
|
|
18
|
+
* a readonly form and as `{"street":"…","city":"…"}` on the detail page.
|
|
19
|
+
*
|
|
20
|
+
* The fix keeps ONE definition rather than a second copy next to the renderer:
|
|
21
|
+
* a duplicated rule is a rule that drifts, and two spellings of the same
|
|
22
|
+
* address on two surfaces of one app is the very failure the issue reports.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately pure — no React, no component imports — so the eager
|
|
25
|
+
* `@object-ui/fields` barrel can use it for cell rendering without pulling
|
|
26
|
+
* `AddressField` (and its inputs) out of the lazily-loaded widget chunk.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { AddressValue } from '@objectstack/spec/data';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Address data structure — `z.input` of `@objectstack/spec`'s
|
|
33
|
+
* `AddressValueSchema`, which is what the platform stores and what
|
|
34
|
+
* `/api/v1/data/**` serves back.
|
|
35
|
+
*
|
|
36
|
+
* The postal code is spelled `postalCode` (objectstack#5143). `AddressField`
|
|
37
|
+
* used to read and write `zipCode`, a key that appears nowhere in the spec, so
|
|
38
|
+
* the stored postal code never reached the input — and whatever the user then
|
|
39
|
+
* typed into that apparently-empty box was written under a key
|
|
40
|
+
* `AddressValueSchema` strips: lost outright on a new record, and on an
|
|
41
|
+
* existing one silently discarded while the stale stored value survived. (A
|
|
42
|
+
* postal code nobody touched did survive an unrelated edit, because the write
|
|
43
|
+
* spread the whole stored object through; the issue's account of that half is
|
|
44
|
+
* corrected in `AddressField.postalCode.test.tsx`.) One name, on both sides:
|
|
45
|
+
* the contract's.
|
|
46
|
+
*
|
|
47
|
+
* IMPORTED rather than declared since objectui#4167. rc.6 publishes
|
|
48
|
+
* `AddressValue` under this exact name, and the local copy — whose own comment
|
|
49
|
+
* already claimed to be "the part names of `AddressSchema`" — declared five of
|
|
50
|
+
* the seven parts. `countryCode` and `formatted` were missing, which is
|
|
51
|
+
* objectstack#4115's failure class precisely: a canonical CLAIM over a narrower
|
|
52
|
+
* shape, believed by the next reader. The claim is now the code.
|
|
53
|
+
*
|
|
54
|
+
* `AddressField` still renders five inputs and {@link formatAddress} still
|
|
55
|
+
* composes five parts, and that is a deliberate split rather than an omission:
|
|
56
|
+
* `formatted` is a derived one-line rendering of the parts (what this module
|
|
57
|
+
* computes) and `countryCode` is the ISO pair for `country`, neither of which a
|
|
58
|
+
* human types into a part box. Binding the type does not publish them as
|
|
59
|
+
* inputs — it stops the type from asserting the platform cannot store them, and
|
|
60
|
+
* it means a write from the widget no longer looks type-clean while dropping a
|
|
61
|
+
* key the contract carries: the `{ ...address }` spread in `handleFieldChange`
|
|
62
|
+
* preserves both, and now says so.
|
|
63
|
+
*
|
|
64
|
+
* The declaration moved here (objectui#4037) so the display cell renderer and
|
|
65
|
+
* the widget share one definition; the SOURCE of that definition is the spec.
|
|
66
|
+
*/
|
|
67
|
+
export type { AddressValue };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The shape written by builds up to and including 17.0.0-rc.1, whose postal
|
|
71
|
+
* code landed under `zipCode` (objectstack#5143). Read-time compatibility ONLY,
|
|
72
|
+
* and deliberately not part of {@link AddressValue}: authoring code must not be
|
|
73
|
+
* able to spell `zipCode`, and nothing here ever writes it back — the first
|
|
74
|
+
* edit of any part normalizes the record onto `postalCode` (see
|
|
75
|
+
* `AddressField`'s `handleFieldChange`). This is not a producer alias to be
|
|
76
|
+
* honoured forever; it exists to carry data THIS widget mis-wrote, and can go
|
|
77
|
+
* once no such data remains.
|
|
78
|
+
*/
|
|
79
|
+
export type LegacyAddressValue = AddressValue & { zipCode?: string };
|
|
80
|
+
|
|
81
|
+
/** Postal code of a stored address, preferring the canonical key. */
|
|
82
|
+
export function readPostalCode(addr: AddressValue): string | undefined {
|
|
83
|
+
return addr.postalCode ?? (addr as LegacyAddressValue).zipCode;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One address part, normalized for joining: absent, non-string and
|
|
88
|
+
* whitespace-only parts all collapse to `''` so the caller's `filter(Boolean)`
|
|
89
|
+
* drops them. This is what keeps a partial address free of dangling separators
|
|
90
|
+
* and of the string `undefined`.
|
|
91
|
+
*/
|
|
92
|
+
function part(value: unknown): string {
|
|
93
|
+
if (value == null) return '';
|
|
94
|
+
return String(value).trim();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Formats a stored address as a single line: `Street, City, State ZIP, Country`.
|
|
99
|
+
*
|
|
100
|
+
* Ordering and separators are NOT invented here — they are the rule
|
|
101
|
+
* `AddressField` already applied in its readonly branch, and the sub-field set
|
|
102
|
+
* its inputs expose (street / city / state / postalCode / country). Missing
|
|
103
|
+
* parts are dropped rather than spaced over, so a street-only address is
|
|
104
|
+
* `"中策路 1 号"` and never `", , ,"`.
|
|
105
|
+
*
|
|
106
|
+
* Returns `''` when no part is usable — callers decide what an empty address
|
|
107
|
+
* looks like (an `EmptyValue` placeholder for the cell renderer), and a value
|
|
108
|
+
* carrying no recognized part at all is NOT silently blanked: see
|
|
109
|
+
* `AddressCellRenderer`, which falls back to JSON so unknown data stays visible.
|
|
110
|
+
*/
|
|
111
|
+
export function formatAddress(addr: AddressValue): string {
|
|
112
|
+
// State and postal code share one comma-delimited group, space-separated
|
|
113
|
+
// inside it ("CA 94102"); either one alone occupies the group by itself.
|
|
114
|
+
const stateAndPostal = [part(addr.state), part(readPostalCode(addr))]
|
|
115
|
+
.filter(Boolean)
|
|
116
|
+
.join(' ');
|
|
117
|
+
return [part(addr.street), part(addr.city), stateAndPostal, part(addr.country)]
|
|
118
|
+
.filter(Boolean)
|
|
119
|
+
.join(', ');
|
|
120
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side `maxSize` guard shared by the file and image widgets
|
|
3
|
+
* (objectui#4141).
|
|
4
|
+
*
|
|
5
|
+
* Why this is shared rather than mirrored per widget: the guard is the only
|
|
6
|
+
* thing standing between a declared `maxSize` and a real `presigned → PUT →
|
|
7
|
+
* complete` round trip, and `paramToField` copies `maxSize` onto the field
|
|
8
|
+
* config for **every** param type regardless of which widget ends up rendering
|
|
9
|
+
* it. So a widget that re-derives the check drifts silently — the image param
|
|
10
|
+
* shipped receiving the constraint and never reading it, while the file param
|
|
11
|
+
* rejected the identical pick, and nothing failed until QA drove the two side by
|
|
12
|
+
* side. One exported function makes the difference visible: a widget either
|
|
13
|
+
* calls it or conspicuously does not.
|
|
14
|
+
*
|
|
15
|
+
* Both widgets deliberately share the `fields.file.exceedsMaxSize` key too. The
|
|
16
|
+
* sentence names a file and a limit and says nothing file-widget-specific, it is
|
|
17
|
+
* already translated in all 11 built-in locales, and a parallel `fields.image.*`
|
|
18
|
+
* twin would be the same sentence maintained twice — drifting the moment one
|
|
19
|
+
* translator rewords one of them.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Minimal shape of the i18next `t` this module needs, matching the established
|
|
24
|
+
* `TranslateFn` pattern in `app-shell/src/providers/writeWarningToast`.
|
|
25
|
+
*/
|
|
26
|
+
export type TranslateFn = (key: string, options?: Record<string, unknown>) => string;
|
|
27
|
+
|
|
28
|
+
/** Divisor for rendering a byte limit as MB, matching the widgets' size display. */
|
|
29
|
+
const BYTES_PER_MB = 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The rejection message for a pick that exceeds `maxSize`, or `null` when the
|
|
33
|
+
* pick is within the limit — so a caller reads it as `if (err) reject`.
|
|
34
|
+
*
|
|
35
|
+
* A falsy `maxSize` (undeclared, or an explicit `0`) means **unrestricted**,
|
|
36
|
+
* preserving the `if (maxSize && …)` semantics the file widget has always
|
|
37
|
+
* applied: an undeclared limit must never turn into a 0-byte one.
|
|
38
|
+
*
|
|
39
|
+
* Takes `name` and `size` separately instead of a `File` because the image
|
|
40
|
+
* widget's crop path validates a re-encoded `Blob`, which carries no name of its
|
|
41
|
+
* own — that name comes from the cropper's output.
|
|
42
|
+
*/
|
|
43
|
+
export function maxSizeError(
|
|
44
|
+
t: TranslateFn,
|
|
45
|
+
pick: { name: string; size: number },
|
|
46
|
+
maxSize: number | undefined,
|
|
47
|
+
): string | null {
|
|
48
|
+
if (!maxSize || pick.size <= maxSize) return null;
|
|
49
|
+
const maxMB = (maxSize / BYTES_PER_MB).toFixed(1);
|
|
50
|
+
return t('fields.file.exceedsMaxSize', {
|
|
51
|
+
defaultValue: `"${pick.name}" exceeds max size (${maxMB} MB)`,
|
|
52
|
+
name: pick.name,
|
|
53
|
+
max: maxMB,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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 { describe, it, expect } from 'vitest';
|
|
10
|
+
import { InstantValueSchema } from '@objectstack/spec/data';
|
|
11
|
+
import {
|
|
12
|
+
toDateInputValue,
|
|
13
|
+
toDateTimeInputValue,
|
|
14
|
+
fromDateTimeInputValue,
|
|
15
|
+
} from './nativeDateValue';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* These assertions are deliberately written to hold in EVERY timezone, because
|
|
19
|
+
* the bug they pin (objectui#3127) is a timezone-shaped one and a test that
|
|
20
|
+
* only passes in UTC would go green on CI while the defect it guards is live
|
|
21
|
+
* for every user east or west of it.
|
|
22
|
+
*
|
|
23
|
+
* The trick: never hardcode the expected wall clock. Assert the SHAPE the
|
|
24
|
+
* native control demands, and assert that reading that shape back (as local
|
|
25
|
+
* time, which is how both the browser and `new Date` read a zone-less
|
|
26
|
+
* datetime) lands on the same INSTANT we started from.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const localInstantOf = (bare: string) => new Date(bare).getTime();
|
|
30
|
+
|
|
31
|
+
describe('toDateInputValue', () => {
|
|
32
|
+
it('keeps a leading YYYY-MM-DD verbatim, in any timezone', () => {
|
|
33
|
+
// Re-parsing would read this as UTC midnight and hand back the 16th
|
|
34
|
+
// anywhere west of Greenwich. The calendar day is the value here.
|
|
35
|
+
expect(toDateInputValue('2026-06-17')).toBe('2026-06-17');
|
|
36
|
+
expect(toDateInputValue('2026-06-17T14:30:00.000Z')).toBe('2026-06-17');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('formats a Date to the local calendar day', () => {
|
|
40
|
+
const d = new Date(2026, 5, 17, 23, 59);
|
|
41
|
+
expect(toDateInputValue(d)).toBe('2026-06-17');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('maps empty and unparseable input to an empty control', () => {
|
|
45
|
+
expect(toDateInputValue(null)).toBe('');
|
|
46
|
+
expect(toDateInputValue(undefined)).toBe('');
|
|
47
|
+
expect(toDateInputValue('')).toBe('');
|
|
48
|
+
expect(toDateInputValue('not a date')).toBe('');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('toDateTimeInputValue', () => {
|
|
53
|
+
it('renders a UTC ISO instant as a local wall clock the control accepts', () => {
|
|
54
|
+
const iso = '2026-06-17T14:30:00.000Z';
|
|
55
|
+
const shown = toDateTimeInputValue(iso);
|
|
56
|
+
|
|
57
|
+
// 1. The shape `datetime-local` demands — the whole point of #3127.
|
|
58
|
+
expect(shown).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
|
59
|
+
// 2. …denoting the SAME instant the record holds.
|
|
60
|
+
expect(localInstantOf(shown)).toBe(new Date(iso).getTime());
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('truncates an already-local datetime to minutes without re-parsing it', () => {
|
|
64
|
+
expect(toDateTimeInputValue('2026-06-17T14:30')).toBe('2026-06-17T14:30');
|
|
65
|
+
expect(toDateTimeInputValue('2026-06-17T14:30:59')).toBe('2026-06-17T14:30');
|
|
66
|
+
expect(toDateTimeInputValue('2026-06-17T14:30:59.123')).toBe('2026-06-17T14:30');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('maps empty and unparseable input to an empty control', () => {
|
|
70
|
+
expect(toDateTimeInputValue(null)).toBe('');
|
|
71
|
+
expect(toDateTimeInputValue(undefined)).toBe('');
|
|
72
|
+
expect(toDateTimeInputValue('')).toBe('');
|
|
73
|
+
expect(toDateTimeInputValue('not a date')).toBe('');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('fromDateTimeInputValue', () => {
|
|
78
|
+
it('reads the control back on the same basis it was written', () => {
|
|
79
|
+
const local = '2026-06-17T14:30';
|
|
80
|
+
const stored = fromDateTimeInputValue(local);
|
|
81
|
+
|
|
82
|
+
expect(stored).toMatch(/Z$/);
|
|
83
|
+
expect(new Date(stored).getTime()).toBe(localInstantOf(local));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('round-trips an ISO instant through the control unchanged', () => {
|
|
87
|
+
// The invariant that makes the read/write pair safe: opening an editor and
|
|
88
|
+
// re-picking the same minute must not move the record.
|
|
89
|
+
const iso = '2026-06-17T14:30:00.000Z';
|
|
90
|
+
expect(fromDateTimeInputValue(toDateTimeInputValue(iso))).toBe(iso);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns an unparseable value untouched rather than blanking it', () => {
|
|
94
|
+
expect(fromDateTimeInputValue('garbage')).toBe('garbage');
|
|
95
|
+
expect(fromDateTimeInputValue('')).toBe('');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The producer side of objectstack#5061. This function's output is not just
|
|
100
|
+
* "an ISO string" by convention — it IS the platform's `datetime` value
|
|
101
|
+
* contract, and `ActionParamDialog` now relies on that: it POSTs the widget's
|
|
102
|
+
* value with no conversion of its own, so a change here that dropped the zone
|
|
103
|
+
* (or emitted the control's naive wall clock) would make every datetime action
|
|
104
|
+
* param 400 again. Pinned against the real schema rather than a regex so the
|
|
105
|
+
* two cannot drift.
|
|
106
|
+
*/
|
|
107
|
+
it('emits a value the platform datetime contract accepts (InstantValueSchema)', () => {
|
|
108
|
+
expect(InstantValueSchema.safeParse(fromDateTimeInputValue('2026-06-17T14:30')).success).toBe(true);
|
|
109
|
+
// The control may include seconds; those must survive into the instant.
|
|
110
|
+
expect(InstantValueSchema.safeParse(fromDateTimeInputValue('2026-06-17T14:30:45')).success).toBe(true);
|
|
111
|
+
// The shape the contract rejects — what the control itself hands us, and so
|
|
112
|
+
// exactly what must NOT come back out of this function.
|
|
113
|
+
expect(InstantValueSchema.safeParse('2026-06-17T14:30').success).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
* Value adapters for the native date/time controls (objectui#3127).
|
|
11
|
+
*
|
|
12
|
+
* ## The defect these close
|
|
13
|
+
*
|
|
14
|
+
* `<input type="date">` accepts exactly `YYYY-MM-DD` and
|
|
15
|
+
* `<input type="datetime-local">` exactly `YYYY-MM-DDTHH:mm[:ss]` — a **local
|
|
16
|
+
* wall clock with no zone suffix**. Anything else is not "coerced" or
|
|
17
|
+
* "warned about": the browser SILENTLY rejects it. The attribute still lands
|
|
18
|
+
* in the DOM, so the value looks present to anyone inspecting markup, while
|
|
19
|
+
* `input.value` reads back `""` and the control paints its empty placeholder:
|
|
20
|
+
*
|
|
21
|
+
* ```js
|
|
22
|
+
* el.getAttribute('value') // "2026-08-16T00:33:00.000Z" ← the record's value
|
|
23
|
+
* el.value // "" ← what the user sees
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Records come back from the API as full ISO-8601 (`…T00:33:00.000Z`) — which
|
|
27
|
+
* is precisely one of those rejected shapes. So EVERY datetime field in an
|
|
28
|
+
* edit dialog rendered blank while holding a real value, and users read that
|
|
29
|
+
* as "the date was lost" and retyped it (which is the only way the display bug
|
|
30
|
+
* could turn into a data change).
|
|
31
|
+
*
|
|
32
|
+
* ## Why a round trip, and not just a formatter
|
|
33
|
+
*
|
|
34
|
+
* Formatting on the way IN without converting on the way OUT would be the
|
|
35
|
+
* worse bug: the control would then be read as UTC but written back as a naive
|
|
36
|
+
* local string, so a user in UTC+8 who picked "08:33" would store 08:33Z and
|
|
37
|
+
* see 16:33 on the detail page. Read basis and write basis must be the same
|
|
38
|
+
* one, so `toDateTimeInputValue` / `fromDateTimeInputValue` are a pair and are
|
|
39
|
+
* used as a pair.
|
|
40
|
+
*
|
|
41
|
+
* ## Shape kept in form state
|
|
42
|
+
*
|
|
43
|
+
* `datetime` round-trips through ISO — the shape the API hands us and the one
|
|
44
|
+
* display/format code already assumes. `date` stays a plain `YYYY-MM-DD`
|
|
45
|
+
* string, matching what the inline cell editor in `@object-ui/components`
|
|
46
|
+
* (`data-table.tsx`) and `@object-ui/plugin-detail`'s `InlineFieldInput`
|
|
47
|
+
* already do. Untouched fields are never re-emitted, so a form that opens and
|
|
48
|
+
* closes without an edit still submits nothing for them.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
function pad2(n: number): string {
|
|
52
|
+
return String(n).padStart(2, '0');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Stored value → `<input type="date">` value (`YYYY-MM-DD`).
|
|
57
|
+
*
|
|
58
|
+
* A leading `YYYY-MM-DD` is passed through VERBATIM rather than re-parsed: a
|
|
59
|
+
* date-only string is parsed as UTC midnight by `new Date()`, so reading local
|
|
60
|
+
* calendar components back out of it shifts the day by one everywhere west of
|
|
61
|
+
* Greenwich. `2026-06-17` must stay June 17th in every zone.
|
|
62
|
+
*/
|
|
63
|
+
export function toDateInputValue(value: unknown): string {
|
|
64
|
+
if (value == null || value === '') return '';
|
|
65
|
+
if (typeof value === 'string') {
|
|
66
|
+
const m = value.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
67
|
+
if (m) return m[1];
|
|
68
|
+
}
|
|
69
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
70
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
71
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Stored value → `<input type="datetime-local">` value (`YYYY-MM-DDTHH:mm`),
|
|
76
|
+
* expressed in the viewer's local wall clock — the same instant the detail
|
|
77
|
+
* page shows, so opening the editor never appears to move the time.
|
|
78
|
+
*
|
|
79
|
+
* A value that is ALREADY a zone-less local datetime is truncated to minutes
|
|
80
|
+
* in place instead of being round-tripped through `Date`, which would
|
|
81
|
+
* reinterpret it and then re-render it, twice risking an off-by-a-zone shift.
|
|
82
|
+
*/
|
|
83
|
+
export function toDateTimeInputValue(value: unknown): string {
|
|
84
|
+
if (value == null || value === '') return '';
|
|
85
|
+
if (typeof value === 'string') {
|
|
86
|
+
const local = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2}(?:\.\d+)?)?$/);
|
|
87
|
+
if (local) return `${local[1]}T${local[2]}`;
|
|
88
|
+
}
|
|
89
|
+
const d = value instanceof Date ? value : new Date(String(value));
|
|
90
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
91
|
+
return (
|
|
92
|
+
`${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +
|
|
93
|
+
`T${pad2(d.getHours())}:${pad2(d.getMinutes())}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* `<input type="datetime-local">` value → stored value (ISO-8601, UTC).
|
|
99
|
+
*
|
|
100
|
+
* The control hands us a zone-less local wall clock; `new Date()` reads that
|
|
101
|
+
* form as local time, which is the inverse of what `toDateTimeInputValue`
|
|
102
|
+
* wrote — the pair is what keeps read and write on one basis.
|
|
103
|
+
*
|
|
104
|
+
* An unparseable string is returned untouched rather than blanked: dropping a
|
|
105
|
+
* value the user can see in the box would be a data loss dressed up as a fix.
|
|
106
|
+
*/
|
|
107
|
+
export function fromDateTimeInputValue(value: string): string {
|
|
108
|
+
if (!value) return '';
|
|
109
|
+
const d = new Date(value);
|
|
110
|
+
if (Number.isNaN(d.getTime())) return value;
|
|
111
|
+
return d.toISOString();
|
|
112
|
+
}
|