@object-ui/fields 16.0.0 → 17.1.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 +630 -0
- package/README.md +45 -7
- package/dist/FieldEditWidget.d.ts +7 -0
- package/dist/field-type-alias.d.ts +16 -0
- package/dist/index.cjs +5 -5
- package/dist/index.d.ts +35 -11
- package/dist/index.js +2241 -1853
- package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
- package/dist/widgets/CheckboxesField.d.ts +8 -1
- package/dist/widgets/FileField.d.ts +1 -1
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- package/dist/widgets/ImageField.d.ts +1 -1
- package/dist/widgets/ImageLightbox.d.ts +30 -0
- package/dist/widgets/ImageLightbox.test.d.ts +1 -0
- package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
- package/dist/widgets/LookupField.idField.test.d.ts +1 -0
- package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
- package/dist/widgets/MultiSelectField.d.ts +10 -2
- package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
- package/dist/widgets/RadioField.d.ts +8 -1
- package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
- package/dist/widgets/SelectField.d.ts +13 -9
- 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/deriveLookupColumns.d.ts +1 -0
- package/dist/widgets/file-value.d.ts +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/types.d.ts +6 -0
- package/dist/widgets/useCascadingOptions.d.ts +18 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/dist/widgets/useUploadingSignal.d.ts +9 -0
- package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
- package/package.json +11 -10
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/field-type-coverage.test.ts +3 -1
- package/src/index.tsx +137 -135
- package/src/standard-widgets.test.tsx +44 -0
- package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
- package/src/widgets/CheckboxesField.tsx +77 -11
- package/src/widgets/FileField.tsx +91 -52
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +106 -65
- package/src/widgets/ImageLightbox.test.tsx +52 -0
- package/src/widgets/ImageLightbox.tsx +108 -0
- package/src/widgets/LookupField.hydration.test.tsx +136 -0
- package/src/widgets/LookupField.idField.test.tsx +61 -0
- package/src/widgets/LookupField.tsx +106 -9
- package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
- package/src/widgets/MultiSelectField.tsx +78 -11
- package/src/widgets/ObjectRefField.tsx +9 -4
- package/src/widgets/PeoplePicker.test.tsx +20 -0
- package/src/widgets/PeoplePicker.tsx +15 -3
- package/src/widgets/RadioField.cascade.test.tsx +141 -0
- package/src/widgets/RadioField.tsx +69 -9
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/SelectField.tsx +33 -31
- package/src/widgets/TextAreaField.tsx +8 -3
- package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
- package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
- package/src/widgets/deriveLookupColumns.test.ts +13 -0
- package/src/widgets/deriveLookupColumns.ts +5 -15
- package/src/widgets/file-value.test.ts +230 -0
- package/src/widgets/file-value.ts +230 -0
- package/src/widgets/types.ts +6 -0
- package/src/widgets/useCascadingOptions.ts +46 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
- package/src/widgets/useUploadingSignal.test.tsx +62 -0
- package/src/widgets/useUploadingSignal.ts +20 -0
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
* Operator spelling guard for the sharing-rule criteria builder (#2901).
|
|
11
|
+
*
|
|
12
|
+
* `condToMongo` emitted `$ncontains` — a token that appears nowhere in
|
|
13
|
+
* `@objectstack/spec` and that objectui's own `convertFiltersToAST` throws on,
|
|
14
|
+
* naming the correct spelling (`$notContains`) in its error message. So every
|
|
15
|
+
* "does not contain" rule authored here validated in the UI and was then
|
|
16
|
+
* rejected downstream. Nothing caught it because the builder's operator list is
|
|
17
|
+
* hand-written and never compared against the spec vocabulary.
|
|
18
|
+
*
|
|
19
|
+
* These tests pin the emitted spellings against `FieldOperatorsSchema`'s keys
|
|
20
|
+
* and pin the round-trip, including the pre-fix spelling that stored criteria
|
|
21
|
+
* still carry.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, it, expect } from 'vitest';
|
|
24
|
+
import { FieldOperatorsSchema } from '@objectstack/spec/data';
|
|
25
|
+
import { condToMongo, kvToCondition } from '../FilterConditionField';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The `$`-prefixed operator keys of the spec's `FieldOperatorsSchema` —
|
|
29
|
+
* DERIVED from the schema (#2942), so a 16th operator landing in the spec
|
|
30
|
+
* fails the reachability test below instead of drifting unnoticed.
|
|
31
|
+
*/
|
|
32
|
+
const SPEC_OPERATORS = new Set(
|
|
33
|
+
Object.keys((FieldOperatorsSchema as unknown as { shape?: Record<string, unknown> }).shape ?? {}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const noTypes = () => undefined;
|
|
37
|
+
|
|
38
|
+
/** Pull the operator keys out of a `{ field: { $op: v } }` fragment. */
|
|
39
|
+
function operatorsOf(frag: Record<string, any> | null): string[] {
|
|
40
|
+
if (!frag) return [];
|
|
41
|
+
return Object.values(frag).flatMap((v) =>
|
|
42
|
+
v !== null && typeof v === 'object' && !Array.isArray(v) ? Object.keys(v) : [],
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('condToMongo emits only spec operator spellings', () => {
|
|
47
|
+
const builderOperators = [
|
|
48
|
+
'equals', 'notEquals', 'contains', 'notContains', 'isEmpty', 'isNotEmpty',
|
|
49
|
+
'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', 'in', 'notIn',
|
|
50
|
+
'before', 'after',
|
|
51
|
+
'startsWith', 'endsWith', 'isNull', 'isNotNull', 'exists', 'notExists',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
it.each(builderOperators)('%s emits a spec-defined operator', (operator) => {
|
|
55
|
+
const frag = condToMongo(
|
|
56
|
+
{ id: 'c1', field: 'name', operator, value: operator === 'in' || operator === 'notIn' ? ['a'] : 'a' } as any,
|
|
57
|
+
noTypes,
|
|
58
|
+
);
|
|
59
|
+
const unknown = operatorsOf(frag).filter((op) => !SPEC_OPERATORS.has(op));
|
|
60
|
+
expect(unknown, `${operator} emits an operator @objectstack/spec does not define`).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('notContains emits $notContains, not the pre-fix $ncontains', () => {
|
|
64
|
+
const frag = condToMongo({ id: 'c1', field: 'name', operator: 'notContains', value: 'x' } as any, noTypes);
|
|
65
|
+
expect(frag).toEqual({ name: { $notContains: 'x' } });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('between emits a range the spec defines', () => {
|
|
69
|
+
const frag = condToMongo({ id: 'c1', field: 'age', operator: 'between', value: [1, 5] } as any, noTypes);
|
|
70
|
+
expect(operatorsOf(frag).every((op) => SPEC_OPERATORS.has(op))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('every spec field operator is reachable from the builder (#2942)', () => {
|
|
75
|
+
it('reads a non-empty operator vocabulary from the spec', () => {
|
|
76
|
+
expect([...SPEC_OPERATORS].length, 'could not read FieldOperatorsSchema.shape').toBeGreaterThan(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('some builder operator emits every spec $-token', () => {
|
|
80
|
+
const builderOperators = [
|
|
81
|
+
'equals', 'notEquals', 'contains', 'notContains', 'isEmpty', 'isNotEmpty',
|
|
82
|
+
'greaterThan', 'lessThan', 'greaterOrEqual', 'lessOrEqual', 'in', 'notIn',
|
|
83
|
+
'before', 'after', 'between',
|
|
84
|
+
'startsWith', 'endsWith', 'isNull', 'isNotNull', 'exists', 'notExists',
|
|
85
|
+
];
|
|
86
|
+
const emitted = new Set<string>();
|
|
87
|
+
for (const operator of builderOperators) {
|
|
88
|
+
const value = operator === 'in' || operator === 'notIn' ? ['a'] : operator === 'between' ? [1, 5] : 'a';
|
|
89
|
+
const frag = condToMongo({ id: 'c1', field: 'f', operator, value } as any, noTypes);
|
|
90
|
+
for (const op of operatorsOf(frag)) emitted.add(op);
|
|
91
|
+
}
|
|
92
|
+
// `$eq` is the spec's IMPLICIT equality form — `equals` deliberately
|
|
93
|
+
// emits the bare `{ field: value }` shape, never an explicit `$eq`.
|
|
94
|
+
// `$between` stays covered by the `$gte`+`$lte` pair `between` emits
|
|
95
|
+
// (kvToCondition reads that pair back as `between`).
|
|
96
|
+
const unreachable = [...SPEC_OPERATORS].filter(
|
|
97
|
+
(op) => op !== '$eq' && op !== '$between' && !emitted.has(op),
|
|
98
|
+
);
|
|
99
|
+
expect(
|
|
100
|
+
unreachable,
|
|
101
|
+
'FieldOperatorsSchema accepts these but no builder operator can author them',
|
|
102
|
+
).toEqual([]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('kvToCondition round-trips what condToMongo writes', () => {
|
|
107
|
+
const cases: Array<[string, unknown]> = [
|
|
108
|
+
['notEquals', 'a'],
|
|
109
|
+
['contains', 'a'],
|
|
110
|
+
['notContains', 'a'],
|
|
111
|
+
['greaterThan', 1],
|
|
112
|
+
['lessThan', 1],
|
|
113
|
+
['greaterOrEqual', 1],
|
|
114
|
+
['lessOrEqual', 1],
|
|
115
|
+
['startsWith', 'a'],
|
|
116
|
+
['endsWith', 'a'],
|
|
117
|
+
['isNull', ''],
|
|
118
|
+
['isNotNull', ''],
|
|
119
|
+
['exists', ''],
|
|
120
|
+
['notExists', ''],
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
it.each(cases)('%s survives the round trip', (operator, value) => {
|
|
124
|
+
const frag = condToMongo({ id: 'c1', field: 'f', operator, value } as any, noTypes)!;
|
|
125
|
+
const [[field, v]] = Object.entries(frag);
|
|
126
|
+
expect(kvToCondition(field, v, 0)).toMatchObject({ field: 'f', operator });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('still reads the pre-fix $ncontains so stored criteria keep loading', () => {
|
|
130
|
+
// Dropping this would make rules saved before the fix fail to load entirely
|
|
131
|
+
// ("criteria can't be represented") rather than migrate.
|
|
132
|
+
expect(kvToCondition('name', { $ncontains: 'x' }, 0)).toMatchObject({
|
|
133
|
+
field: 'name',
|
|
134
|
+
operator: 'notContains',
|
|
135
|
+
value: 'x',
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('rejects an operator it cannot represent rather than guessing', () => {
|
|
140
|
+
expect(kvToCondition('name', { $nope: 'x' }, 0)).toBeNull();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -50,6 +50,19 @@ describe('deriveLookupColumns', () => {
|
|
|
50
50
|
expect(deriveLookupColumns(accountSchema, { displayField: 'name', max: 2 })).toHaveLength(2);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
it('skips fields flagged `system: true` even when the name is not a known audit column', () => {
|
|
54
|
+
const schema = {
|
|
55
|
+
fields: {
|
|
56
|
+
name: { type: 'text', label: 'Name' },
|
|
57
|
+
// Non-canonical name, but stamped system by the framework — must be skipped.
|
|
58
|
+
custom_ref: { type: 'lookup', label: 'Custom Ref', system: true } as any,
|
|
59
|
+
code: { type: 'text', label: 'Code' },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
const cols = deriveLookupColumns(schema, { displayField: 'name', max: 10 });
|
|
63
|
+
expect(cols.map((c) => c.field)).toEqual(['name', 'code']);
|
|
64
|
+
});
|
|
65
|
+
|
|
53
66
|
it('honours an object-level displayFields list, with the display field first', () => {
|
|
54
67
|
const schema = {
|
|
55
68
|
fields: {
|
|
@@ -1,17 +1,5 @@
|
|
|
1
1
|
import type { LookupColumnDef } from '@object-ui/types';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* System / audit fields that carry no disambiguating value in a record picker.
|
|
5
|
-
* Excluded from auto-derived columns so the picker shows business data, not
|
|
6
|
-
* bookkeeping.
|
|
7
|
-
*/
|
|
8
|
-
const SYSTEM_FIELDS = new Set<string>([
|
|
9
|
-
'id', '_id',
|
|
10
|
-
'created', 'modified', 'created_at', 'updated_at',
|
|
11
|
-
'created_by', 'updated_by', 'modified_by',
|
|
12
|
-
'owner_id', 'organization_id', 'space', 'company_id',
|
|
13
|
-
'instance_state', 'locked', 'is_deleted', 'deleted',
|
|
14
|
-
]);
|
|
2
|
+
import { isSystemManagedField } from '@object-ui/types';
|
|
15
3
|
|
|
16
4
|
/**
|
|
17
5
|
* Field types that don't render usefully as a compact picker column (large
|
|
@@ -31,7 +19,7 @@ export interface DeriveColumnsOptions {
|
|
|
31
19
|
}
|
|
32
20
|
|
|
33
21
|
interface ObjectSchemaLike {
|
|
34
|
-
fields?: Record<string, { type?: string; label?: string; hidden?: boolean }>;
|
|
22
|
+
fields?: Record<string, { type?: string; label?: string; hidden?: boolean; system?: boolean }>;
|
|
35
23
|
/**
|
|
36
24
|
* ADR-0085 semantic role: the object's most important fields. The single
|
|
37
25
|
* source for "how to list this object" — shared with the detail-page related
|
|
@@ -106,8 +94,10 @@ export function deriveLookupColumns(
|
|
|
106
94
|
// 3. Derive from the field set in declaration order.
|
|
107
95
|
const candidates = Object.keys(fields).filter((name) => {
|
|
108
96
|
if (name === displayField) return false;
|
|
109
|
-
if (SYSTEM_FIELDS.has(name)) return false;
|
|
110
97
|
const f = fieldDef(name);
|
|
98
|
+
// Skip framework-managed system/audit/ownership columns (incl. owner_id) —
|
|
99
|
+
// they carry no disambiguating value in a record picker.
|
|
100
|
+
if (isSystemManagedField(name, f)) return false;
|
|
111
101
|
if (f.hidden) return false;
|
|
112
102
|
if (f.type && NON_TABULAR_TYPES.has(f.type)) return false;
|
|
113
103
|
return true;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isFileIdToken,
|
|
4
|
+
fileIdOf,
|
|
5
|
+
readFileValue,
|
|
6
|
+
readFileValues,
|
|
7
|
+
isImageValue,
|
|
8
|
+
fileValueForSubmit,
|
|
9
|
+
uploadResultView,
|
|
10
|
+
withRecentUploads,
|
|
11
|
+
} from './file-value';
|
|
12
|
+
|
|
13
|
+
const uploadResult = (over: Record<string, unknown> = {}) => ({
|
|
14
|
+
url: 'https://app.example.com/api/v1/storage/files/file_a',
|
|
15
|
+
name: 'a.png',
|
|
16
|
+
size: 1024,
|
|
17
|
+
mimeType: 'image/png',
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('isFileIdToken', () => {
|
|
22
|
+
it.each(['file_a', 'abc123', '0e2f4c1a-9b7d-4e3f-8a1b-2c3d4e5f6a7b', 'A-_9'])(
|
|
23
|
+
'accepts the minted id %s',
|
|
24
|
+
(v) => expect(isFileIdToken(v)).toBe(true),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
'https://cdn.example.com/a.png',
|
|
29
|
+
'/api/v1/storage/files/file_a',
|
|
30
|
+
'data:image/png;base64,aGk=',
|
|
31
|
+
'blob:http://localhost/abc',
|
|
32
|
+
'a.png',
|
|
33
|
+
'',
|
|
34
|
+
])('rejects the URL-shaped or dotted value %s', (v) => expect(isFileIdToken(v)).toBe(false));
|
|
35
|
+
|
|
36
|
+
it('rejects non-strings and over-long values', () => {
|
|
37
|
+
expect(isFileIdToken(null)).toBe(false);
|
|
38
|
+
expect(isFileIdToken(42)).toBe(false);
|
|
39
|
+
expect(isFileIdToken('a'.repeat(65))).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('fileIdOf', () => {
|
|
44
|
+
it('reads a bare reference', () => {
|
|
45
|
+
expect(fileIdOf('file_a')).toBe('file_a');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('prefers file_id, then id', () => {
|
|
49
|
+
expect(fileIdOf({ file_id: 'legacy', id: 'expanded' })).toBe('legacy');
|
|
50
|
+
expect(fileIdOf({ id: 'expanded' })).toBe('expanded');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns undefined when there is no id to read', () => {
|
|
54
|
+
expect(fileIdOf({ url: 'https://cdn.example.com/a.png', name: 'a.png' })).toBeUndefined();
|
|
55
|
+
expect(fileIdOf('https://cdn.example.com/a.png')).toBeUndefined();
|
|
56
|
+
expect(fileIdOf(null)).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('readFileValue', () => {
|
|
61
|
+
/**
|
|
62
|
+
* The casing split is the reason this module exists: the platform's expanded
|
|
63
|
+
* form is camelCase `mimeType`, the legacy inline blob is snake_case
|
|
64
|
+
* `mime_type`. A widget reading only one silently stops recognising images
|
|
65
|
+
* the moment the backend switches form.
|
|
66
|
+
*/
|
|
67
|
+
it('reads the expanded form (camelCase mimeType)', () => {
|
|
68
|
+
const view = readFileValue({
|
|
69
|
+
id: 'file_a',
|
|
70
|
+
name: 'a.png',
|
|
71
|
+
size: 1024,
|
|
72
|
+
mimeType: 'image/png',
|
|
73
|
+
url: '/api/v1/storage/files/file_a',
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(view).toMatchObject({
|
|
77
|
+
id: 'file_a',
|
|
78
|
+
name: 'a.png',
|
|
79
|
+
size: 1024,
|
|
80
|
+
mimeType: 'image/png',
|
|
81
|
+
url: '/api/v1/storage/files/file_a',
|
|
82
|
+
});
|
|
83
|
+
expect(isImageValue(view)).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('reads the legacy inline blob (snake_case mime_type)', () => {
|
|
87
|
+
const view = readFileValue({
|
|
88
|
+
file_id: 'file_a',
|
|
89
|
+
name: 'a.png',
|
|
90
|
+
original_name: 'original.png',
|
|
91
|
+
size: 1024,
|
|
92
|
+
mime_type: 'image/png',
|
|
93
|
+
url: 'https://cdn.example.com/a.png',
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(view).toMatchObject({ id: 'file_a', name: 'a.png', mimeType: 'image/png' });
|
|
97
|
+
expect(isImageValue(view)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('falls back to original_name when name is absent', () => {
|
|
101
|
+
expect(readFileValue({ original_name: 'orig.pdf' }).name).toBe('orig.pdf');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('derives a name from the URL when the value carries none', () => {
|
|
105
|
+
expect(readFileValue({ url: 'https://cdn.example.com/docs/report%20v2.pdf' }).name).toBe(
|
|
106
|
+
'report v2.pdf',
|
|
107
|
+
);
|
|
108
|
+
expect(readFileValue('https://cdn.example.com/a.png').name).toBe('a.png');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('resolves a bare reference to the stable download URL', () => {
|
|
112
|
+
const view = readFileValue('file_a', 'File');
|
|
113
|
+
|
|
114
|
+
// The read path may leave a value in its bare-reference form (seen on the
|
|
115
|
+
// edit-form data path); the stable endpoint is derivable from the id, so a
|
|
116
|
+
// thumbnail still renders instead of a broken `<img src="">`.
|
|
117
|
+
expect(view).toMatchObject({ id: 'file_a', name: 'File' });
|
|
118
|
+
expect(view.url).toBe('/api/v1/storage/files/file_a');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('derives the URL of an object that carries an id but no url', () => {
|
|
122
|
+
const view = readFileValue({ id: 'file_b', name: 'b.png' });
|
|
123
|
+
expect(view.url).toBe('/api/v1/storage/files/file_b');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('treats a URL string as a URL, never as a reference', () => {
|
|
127
|
+
const view = readFileValue('/api/v1/storage/files/file_a');
|
|
128
|
+
expect(view.id).toBeUndefined();
|
|
129
|
+
expect(view.url).toBe('/api/v1/storage/files/file_a');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('uses the supplied fallback name so widgets stay localised', () => {
|
|
133
|
+
expect(readFileValue({}, '文件').name).toBe('文件');
|
|
134
|
+
expect(readFileValue(null, '文件').name).toBe('文件');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('is not an image when the MIME type says otherwise or is missing', () => {
|
|
138
|
+
expect(isImageValue(readFileValue({ mimeType: 'application/pdf' }))).toBe(false);
|
|
139
|
+
expect(isImageValue(readFileValue({ name: 'a.png' }))).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('readFileValues', () => {
|
|
144
|
+
it('normalises single, array and empty values', () => {
|
|
145
|
+
expect(readFileValues(null)).toEqual([]);
|
|
146
|
+
expect(readFileValues([])).toEqual([]);
|
|
147
|
+
expect(readFileValues('file_a')).toHaveLength(1);
|
|
148
|
+
expect(readFileValues(['file_a', 'file_b'])).toHaveLength(2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('drops null entries inside an array', () => {
|
|
152
|
+
expect(readFileValues(['file_a', null, 'file_b'])).toHaveLength(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('handles a mixed-form array during the transition', () => {
|
|
156
|
+
const views = readFileValues([
|
|
157
|
+
'file_a',
|
|
158
|
+
{ id: 'file_b', name: 'b.png', mimeType: 'image/png' },
|
|
159
|
+
{ name: 'c.png', mime_type: 'image/png', url: 'https://cdn.example.com/c.png' },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
expect(views.map((v) => v.id)).toEqual(['file_a', 'file_b', undefined]);
|
|
163
|
+
expect(views.map(isImageValue)).toEqual([false, true, true]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('fileValueForSubmit', () => {
|
|
168
|
+
it('submits the bare reference when the adapter surfaced a fileId', () => {
|
|
169
|
+
expect(fileValueForSubmit(uploadResult({ meta: { fileId: 'file_a' } }), 'orig.png')).toBe(
|
|
170
|
+
'file_a',
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Back-compat: the object-URL fallback adapter, or a backend predating
|
|
176
|
+
* file-as-reference, surfaces no fileId. The same build must keep working
|
|
177
|
+
* there, so it submits the legacy blob unchanged.
|
|
178
|
+
*/
|
|
179
|
+
it('falls back to the legacy inline blob when there is no fileId', () => {
|
|
180
|
+
expect(fileValueForSubmit(uploadResult(), 'orig.png')).toEqual({
|
|
181
|
+
name: 'a.png',
|
|
182
|
+
original_name: 'orig.png',
|
|
183
|
+
size: 1024,
|
|
184
|
+
mime_type: 'image/png',
|
|
185
|
+
url: 'https://app.example.com/api/v1/storage/files/file_a',
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('ignores a meta.fileId that is not id-shaped', () => {
|
|
190
|
+
const v = fileValueForSubmit(uploadResult({ meta: { fileId: 'https://evil.example/x' } }));
|
|
191
|
+
expect(typeof v).toBe('object');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('round-trips through readFileValue in both modes', () => {
|
|
195
|
+
const asRef = fileValueForSubmit(uploadResult({ meta: { fileId: 'file_a' } }));
|
|
196
|
+
const asBlob = fileValueForSubmit(uploadResult(), 'orig.png');
|
|
197
|
+
|
|
198
|
+
expect(readFileValue(asRef).id).toBe('file_a');
|
|
199
|
+
expect(readFileValue(asBlob).mimeType).toBe('image/png');
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('withRecentUploads', () => {
|
|
204
|
+
it('fills in display details for a bare reference just uploaded', () => {
|
|
205
|
+
const view = uploadResultView(uploadResult({ meta: { fileId: 'file_a' } }), 'orig.png');
|
|
206
|
+
const merged = withRecentUploads(readFileValues('file_a'), { file_a: view });
|
|
207
|
+
|
|
208
|
+
expect(merged[0]).toMatchObject({
|
|
209
|
+
id: 'file_a',
|
|
210
|
+
name: 'a.png',
|
|
211
|
+
mimeType: 'image/png',
|
|
212
|
+
url: 'https://app.example.com/api/v1/storage/files/file_a',
|
|
213
|
+
});
|
|
214
|
+
// The raw value stays the reference — display enrichment must not change
|
|
215
|
+
// what the field submits.
|
|
216
|
+
expect(merged[0].raw).toBe('file_a');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('leaves a value the backend already expanded alone', () => {
|
|
220
|
+
const stale = uploadResultView(uploadResult({ name: 'stale.png', meta: { fileId: 'file_a' } }));
|
|
221
|
+
const expanded = readFileValues([{ id: 'file_a', name: 'fresh.png', url: '/u', mimeType: 'image/png' }]);
|
|
222
|
+
|
|
223
|
+
expect(withRecentUploads(expanded, { file_a: stale })[0].name).toBe('fresh.png');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('is a no-op with nothing remembered', () => {
|
|
227
|
+
const views = readFileValues('file_a');
|
|
228
|
+
expect(withRecentUploads(views, {})).toBe(views);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File field value shapes (ObjectStack ADR-0104 D3 wave 2).
|
|
3
|
+
*
|
|
4
|
+
* A `file`/`image`/`avatar`/`video`/`audio` field value reaches the UI in one
|
|
5
|
+
* of three forms, and every widget that renders one has to cope with all
|
|
6
|
+
* three — so the rules live here once rather than being re-derived per widget.
|
|
7
|
+
*
|
|
8
|
+
* 1. **Reference** — a bare `sys_file` id string. The form the backend stores
|
|
9
|
+
* once file-as-reference is adopted.
|
|
10
|
+
* 2. **Expanded** — `{ id, name, size, mimeType, url }`, what the read path
|
|
11
|
+
* returns after resolving a stored reference. Note `mimeType`: this comes
|
|
12
|
+
* from the platform's spec-owned `FileValueSchema` and is **camelCase**.
|
|
13
|
+
* 3. **Legacy inline blob** — `{ file_id?, name, original_name, size,
|
|
14
|
+
* mime_type, url }`, the pre-reference shape this package used to build
|
|
15
|
+
* itself, with **snake_case** `mime_type`.
|
|
16
|
+
*
|
|
17
|
+
* That casing split is the whole reason this module exists: a widget reading
|
|
18
|
+
* only `mime_type` silently stops recognising images the moment the backend
|
|
19
|
+
* starts returning the expanded form, and the failure looks like "thumbnails
|
|
20
|
+
* disappeared" rather than anything pointing at a value shape.
|
|
21
|
+
*
|
|
22
|
+
* ## Submitting
|
|
23
|
+
*
|
|
24
|
+
* When the upload adapter surfaced a `fileId`, the widget submits the **bare
|
|
25
|
+
* id** — the reference form. When it did not (the object-URL fallback adapter,
|
|
26
|
+
* an older backend), it submits the legacy blob unchanged. So the same build
|
|
27
|
+
* works against a backend that has adopted references and one that has not.
|
|
28
|
+
* Action params already POST a bare fileId; this brings record field values
|
|
29
|
+
* onto the same contract.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** A file value normalised for rendering, whatever form it arrived in. */
|
|
33
|
+
export interface FileValueView {
|
|
34
|
+
/** `sys_file` id, when the value carries one. */
|
|
35
|
+
id?: string;
|
|
36
|
+
/** Best available display name. Never empty. */
|
|
37
|
+
name: string;
|
|
38
|
+
/** Resolvable URL, when the value carries one. A bare reference does not. */
|
|
39
|
+
url?: string;
|
|
40
|
+
size?: number;
|
|
41
|
+
mimeType?: string;
|
|
42
|
+
/** The value as it arrived, so callers can pass it through untouched. */
|
|
43
|
+
raw: unknown;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A minted file id: uuid/nanoid-shaped, and crucially not a URL — a URL always
|
|
48
|
+
* carries `:`, `/` or `.`, so it can never match. Mirrors the platform's
|
|
49
|
+
* `isFileIdToken`, which is the arbiter on the server side of the same question.
|
|
50
|
+
*/
|
|
51
|
+
export function isFileIdToken(value: unknown): value is string {
|
|
52
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The `sys_file` id a value refers to, or `undefined`.
|
|
57
|
+
*
|
|
58
|
+
* `file_id ?? id` matches the rule `serializeParamValues` already applies to
|
|
59
|
+
* action params — one extraction rule for both surfaces.
|
|
60
|
+
*/
|
|
61
|
+
export function fileIdOf(value: unknown): string | undefined {
|
|
62
|
+
if (isFileIdToken(value)) return value;
|
|
63
|
+
if (value && typeof value === 'object') {
|
|
64
|
+
const o = value as Record<string, unknown>;
|
|
65
|
+
const id = o.file_id ?? o.id;
|
|
66
|
+
if (typeof id === 'string' && id) return id;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The stable download endpoint a bare `sys_file` id resolves to.
|
|
73
|
+
*
|
|
74
|
+
* `createObjectStackUploadAdapter` stores exactly this URL (`${basePath}/files/
|
|
75
|
+
* :id`, default base `/api/v1/storage`) as a completed upload's value, and the
|
|
76
|
+
* endpoint 302-redirects to a freshly-signed short-lived URL on every request —
|
|
77
|
+
* so it can be used directly as an `<img src>`. Building it here means a value
|
|
78
|
+
* still in its bare-reference form (the backend read path didn't expand it —
|
|
79
|
+
* seen on the edit-form data path, objectui image thumbnails rendering broken)
|
|
80
|
+
* resolves to a real URL instead of an empty `src`.
|
|
81
|
+
*/
|
|
82
|
+
export const FILE_STORAGE_BASE_PATH = '/api/v1/storage';
|
|
83
|
+
|
|
84
|
+
/** The stable download URL for a `sys_file` id. */
|
|
85
|
+
export function fileUrlFromId(id: string): string {
|
|
86
|
+
return `${FILE_STORAGE_BASE_PATH}/files/${encodeURIComponent(id)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Last path segment of a URL, used as a display name of last resort. */
|
|
90
|
+
function nameFromUrl(url: string): string {
|
|
91
|
+
const path = url.split(/[?#]/)[0] ?? url;
|
|
92
|
+
const seg = path.split('/').filter(Boolean).pop();
|
|
93
|
+
return seg ? decodeURIComponent(seg) : url;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Normalise one file value for display.
|
|
98
|
+
*
|
|
99
|
+
* @param fallbackName shown when the value carries no usable name — pass a
|
|
100
|
+
* translated string so the widget stays localised.
|
|
101
|
+
*/
|
|
102
|
+
export function readFileValue(value: unknown, fallbackName = 'File'): FileValueView {
|
|
103
|
+
if (value == null) return { name: fallbackName, raw: value };
|
|
104
|
+
|
|
105
|
+
if (typeof value === 'string') {
|
|
106
|
+
// A bare id carries no name of its own (the backend expands it on read),
|
|
107
|
+
// but its URL is derivable from the stable download endpoint — so a value
|
|
108
|
+
// the read path left as a bare reference still renders a thumbnail instead
|
|
109
|
+
// of a broken `<img src="">`.
|
|
110
|
+
if (isFileIdToken(value)) return { id: value, name: fallbackName, url: fileUrlFromId(value), raw: value };
|
|
111
|
+
// Otherwise it is a URL (legacy external link, data:, blob:).
|
|
112
|
+
return { url: value, name: nameFromUrl(value), raw: value };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (typeof value === 'object') {
|
|
116
|
+
const o = value as Record<string, unknown>;
|
|
117
|
+
const str = (v: unknown) => (typeof v === 'string' && v ? v : undefined);
|
|
118
|
+
const id = fileIdOf(o);
|
|
119
|
+
// An object shape that carries an id but not its URL (a partially-expanded
|
|
120
|
+
// reference) still resolves to the stable endpoint, same as a bare id.
|
|
121
|
+
const url = str(o.url) ?? (id ? fileUrlFromId(id) : undefined);
|
|
122
|
+
const name =
|
|
123
|
+
str(o.name) ??
|
|
124
|
+
str(o.original_name) ??
|
|
125
|
+
(str(o.url) ? nameFromUrl(str(o.url) as string) : undefined) ??
|
|
126
|
+
fallbackName;
|
|
127
|
+
return {
|
|
128
|
+
id,
|
|
129
|
+
name,
|
|
130
|
+
url,
|
|
131
|
+
size: typeof o.size === 'number' ? o.size : undefined,
|
|
132
|
+
// Expanded form is camelCase, legacy blob is snake_case — accept both.
|
|
133
|
+
mimeType: str(o.mimeType) ?? str(o.mime_type),
|
|
134
|
+
raw: value,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { name: fallbackName, raw: value };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Normalise a field value (single or `multiple`) to an array of views. */
|
|
142
|
+
export function readFileValues(value: unknown, fallbackName = 'File'): FileValueView[] {
|
|
143
|
+
if (value == null) return [];
|
|
144
|
+
const items = Array.isArray(value) ? value : [value];
|
|
145
|
+
return items.filter((v) => v != null).map((v) => readFileValue(v, fallbackName));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Does this value look like an image, by MIME type? */
|
|
149
|
+
export function isImageValue(view: FileValueView): boolean {
|
|
150
|
+
return (view.mimeType ?? '').startsWith('image/');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The subset of an upload result these helpers read. */
|
|
154
|
+
export interface UploadResultLike {
|
|
155
|
+
url: string;
|
|
156
|
+
name: string;
|
|
157
|
+
size: number;
|
|
158
|
+
mimeType: string;
|
|
159
|
+
meta?: Record<string, unknown>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* What to store in the field for a completed upload.
|
|
164
|
+
*
|
|
165
|
+
* Returns the bare `sys_file` id when the adapter surfaced one — the reference
|
|
166
|
+
* form — and the legacy inline blob when it did not, so a deployment whose
|
|
167
|
+
* upload adapter or backend predates file-as-reference keeps working unchanged.
|
|
168
|
+
*/
|
|
169
|
+
export function fileValueForSubmit(
|
|
170
|
+
result: UploadResultLike,
|
|
171
|
+
originalName?: string,
|
|
172
|
+
): string | Record<string, unknown> {
|
|
173
|
+
const fileId = (result.meta as { fileId?: unknown } | undefined)?.fileId;
|
|
174
|
+
if (isFileIdToken(fileId)) return fileId;
|
|
175
|
+
return {
|
|
176
|
+
name: result.name,
|
|
177
|
+
original_name: originalName ?? result.name,
|
|
178
|
+
size: result.size,
|
|
179
|
+
mime_type: result.mimeType,
|
|
180
|
+
url: result.url,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The display view of a just-completed upload.
|
|
186
|
+
*
|
|
187
|
+
* Submitting a bare id means the field value no longer carries the name, size
|
|
188
|
+
* or URL needed to render it, and the enriched form only comes back on the next
|
|
189
|
+
* read. Widgets keep these views keyed by id so an upload appears immediately
|
|
190
|
+
* instead of showing a bare token until a refetch.
|
|
191
|
+
*/
|
|
192
|
+
export function uploadResultView(result: UploadResultLike, originalName?: string): FileValueView {
|
|
193
|
+
const id = isFileIdToken((result.meta as { fileId?: unknown } | undefined)?.fileId)
|
|
194
|
+
? ((result.meta as { fileId?: string }).fileId as string)
|
|
195
|
+
: undefined;
|
|
196
|
+
return {
|
|
197
|
+
id,
|
|
198
|
+
name: result.name || originalName || 'File',
|
|
199
|
+
url: result.url,
|
|
200
|
+
size: result.size,
|
|
201
|
+
mimeType: result.mimeType,
|
|
202
|
+
raw: result,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Merge locally-known upload views over a field value's own views, matched by
|
|
208
|
+
* id. A value that is still a bare reference picks up the name/URL captured at
|
|
209
|
+
* upload time; anything already enriched by the backend is left alone.
|
|
210
|
+
*/
|
|
211
|
+
export function withRecentUploads(
|
|
212
|
+
views: FileValueView[],
|
|
213
|
+
recent: Record<string, FileValueView>,
|
|
214
|
+
): FileValueView[] {
|
|
215
|
+
if (Object.keys(recent).length === 0) return views;
|
|
216
|
+
return views.map((v) => {
|
|
217
|
+
const known = v.id ? recent[v.id] : undefined;
|
|
218
|
+
if (!known) return v;
|
|
219
|
+
// A value the backend already expanded carries its own URL on the raw
|
|
220
|
+
// object — leave that alone. A bare reference (string id, or an id-only
|
|
221
|
+
// object) has only the synthetic download URL `readFileValue` derived, so
|
|
222
|
+
// the just-uploaded name/mimeType still fills in.
|
|
223
|
+
const rawHasOwnUrl =
|
|
224
|
+
!!v.raw &&
|
|
225
|
+
typeof v.raw === 'object' &&
|
|
226
|
+
typeof (v.raw as Record<string, unknown>).url === 'string' &&
|
|
227
|
+
!!(v.raw as Record<string, unknown>).url;
|
|
228
|
+
return rawHasOwnUrl ? v : { ...known, raw: v.raw };
|
|
229
|
+
});
|
|
230
|
+
}
|
package/src/widgets/types.ts
CHANGED
|
@@ -9,5 +9,11 @@ export type FieldWidgetProps<T = any> = {
|
|
|
9
9
|
disabled?: boolean;
|
|
10
10
|
className?: string;
|
|
11
11
|
errorMessage?: string;
|
|
12
|
+
/**
|
|
13
|
+
* Upload widgets (`file`/`image`) fire this when their in-progress state
|
|
14
|
+
* flips, so a host can block submit until a presigned upload settles. Other
|
|
15
|
+
* widgets ignore it.
|
|
16
|
+
*/
|
|
17
|
+
onUploadingChange?: (uploading: boolean) => void;
|
|
12
18
|
[key: string]: any;
|
|
13
19
|
}
|