@object-ui/fields 16.1.0 → 17.2.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 +626 -0
- package/dist/FieldEditWidget.d.ts +9 -2
- package/dist/__tests__/spec-symbol-batch7.test.d.ts +8 -0
- package/dist/__tests__/widget-props-contract.test.d.ts +8 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +14 -11
- package/dist/index.js +2426 -2100
- package/dist/widgets/AddressField.d.ts +2 -2
- package/dist/widgets/AutoNumberField.d.ts +2 -2
- package/dist/widgets/AvatarField.d.ts +2 -2
- package/dist/widgets/BooleanField.d.ts +2 -2
- package/dist/widgets/CapabilityMultiSelectField.d.ts +2 -2
- package/dist/widgets/CheckboxesField.d.ts +2 -2
- package/dist/widgets/CodeField.d.ts +2 -2
- package/dist/widgets/ColorField.d.ts +2 -2
- package/dist/widgets/CurrencyField.d.ts +2 -2
- package/dist/widgets/DateField.d.ts +2 -2
- package/dist/widgets/DateTimeField.d.ts +2 -2
- package/dist/widgets/EmailField.d.ts +2 -2
- package/dist/widgets/FileField.d.ts +2 -2
- package/dist/widgets/FilterConditionField.d.ts +34 -2
- package/dist/widgets/FormulaField.d.ts +2 -2
- package/dist/widgets/GeolocationField.d.ts +2 -2
- package/dist/widgets/GridField.d.ts +2 -2
- package/dist/widgets/ImageField.d.ts +2 -2
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LocationField.d.ts +2 -2
- package/dist/widgets/LookupField.d.ts +2 -2
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MasterDetailField.d.ts +2 -2
- package/dist/widgets/MultiSelectField.d.ts +2 -2
- package/dist/widgets/NumberField.d.ts +2 -2
- package/dist/widgets/ObjectField.d.ts +2 -2
- package/dist/widgets/ObjectRefField.d.ts +2 -2
- package/dist/widgets/PasswordField.d.ts +2 -2
- package/dist/widgets/PercentField.d.ts +2 -2
- package/dist/widgets/PhoneField.d.ts +2 -2
- package/dist/widgets/QRCodeField.d.ts +2 -2
- package/dist/widgets/RadioField.d.ts +2 -2
- package/dist/widgets/RatingField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.d.ts +2 -2
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/RichTextField.d.ts +2 -2
- package/dist/widgets/SelectField.d.ts +2 -2
- package/dist/widgets/SignatureField.d.ts +2 -2
- package/dist/widgets/SliderField.d.ts +2 -2
- package/dist/widgets/SummaryField.d.ts +2 -2
- package/dist/widgets/TagsField.d.ts +2 -2
- package/dist/widgets/TextAreaField.d.ts +2 -2
- package/dist/widgets/TextField.d.ts +2 -2
- package/dist/widgets/TimeField.d.ts +2 -2
- package/dist/widgets/UrlField.d.ts +2 -2
- package/dist/widgets/UserField.d.ts +2 -2
- package/dist/widgets/VectorField.d.ts +2 -2
- package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
- package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
- package/dist/widgets/file-value.d.ts +99 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +119 -3
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/package.json +9 -8
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +50 -8
- package/src/__tests__/spec-symbol-batch7.test.ts +112 -0
- package/src/__tests__/widget-props-contract.test.tsx +129 -0
- package/src/complex-widgets.test.tsx +3 -3
- package/src/datetime-widgets.test.tsx +2 -2
- package/src/field-type-alias.ts +100 -0
- package/src/index.tsx +97 -134
- package/src/widgets/AddressField.tsx +2 -2
- package/src/widgets/AutoNumberField.tsx +2 -2
- package/src/widgets/AvatarField.tsx +2 -2
- package/src/widgets/BooleanField.tsx +2 -2
- package/src/widgets/CapabilityMultiSelectField.tsx +4 -4
- package/src/widgets/CheckboxesField.tsx +12 -9
- package/src/widgets/CodeField.tsx +2 -2
- package/src/widgets/ColorField.tsx +2 -2
- package/src/widgets/CurrencyField.tsx +2 -2
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +2 -2
- package/src/widgets/FileField.tsx +90 -59
- package/src/widgets/FilterConditionField.tsx +112 -12
- package/src/widgets/FormulaField.tsx +2 -2
- package/src/widgets/GeolocationField.tsx +2 -2
- package/src/widgets/GridField.tsx +3 -3
- package/src/widgets/ImageField.tsx +73 -35
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LocationField.tsx +2 -2
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +116 -19
- package/src/widgets/MasterDetailField.tsx +2 -2
- package/src/widgets/MultiSelectField.tsx +10 -7
- package/src/widgets/NumberField.tsx +2 -2
- package/src/widgets/ObjectField.tsx +2 -2
- package/src/widgets/ObjectRefField.tsx +13 -8
- package/src/widgets/PasswordField.tsx +2 -2
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/PercentField.tsx +2 -2
- package/src/widgets/PhoneField.tsx +2 -2
- package/src/widgets/QRCodeField.tsx +2 -2
- package/src/widgets/RadioField.tsx +10 -7
- package/src/widgets/RatingField.tsx +2 -2
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +40 -20
- package/src/widgets/RichTextField.tsx +2 -2
- package/src/widgets/SelectField.tsx +5 -5
- package/src/widgets/SignatureField.tsx +2 -2
- package/src/widgets/SliderField.tsx +2 -2
- package/src/widgets/SummaryField.tsx +2 -2
- package/src/widgets/TagsField.tsx +3 -3
- package/src/widgets/TextAreaField.tsx +6 -3
- package/src/widgets/TextField.tsx +2 -2
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +2 -2
- package/src/widgets/UserField.tsx +2 -2
- package/src/widgets/VectorField.tsx +2 -2
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +246 -0
- package/src/widgets/types.ts +129 -6
- package/src/widgets/useFieldTranslation.ts +30 -0
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
* `FieldWidgetComponentProps` is a CLOSED contract (objectui#3221).
|
|
11
|
+
*
|
|
12
|
+
* It used to end in `[key: string]: any`, which made every check over it
|
|
13
|
+
* vacuous: a type claiming to have every key can never be reported as missing
|
|
14
|
+
* one (objectstack#4075). Three consequences, one test each below:
|
|
15
|
+
*
|
|
16
|
+
* 1. a key the type does not declare is a compile error, not a silent `any`
|
|
17
|
+
* — which is what makes the `error` / `errorMessage` divergence
|
|
18
|
+
* (objectui#3222) decidable by the compiler at all;
|
|
19
|
+
* 2. a MISSPELLED prop (`readOnly` for `readonly`) is rejected;
|
|
20
|
+
* 3. the pass-through keys hosts genuinely forward still type-check, and
|
|
21
|
+
* still reach the rendered control at runtime — closing the type must not
|
|
22
|
+
* have been paid for by dropping real behaviour.
|
|
23
|
+
*
|
|
24
|
+
* (1) and (2) are compile-time: a violation fails `pnpm --filter
|
|
25
|
+
* @object-ui/fields type-check`, not this run. `@ts-expect-error` inverts
|
|
26
|
+
* that — the line fails to compile if the error it expects ever STOPS
|
|
27
|
+
* happening, i.e. if the index signature comes back.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { render, screen } from '@testing-library/react';
|
|
32
|
+
|
|
33
|
+
import { TextField } from '../widgets/TextField';
|
|
34
|
+
import { BooleanField } from '../widgets/BooleanField';
|
|
35
|
+
import type { FieldWidgetComponentProps } from '../widgets/types';
|
|
36
|
+
import type { FieldMetadata } from '@object-ui/types';
|
|
37
|
+
|
|
38
|
+
type Assert<T extends true> = T;
|
|
39
|
+
type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2 ? true : false;
|
|
40
|
+
type Extends<A, B> = [A] extends [B] ? true : false;
|
|
41
|
+
|
|
42
|
+
const field = { name: 'title', label: 'Title', type: 'text' } as unknown as FieldMetadata;
|
|
43
|
+
|
|
44
|
+
describe('FieldWidgetComponentProps is closed (objectui#3221)', () => {
|
|
45
|
+
it('has no `[key: string]` index signature', () => {
|
|
46
|
+
// The single assertion the whole change turns on. While the index
|
|
47
|
+
// signature existed this was `true`, and every check below was vacuous.
|
|
48
|
+
type _NotOpen = Assert<Equal<Extends<string, keyof FieldWidgetComponentProps>, false>>;
|
|
49
|
+
expect(true).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects keys it does not declare', () => {
|
|
53
|
+
const props = {} as FieldWidgetComponentProps<string>;
|
|
54
|
+
|
|
55
|
+
// The spec's `FieldWidgetPropsSchema` declares both; this type declares
|
|
56
|
+
// neither. Reading them used to give `any` and `undefined` forever.
|
|
57
|
+
// Whether to ADD them is objectui#3222 — deliberately not decided here.
|
|
58
|
+
// @ts-expect-error `required` is not part of this contract
|
|
59
|
+
void props.required;
|
|
60
|
+
// @ts-expect-error `error` is not part of this contract — this type's slot is `errorMessage`
|
|
61
|
+
void props.error;
|
|
62
|
+
|
|
63
|
+
// …and the class of bug the index signature hid best: a typo.
|
|
64
|
+
// @ts-expect-error the prop is `readonly`, not React's DOM `readOnly`
|
|
65
|
+
void props.readOnly;
|
|
66
|
+
// @ts-expect-error the prop is `onChange`
|
|
67
|
+
void props.onchange;
|
|
68
|
+
|
|
69
|
+
expect(true).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('still accepts the pass-through keys hosts actually forward', () => {
|
|
73
|
+
// Closing the type is only correct if the real call sites still compile.
|
|
74
|
+
// Each key here has a named producer: the form renderer, the inline-edit
|
|
75
|
+
// host, or the line-item grid. Deleting one from the type breaks this.
|
|
76
|
+
const passThrough: FieldWidgetComponentProps<string> = {
|
|
77
|
+
value: '',
|
|
78
|
+
onChange: () => {},
|
|
79
|
+
field,
|
|
80
|
+
// form renderer (`renderFieldComponent`)
|
|
81
|
+
schema: field,
|
|
82
|
+
dataSource: {},
|
|
83
|
+
dependentValues: { country: 'cn' },
|
|
84
|
+
dependsOn: ['country'],
|
|
85
|
+
emptyHint: 'Select country first',
|
|
86
|
+
name: 'title',
|
|
87
|
+
// inline-edit hosts / line-item grid
|
|
88
|
+
compact: true,
|
|
89
|
+
autoFocus: true,
|
|
90
|
+
onSelectRecord: () => {},
|
|
91
|
+
onCreateNew: () => {},
|
|
92
|
+
// DOM / a11y
|
|
93
|
+
id: 'title-input',
|
|
94
|
+
'aria-label': 'Title',
|
|
95
|
+
'data-testid': 'field-title',
|
|
96
|
+
};
|
|
97
|
+
expect(passThrough.compact).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('closing the type kept the pass-through behaviour', () => {
|
|
102
|
+
it('forwards a11y and data attributes to the rendered input', () => {
|
|
103
|
+
render(
|
|
104
|
+
<TextField
|
|
105
|
+
value="hello"
|
|
106
|
+
onChange={() => {}}
|
|
107
|
+
field={field}
|
|
108
|
+
aria-label="Title"
|
|
109
|
+
data-testid="title-input"
|
|
110
|
+
/>,
|
|
111
|
+
);
|
|
112
|
+
const input = screen.getByTestId('title-input');
|
|
113
|
+
expect(input).toHaveAttribute('aria-label', 'Title');
|
|
114
|
+
expect(input).toHaveValue('hello');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('forwards `disabled` — a declared key, not an index-signature accident', () => {
|
|
118
|
+
render(
|
|
119
|
+
<BooleanField
|
|
120
|
+
value={false}
|
|
121
|
+
onChange={() => {}}
|
|
122
|
+
field={{ name: 'active', label: 'Active', type: 'boolean' } as unknown as FieldMetadata}
|
|
123
|
+
disabled
|
|
124
|
+
data-testid="active-switch"
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
expect(screen.getByTestId('active-switch')).toBeDisabled();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -6,7 +6,7 @@ import { FieldEditWidget } from './FieldEditWidget';
|
|
|
6
6
|
import { MasterDetailField } from './widgets/MasterDetailField';
|
|
7
7
|
import { GridField } from './widgets/GridField';
|
|
8
8
|
import { FileField } from './widgets/FileField';
|
|
9
|
-
import type {
|
|
9
|
+
import type { FieldWidgetComponentProps } from './widgets/types';
|
|
10
10
|
|
|
11
11
|
// ------------- Mocks & Setup -------------
|
|
12
12
|
|
|
@@ -15,7 +15,7 @@ const mockField = {
|
|
|
15
15
|
label: 'Test Field',
|
|
16
16
|
} as any;
|
|
17
17
|
|
|
18
|
-
const baseProps:
|
|
18
|
+
const baseProps: FieldWidgetComponentProps<any> = {
|
|
19
19
|
field: mockField,
|
|
20
20
|
value: undefined,
|
|
21
21
|
onChange: vi.fn(),
|
|
@@ -78,7 +78,7 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
78
78
|
reference_field: 'name',
|
|
79
79
|
} as any;
|
|
80
80
|
|
|
81
|
-
const dynamicProps:
|
|
81
|
+
const dynamicProps: FieldWidgetComponentProps<any> = {
|
|
82
82
|
...baseProps,
|
|
83
83
|
field: dynamicField,
|
|
84
84
|
dataSource: mockDataSource,
|
|
@@ -3,7 +3,7 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
3
3
|
import { DateField } from './widgets/DateField';
|
|
4
4
|
import { DateTimeField } from './widgets/DateTimeField';
|
|
5
5
|
import { TimeField } from './widgets/TimeField';
|
|
6
|
-
import type {
|
|
6
|
+
import type { FieldWidgetComponentProps } from './widgets/types';
|
|
7
7
|
|
|
8
8
|
const mockField = {
|
|
9
9
|
name: 'test_field',
|
|
@@ -11,7 +11,7 @@ const mockField = {
|
|
|
11
11
|
type: 'date',
|
|
12
12
|
} as any;
|
|
13
13
|
|
|
14
|
-
const baseProps:
|
|
14
|
+
const baseProps: FieldWidgetComponentProps<string> = {
|
|
15
15
|
field: mockField,
|
|
16
16
|
value: '',
|
|
17
17
|
onChange: vi.fn(),
|
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
* Map field type to form component type
|
|
11
|
+
*
|
|
12
|
+
* @param fieldType - The ObjectQL field type identifier to convert
|
|
13
|
+
* (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
|
|
14
|
+
* @returns The normalized form field type string used in the form schema
|
|
15
|
+
* (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
|
|
16
|
+
*/
|
|
17
|
+
export function mapFieldTypeToFormType(fieldType: string): string {
|
|
18
|
+
const typeMap: Record<string, string> = {
|
|
19
|
+
// Text-based fields
|
|
20
|
+
text: 'field:text',
|
|
21
|
+
textarea: 'field:textarea',
|
|
22
|
+
markdown: 'field:markdown', // Markdown editor (fallback to textarea)
|
|
23
|
+
html: 'field:html', // Rich text editor (fallback to textarea)
|
|
24
|
+
richtext: 'field:richtext', // WYSIWYG rich-text editor
|
|
25
|
+
secret: 'field:password', // encrypted-at-rest value — mask input like a password
|
|
26
|
+
|
|
27
|
+
// Numeric fields
|
|
28
|
+
number: 'field:number',
|
|
29
|
+
currency: 'field:currency',
|
|
30
|
+
percent: 'field:percent',
|
|
31
|
+
slider: 'field:slider',
|
|
32
|
+
progress: 'field:slider', // bounded 0..100 progress — edit via slider
|
|
33
|
+
rating: 'field:rating',
|
|
34
|
+
|
|
35
|
+
// Date/Time fields
|
|
36
|
+
date: 'field:date',
|
|
37
|
+
datetime: 'field:datetime',
|
|
38
|
+
time: 'field:time',
|
|
39
|
+
|
|
40
|
+
// Boolean
|
|
41
|
+
boolean: 'field:boolean',
|
|
42
|
+
toggle: 'field:boolean', // toggle is a boolean rendered as a switch
|
|
43
|
+
|
|
44
|
+
// Selection fields
|
|
45
|
+
select: 'field:select',
|
|
46
|
+
multiselect: 'field:multiselect',
|
|
47
|
+
radio: 'field:radio',
|
|
48
|
+
checkboxes: 'field:checkboxes',
|
|
49
|
+
tags: 'field:tags',
|
|
50
|
+
lookup: 'field:lookup',
|
|
51
|
+
master_detail: 'field:master_detail',
|
|
52
|
+
tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
|
|
53
|
+
// `user` is a lookup specialized to sys_user; `owner` mirrors it (record
|
|
54
|
+
// ownership). Both render via the UserField person-picker (delegates to the
|
|
55
|
+
// lookup picker). Without these they would fall through to `field:text`.
|
|
56
|
+
user: 'field:user',
|
|
57
|
+
owner: 'field:owner',
|
|
58
|
+
|
|
59
|
+
// Contact fields
|
|
60
|
+
email: 'field:email',
|
|
61
|
+
phone: 'field:phone',
|
|
62
|
+
url: 'field:url',
|
|
63
|
+
|
|
64
|
+
// File / media fields
|
|
65
|
+
file: 'field:file',
|
|
66
|
+
image: 'field:image',
|
|
67
|
+
avatar: 'field:avatar',
|
|
68
|
+
video: 'field:file', // uploads as a file
|
|
69
|
+
audio: 'field:file', // uploads as a file
|
|
70
|
+
signature: 'field:signature',
|
|
71
|
+
|
|
72
|
+
// Special / enhanced fields
|
|
73
|
+
password: 'field:password',
|
|
74
|
+
location: 'field:location', // Location/map field (fallback to input)
|
|
75
|
+
geolocation: 'field:geolocation',
|
|
76
|
+
address: 'field:address',
|
|
77
|
+
color: 'field:color',
|
|
78
|
+
code: 'field:code',
|
|
79
|
+
json: 'field:code', // JSON edited in the code editor
|
|
80
|
+
qrcode: 'field:qrcode',
|
|
81
|
+
vector: 'field:vector',
|
|
82
|
+
|
|
83
|
+
// Embedded structured values (stored as JSON on the row)
|
|
84
|
+
object: 'field:object',
|
|
85
|
+
composite: 'field:object', // embedded object
|
|
86
|
+
record: 'field:object', // name-keyed map
|
|
87
|
+
repeater: 'field:grid', // embedded array of rows
|
|
88
|
+
|
|
89
|
+
// Auto-generated/computed fields (typically read-only)
|
|
90
|
+
formula: 'field:formula',
|
|
91
|
+
summary: 'field:summary',
|
|
92
|
+
// `@objectstack/spec` spells this `autonumber`; the widget map key is
|
|
93
|
+
// `auto_number`. Map both spellings so a spec-typed field/param doesn't
|
|
94
|
+
// fall through to the plain text input.
|
|
95
|
+
autonumber: 'field:auto_number',
|
|
96
|
+
auto_number: 'field:auto_number',
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return typeMap[fieldType] || 'field:text';
|
|
100
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -303,6 +303,8 @@ import { PercentField } from './widgets/PercentField';
|
|
|
303
303
|
import { PasswordField } from './widgets/PasswordField';
|
|
304
304
|
import { FileField } from './widgets/FileField';
|
|
305
305
|
import { ImageField } from './widgets/ImageField';
|
|
306
|
+
import { ImageLightbox } from './widgets/ImageLightbox';
|
|
307
|
+
import { readFileValues } from './widgets/file-value';
|
|
306
308
|
import { LocationField } from './widgets/LocationField';
|
|
307
309
|
import { FormulaField } from './widgets/FormulaField';
|
|
308
310
|
import { SummaryField } from './widgets/SummaryField';
|
|
@@ -506,7 +508,7 @@ function formatRelativeDays(diffDays: number, locale?: string): string {
|
|
|
506
508
|
|
|
507
509
|
/**
|
|
508
510
|
* Format date as relative time (e.g., "3 days ago", "Today", "Overdue 3d"),
|
|
509
|
-
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/
|
|
511
|
+
* localized via `Intl.RelativeTimeFormat` (objectstack-ai/objectstack#3040).
|
|
510
512
|
*
|
|
511
513
|
* `dueLike` gates the "Overdue" wording — a past `start_date`/`created_at`
|
|
512
514
|
* isn't overdue, only a past due/deadline-semantic field is. Non-due-like
|
|
@@ -1268,50 +1270,88 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
1268
1270
|
}
|
|
1269
1271
|
|
|
1270
1272
|
/**
|
|
1271
|
-
* Image field cell renderer (with thumbnails)
|
|
1273
|
+
* Image field cell renderer (with thumbnails + click-to-zoom).
|
|
1274
|
+
*
|
|
1275
|
+
* An image value may be a plain URL string, an object ({ url | src | href … }),
|
|
1276
|
+
* a bare `sys_file` id, or an array of any of those. Normalising through
|
|
1277
|
+
* `readFileValues` (which resolves a bare id to its stable download URL) means
|
|
1278
|
+
* a string-URL field, a CDN link, and an unexpanded reference all render a
|
|
1279
|
+
* thumbnail instead of a broken `<img src="">` placeholder.
|
|
1280
|
+
*
|
|
1281
|
+
* Clicking a thumbnail opens a full-screen lightbox (single or gallery). The
|
|
1282
|
+
* click is `stopPropagation`-guarded so, inside a grid row, enlarging an image
|
|
1283
|
+
* doesn't also trigger row navigation.
|
|
1272
1284
|
*/
|
|
1273
1285
|
export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
1274
|
-
|
|
1286
|
+
const { t } = useObjectTranslation();
|
|
1287
|
+
const [lightboxIndex, setLightboxIndex] = React.useState<number | null>(null);
|
|
1288
|
+
|
|
1289
|
+
const imgs = React.useMemo(
|
|
1290
|
+
() =>
|
|
1291
|
+
readFileValues(value, 'Image')
|
|
1292
|
+
.filter((v) => v.url)
|
|
1293
|
+
.map((v) => ({ url: v.url as string, name: v.name })),
|
|
1294
|
+
[value],
|
|
1295
|
+
);
|
|
1275
1296
|
|
|
1276
|
-
|
|
1277
|
-
// or an array of either. Normalize so a string-URL field (common — e.g. a
|
|
1278
|
-
// `cover` seeded with a CDN link) renders a thumbnail instead of a broken
|
|
1279
|
-
// <img src=""> placeholder.
|
|
1280
|
-
const urlOf = (v: any): string =>
|
|
1281
|
-
typeof v === 'string' ? v : (v?.url || v?.src || v?.href || v?.thumbnailUrl || '');
|
|
1282
|
-
const nameOf = (v: any, fallback: string): string =>
|
|
1283
|
-
(v && typeof v === 'object' && (v.name || v.original_name)) || fallback;
|
|
1297
|
+
if (!value || imgs.length === 0) return <EmptyValue />;
|
|
1284
1298
|
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1299
|
+
const imageAlt = (idx: number, name?: string) =>
|
|
1300
|
+
name || t('fields.image.imageAlt', { index: idx + 1 });
|
|
1301
|
+
const open = (idx: number) => (e: React.MouseEvent) => {
|
|
1302
|
+
e.stopPropagation();
|
|
1303
|
+
setLightboxIndex(idx);
|
|
1304
|
+
};
|
|
1305
|
+
const multiple = imgs.length > 1;
|
|
1306
|
+
|
|
1307
|
+
const lightbox = lightboxIndex !== null && (
|
|
1308
|
+
<ImageLightbox
|
|
1309
|
+
images={imgs}
|
|
1310
|
+
index={lightboxIndex}
|
|
1311
|
+
open
|
|
1312
|
+
onOpenChange={(o) => !o && setLightboxIndex(null)}
|
|
1313
|
+
onIndexChange={setLightboxIndex}
|
|
1314
|
+
/>
|
|
1315
|
+
);
|
|
1316
|
+
|
|
1317
|
+
if (multiple) {
|
|
1288
1318
|
return (
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1319
|
+
<>
|
|
1320
|
+
<div className="flex -space-x-2">
|
|
1321
|
+
{imgs.slice(0, 3).map((img, idx) => (
|
|
1322
|
+
<img
|
|
1323
|
+
key={idx}
|
|
1324
|
+
src={img.url}
|
|
1325
|
+
alt={imageAlt(idx, img.name)}
|
|
1326
|
+
onClick={open(idx)}
|
|
1327
|
+
className="size-8 cursor-zoom-in rounded-md border-2 border-background object-cover transition-transform hover:scale-110 hover:z-10"
|
|
1328
|
+
/>
|
|
1329
|
+
))}
|
|
1330
|
+
{imgs.length > 3 && (
|
|
1331
|
+
<button
|
|
1332
|
+
type="button"
|
|
1333
|
+
onClick={open(3)}
|
|
1334
|
+
className="size-8 rounded-md border-2 border-background bg-muted flex items-center justify-center text-xs font-medium text-muted-foreground hover:bg-muted/80"
|
|
1335
|
+
>
|
|
1336
|
+
+{imgs.length - 3}
|
|
1337
|
+
</button>
|
|
1338
|
+
)}
|
|
1339
|
+
</div>
|
|
1340
|
+
{lightbox}
|
|
1341
|
+
</>
|
|
1304
1342
|
);
|
|
1305
1343
|
}
|
|
1306
1344
|
|
|
1307
|
-
const url = urlOf(value);
|
|
1308
|
-
if (!url) return <EmptyValue />;
|
|
1309
1345
|
return (
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1346
|
+
<>
|
|
1347
|
+
<img
|
|
1348
|
+
src={imgs[0].url}
|
|
1349
|
+
alt={imageAlt(0, imgs[0].name)}
|
|
1350
|
+
onClick={open(0)}
|
|
1351
|
+
className="size-10 cursor-zoom-in rounded-md object-cover transition-transform hover:scale-105"
|
|
1352
|
+
/>
|
|
1353
|
+
{lightbox}
|
|
1354
|
+
</>
|
|
1315
1355
|
);
|
|
1316
1356
|
}
|
|
1317
1357
|
|
|
@@ -1823,98 +1863,10 @@ setCellRendererResolver(getCellRenderer);
|
|
|
1823
1863
|
|
|
1824
1864
|
|
|
1825
1865
|
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
* (for example: `"text"`, `"number"`, `"date"`, `"lookup"`).
|
|
1831
|
-
* @returns The normalized form field type string used in the form schema
|
|
1832
|
-
* (for example: `"input"`, `"textarea"`, `"date-picker"`, `"select"`).
|
|
1833
|
-
*/
|
|
1834
|
-
export function mapFieldTypeToFormType(fieldType: string): string {
|
|
1835
|
-
const typeMap: Record<string, string> = {
|
|
1836
|
-
// Text-based fields
|
|
1837
|
-
text: 'field:text',
|
|
1838
|
-
textarea: 'field:textarea',
|
|
1839
|
-
markdown: 'field:markdown', // Markdown editor (fallback to textarea)
|
|
1840
|
-
html: 'field:html', // Rich text editor (fallback to textarea)
|
|
1841
|
-
richtext: 'field:richtext', // WYSIWYG rich-text editor
|
|
1842
|
-
secret: 'field:password', // encrypted-at-rest value — mask input like a password
|
|
1843
|
-
|
|
1844
|
-
// Numeric fields
|
|
1845
|
-
number: 'field:number',
|
|
1846
|
-
currency: 'field:currency',
|
|
1847
|
-
percent: 'field:percent',
|
|
1848
|
-
slider: 'field:slider',
|
|
1849
|
-
progress: 'field:slider', // bounded 0..100 progress — edit via slider
|
|
1850
|
-
rating: 'field:rating',
|
|
1851
|
-
|
|
1852
|
-
// Date/Time fields
|
|
1853
|
-
date: 'field:date',
|
|
1854
|
-
datetime: 'field:datetime',
|
|
1855
|
-
time: 'field:time',
|
|
1856
|
-
|
|
1857
|
-
// Boolean
|
|
1858
|
-
boolean: 'field:boolean',
|
|
1859
|
-
toggle: 'field:boolean', // toggle is a boolean rendered as a switch
|
|
1860
|
-
|
|
1861
|
-
// Selection fields
|
|
1862
|
-
select: 'field:select',
|
|
1863
|
-
multiselect: 'field:multiselect',
|
|
1864
|
-
radio: 'field:radio',
|
|
1865
|
-
checkboxes: 'field:checkboxes',
|
|
1866
|
-
tags: 'field:tags',
|
|
1867
|
-
lookup: 'field:lookup',
|
|
1868
|
-
master_detail: 'field:master_detail',
|
|
1869
|
-
tree: 'field:lookup', // hierarchical reference — pick the parent via a lookup
|
|
1870
|
-
// `user` is a lookup specialized to sys_user; `owner` mirrors it (record
|
|
1871
|
-
// ownership). Both render via the UserField person-picker (delegates to the
|
|
1872
|
-
// lookup picker). Without these they would fall through to `field:text`.
|
|
1873
|
-
user: 'field:user',
|
|
1874
|
-
owner: 'field:owner',
|
|
1875
|
-
|
|
1876
|
-
// Contact fields
|
|
1877
|
-
email: 'field:email',
|
|
1878
|
-
phone: 'field:phone',
|
|
1879
|
-
url: 'field:url',
|
|
1880
|
-
|
|
1881
|
-
// File / media fields
|
|
1882
|
-
file: 'field:file',
|
|
1883
|
-
image: 'field:image',
|
|
1884
|
-
avatar: 'field:avatar',
|
|
1885
|
-
video: 'field:file', // uploads as a file
|
|
1886
|
-
audio: 'field:file', // uploads as a file
|
|
1887
|
-
signature: 'field:signature',
|
|
1888
|
-
|
|
1889
|
-
// Special / enhanced fields
|
|
1890
|
-
password: 'field:password',
|
|
1891
|
-
location: 'field:location', // Location/map field (fallback to input)
|
|
1892
|
-
geolocation: 'field:geolocation',
|
|
1893
|
-
address: 'field:address',
|
|
1894
|
-
color: 'field:color',
|
|
1895
|
-
code: 'field:code',
|
|
1896
|
-
json: 'field:code', // JSON edited in the code editor
|
|
1897
|
-
qrcode: 'field:qrcode',
|
|
1898
|
-
vector: 'field:vector',
|
|
1899
|
-
|
|
1900
|
-
// Embedded structured values (stored as JSON on the row)
|
|
1901
|
-
object: 'field:object',
|
|
1902
|
-
composite: 'field:object', // embedded object
|
|
1903
|
-
record: 'field:object', // name-keyed map
|
|
1904
|
-
repeater: 'field:grid', // embedded array of rows
|
|
1905
|
-
|
|
1906
|
-
// Auto-generated/computed fields (typically read-only)
|
|
1907
|
-
formula: 'field:formula',
|
|
1908
|
-
summary: 'field:summary',
|
|
1909
|
-
// `@objectstack/spec` spells this `autonumber`; the widget map key is
|
|
1910
|
-
// `auto_number`. Map both spellings so a spec-typed field/param doesn't
|
|
1911
|
-
// fall through to the plain text input.
|
|
1912
|
-
autonumber: 'field:auto_number',
|
|
1913
|
-
auto_number: 'field:auto_number',
|
|
1914
|
-
};
|
|
1915
|
-
|
|
1916
|
-
return typeMap[fieldType] || 'field:text';
|
|
1917
|
-
}
|
|
1866
|
+
// `mapFieldTypeToFormType` moved to './field-type-alias' (re-exported below) so
|
|
1867
|
+
// FieldEditWidget can resolve spec aliases without importing this barrel.
|
|
1868
|
+
export { mapFieldTypeToFormType } from './field-type-alias';
|
|
1869
|
+
import { mapFieldTypeToFormType } from './field-type-alias';
|
|
1918
1870
|
|
|
1919
1871
|
/**
|
|
1920
1872
|
* Formats file size in bytes to human-readable string
|
|
@@ -1966,18 +1918,23 @@ export function buildValidationRules(field: any): any {
|
|
|
1966
1918
|
// vars); a field-authored `*_message` is a string and passes through as-is,
|
|
1967
1919
|
// still winning over the localized default. See form.tsx `localizeRule`.
|
|
1968
1920
|
|
|
1969
|
-
// Length validation for text fields
|
|
1970
|
-
|
|
1921
|
+
// Length validation for text fields. The spec-canonical keys are camelCase
|
|
1922
|
+
// (`minLength`/`maxLength`, @objectstack/spec FieldSchema — what the server
|
|
1923
|
+
// record-validator enforces); the snake_case pair is the legacy objectui
|
|
1924
|
+
// spelling, kept as fallback (framework#1878/#1891 naming-drift closeout).
|
|
1925
|
+
const minLength = (field as any).minLength ?? field.min_length;
|
|
1926
|
+
if (minLength) {
|
|
1971
1927
|
rules.minLength = {
|
|
1972
|
-
value:
|
|
1928
|
+
value: minLength,
|
|
1973
1929
|
message: typeof field.min_length_message === 'string' ? field.min_length_message : undefined,
|
|
1974
1930
|
messageKey: 'validation.minLength',
|
|
1975
1931
|
};
|
|
1976
1932
|
}
|
|
1977
1933
|
|
|
1978
|
-
|
|
1934
|
+
const maxLength = (field as any).maxLength ?? field.max_length;
|
|
1935
|
+
if (maxLength) {
|
|
1979
1936
|
rules.maxLength = {
|
|
1980
|
-
value:
|
|
1937
|
+
value: maxLength,
|
|
1981
1938
|
message: typeof field.max_length_message === 'string' ? field.max_length_message : undefined,
|
|
1982
1939
|
messageKey: 'validation.maxLength',
|
|
1983
1940
|
};
|
|
@@ -2091,7 +2048,9 @@ function createFieldRenderer(FieldWidget: React.ComponentType<any>) {
|
|
|
2091
2048
|
readonly: schema?.readonly || schema?.readOnly,
|
|
2092
2049
|
help: schema?.help,
|
|
2093
2050
|
description: schema?.description,
|
|
2094
|
-
|
|
2051
|
+
// `??`, not `||` — an authored default of `false` / `0` / `''` is a value,
|
|
2052
|
+
// and `||` silently threw it away (same falsy-as-empty class as cloud#972).
|
|
2053
|
+
defaultValue: schema?.defaultValue ?? schema?.value,
|
|
2095
2054
|
...schema,
|
|
2096
2055
|
};
|
|
2097
2056
|
|
|
@@ -2438,6 +2397,10 @@ export function registerFields() {
|
|
|
2438
2397
|
}
|
|
2439
2398
|
|
|
2440
2399
|
export * from './widgets/types';
|
|
2400
|
+
// File field value shapes (ObjectStack ADR-0104 D3 wave 2) — the single
|
|
2401
|
+
// arbiter of reference vs expanded vs legacy-blob form, shared by the upload
|
|
2402
|
+
// widgets and by action-param serialization.
|
|
2403
|
+
export * from './widgets/file-value';
|
|
2441
2404
|
export * from './FieldEditWidget';
|
|
2442
2405
|
export * from './widgets/TextField';
|
|
2443
2406
|
export * from './widgets/NumberField';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Input, Label, EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Address data structure
|
|
@@ -17,7 +17,7 @@ export interface AddressValue {
|
|
|
17
17
|
* Address field widget - provides a structured address input
|
|
18
18
|
* Supports street, city, state, zip code, and country
|
|
19
19
|
*/
|
|
20
|
-
export function AddressField({ value, onChange, field, readonly, ...props }:
|
|
20
|
+
export function AddressField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<AddressValue>) {
|
|
21
21
|
const address = value || {};
|
|
22
22
|
|
|
23
23
|
const handleFieldChange = (fieldName: keyof AddressValue, fieldValue: string) => {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { EmptyValue } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* AutoNumberField - Read-only auto-generated field
|
|
7
7
|
* Values are automatically generated by the system and cannot be edited
|
|
8
8
|
*/
|
|
9
|
-
export function AutoNumberField({ value, field, ...props }:
|
|
9
|
+
export function AutoNumberField({ value, field, ...props }: FieldWidgetComponentProps<string | number>) {
|
|
10
10
|
if (value == null) {
|
|
11
11
|
return <EmptyValue className={props.className} />;
|
|
12
12
|
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { Avatar, AvatarFallback, AvatarImage, Button } from '@object-ui/components';
|
|
3
3
|
import { Upload, X } from 'lucide-react';
|
|
4
|
-
import {
|
|
4
|
+
import { FieldWidgetComponentProps } from './types';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Avatar field widget - provides an avatar/profile picture uploader
|
|
8
8
|
* Supports image URLs or file uploads
|
|
9
9
|
*/
|
|
10
|
-
export function AvatarField({ value, onChange, field, readonly, ...props }:
|
|
10
|
+
export function AvatarField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<string>) {
|
|
11
11
|
const [isHovered, setIsHovered] = React.useState(false);
|
|
12
12
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
13
13
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import React, { useId } from 'react';
|
|
2
2
|
import { Switch, Checkbox, Label } from '@object-ui/components';
|
|
3
|
-
import {
|
|
3
|
+
import { FieldWidgetComponentProps } from './types';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* BooleanField - Toggle input supporting switch and checkbox variants
|
|
7
7
|
* Renders as Switch or Checkbox based on field widget configuration
|
|
8
8
|
*/
|
|
9
|
-
export function BooleanField({ value, onChange, field, readonly, ...props }:
|
|
9
|
+
export function BooleanField({ value, onChange, field, readonly, ...props }: FieldWidgetComponentProps<boolean>) {
|
|
10
10
|
const config = (field || (props as any).schema) as any;
|
|
11
11
|
// Use simple type assertion for arbitrary custom properties not in BaseFieldMetadata
|
|
12
12
|
const widget = config?.widget;
|
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Badge, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { DataSource, QueryParams } from '@object-ui/types';
|
|
5
|
-
import {
|
|
5
|
+
import { FieldWidgetComponentProps } from './types';
|
|
6
6
|
import { useFieldTranslation } from './useFieldTranslation';
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -86,12 +86,12 @@ export function CapabilityMultiSelectField({
|
|
|
86
86
|
readonly,
|
|
87
87
|
className,
|
|
88
88
|
...props
|
|
89
|
-
}:
|
|
89
|
+
}: FieldWidgetComponentProps<string | string[]>) {
|
|
90
90
|
const { t } = useFieldTranslation();
|
|
91
91
|
const ctx = React.useContext(SchemaRendererContext);
|
|
92
92
|
const dataSource: DataSource | null =
|
|
93
|
-
(props as any)
|
|
94
|
-
const disabled =
|
|
93
|
+
(props.dataSource as any) ?? (ctx as any)?.dataSource ?? null;
|
|
94
|
+
const disabled = props.disabled;
|
|
95
95
|
|
|
96
96
|
const [caps, setCaps] = React.useState<Capability[] | null>(null);
|
|
97
97
|
|