@object-ui/fields 16.1.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 +432 -0
- 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 +14 -11
- package/dist/index.js +2428 -2100
- package/dist/widgets/FilterConditionField.d.ts +32 -0
- 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/RecipientPickerField.test.d.ts +0 -0
- 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 +112 -0
- package/dist/widgets/file-value.test.d.ts +1 -0
- package/dist/widgets/useFieldTranslation.d.ts +2 -0
- package/package.json +9 -8
- package/src/FieldEditWidget.test.ts +74 -2
- package/src/FieldEditWidget.tsx +44 -4
- package/src/field-type-alias.ts +100 -0
- package/src/index.tsx +94 -133
- package/src/widgets/CheckboxesField.tsx +8 -5
- package/src/widgets/FileField.tsx +88 -57
- package/src/widgets/FilterConditionField.tsx +109 -9
- package/src/widgets/ImageField.tsx +71 -33
- 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.tsx +6 -3
- 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.tsx +6 -3
- package/src/widgets/RecipientPickerField.test.tsx +157 -0
- package/src/widgets/RecipientPickerField.tsx +36 -16
- package/src/widgets/TextAreaField.tsx +4 -1
- 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 +230 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
|
@@ -2,6 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import { Combobox, EmptyValue, cn } from '@object-ui/components';
|
|
3
3
|
import { SchemaRendererContext } from '@object-ui/react';
|
|
4
4
|
import type { FieldWidgetProps } from './types';
|
|
5
|
+
import { useFieldTranslation } from './useFieldTranslation';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* RecipientPickerField — dependent record picker for a polymorphic recipient
|
|
@@ -31,14 +32,20 @@ interface RecipientMapping {
|
|
|
31
32
|
storeField: 'id' | 'name';
|
|
32
33
|
/** Candidate display-label fields, in preference order. */
|
|
33
34
|
labelFields: string[];
|
|
35
|
+
/**
|
|
36
|
+
* i18n key for the "choose one" placeholder. Keyed per type rather than
|
|
37
|
+
* interpolating the enum value into an English sentence — "Select a
|
|
38
|
+
* business unit" and "选择业务单元" share no structure (objectstack#3821).
|
|
39
|
+
*/
|
|
40
|
+
placeholderKey: string;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
|
|
37
|
-
user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'] },
|
|
38
|
-
team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'] },
|
|
39
|
-
business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
|
|
40
|
-
unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'] },
|
|
41
|
-
position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'] },
|
|
44
|
+
user: { object: 'sys_user', storeField: 'id', labelFields: ['name', 'full_name', 'email'], placeholderKey: 'fields.recipient.selectUser' },
|
|
45
|
+
team: { object: 'sys_team', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectTeam' },
|
|
46
|
+
business_unit: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectBusinessUnit' },
|
|
47
|
+
unit_and_subordinates: { object: 'sys_business_unit', storeField: 'id', labelFields: ['name', 'label'], placeholderKey: 'fields.recipient.selectUnitAndSubordinates' },
|
|
48
|
+
position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
|
|
42
49
|
};
|
|
43
50
|
|
|
44
51
|
export function RecipientPickerField({
|
|
@@ -49,6 +56,7 @@ export function RecipientPickerField({
|
|
|
49
56
|
...props
|
|
50
57
|
}: FieldWidgetProps<string>) {
|
|
51
58
|
const ctx = React.useContext(SchemaRendererContext);
|
|
59
|
+
const { t } = useFieldTranslation();
|
|
52
60
|
const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
53
61
|
const disabled = (props as any).disabled as boolean | undefined;
|
|
54
62
|
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
@@ -57,12 +65,18 @@ export function RecipientPickerField({
|
|
|
57
65
|
|
|
58
66
|
const [records, setRecords] = React.useState<any[] | null>(null);
|
|
59
67
|
|
|
60
|
-
// Reset the stored recipient when the
|
|
61
|
-
// user is not a valid team/business-unit id).
|
|
62
|
-
//
|
|
63
|
-
|
|
68
|
+
// Reset the stored recipient when the admin PICKS a different type (an id for
|
|
69
|
+
// a user is not a valid team/business-unit id).
|
|
70
|
+
//
|
|
71
|
+
// "Different type" means one non-empty type replacing another. The empty
|
|
72
|
+
// string is not a type — it is the edit form before the record has hydrated,
|
|
73
|
+
// and treating `'' → 'user'` as a change wiped the saved recipient the moment
|
|
74
|
+
// an existing rule was opened for editing (objectstack#3821): the picker went
|
|
75
|
+
// blank, and saving persisted the blank. Only a non-empty predecessor can
|
|
76
|
+
// invalidate the stored id.
|
|
77
|
+
const prevType = React.useRef<string>('');
|
|
64
78
|
React.useEffect(() => {
|
|
65
|
-
if (prevType.current
|
|
79
|
+
if (prevType.current && recipientType && prevType.current !== recipientType && value) {
|
|
66
80
|
onChange('' as any);
|
|
67
81
|
}
|
|
68
82
|
prevType.current = recipientType;
|
|
@@ -75,7 +89,11 @@ export function RecipientPickerField({
|
|
|
75
89
|
let cancelled = false;
|
|
76
90
|
(async () => {
|
|
77
91
|
try {
|
|
78
|
-
|
|
92
|
+
// Object form, not the 'name asc' clause string: the clause string is
|
|
93
|
+
// supported again (objectstack#3821 fixed ApiDataSource walking it
|
|
94
|
+
// character by character), but the structured form can't regress that
|
|
95
|
+
// way for any data source.
|
|
96
|
+
const res = await dataSource.find(mapping.object, { $top: 500, $orderby: { name: 'asc' } });
|
|
79
97
|
const list: any[] = res?.data ?? res?.records ?? (Array.isArray(res) ? res : []);
|
|
80
98
|
if (!cancelled) setRecords(Array.isArray(list) ? list : []);
|
|
81
99
|
} catch {
|
|
@@ -105,7 +123,7 @@ export function RecipientPickerField({
|
|
|
105
123
|
if (!recipientType) {
|
|
106
124
|
return (
|
|
107
125
|
<p className={cn('text-sm text-muted-foreground', className)}>
|
|
108
|
-
|
|
126
|
+
{t('fields.recipient.selectTypeFirst')}
|
|
109
127
|
</p>
|
|
110
128
|
);
|
|
111
129
|
}
|
|
@@ -137,12 +155,14 @@ export function RecipientPickerField({
|
|
|
137
155
|
value={value ?? ''}
|
|
138
156
|
onValueChange={(v) => onChange(v as any)}
|
|
139
157
|
placeholder={
|
|
140
|
-
records === null
|
|
158
|
+
records === null
|
|
159
|
+
? t('fields.recipient.loading')
|
|
160
|
+
: t(mapping.placeholderKey ?? 'fields.recipient.select')
|
|
141
161
|
}
|
|
142
|
-
searchPlaceholder=
|
|
143
|
-
emptyText={records === null ? '
|
|
162
|
+
searchPlaceholder={t('fields.recipient.search')}
|
|
163
|
+
emptyText={records === null ? t('fields.recipient.loading') : t('fields.recipient.empty')}
|
|
144
164
|
disabled={disabled}
|
|
145
|
-
className={className}
|
|
165
|
+
className={cn('w-full', className)}
|
|
146
166
|
/>
|
|
147
167
|
);
|
|
148
168
|
}
|
|
@@ -36,7 +36,10 @@ export function TextAreaField({ value, onChange, field, readonly, errorMessage,
|
|
|
36
36
|
|
|
37
37
|
const textareaField = (field || (props as any).schema) as any;
|
|
38
38
|
const rows = textareaField?.rows || 4;
|
|
39
|
-
|
|
39
|
+
// Spec FieldSchema declares camelCase `maxLength`; `max_length` is the legacy
|
|
40
|
+
// objectui spelling. Dual-read (framework#1878 §3 recheck) — without this a
|
|
41
|
+
// spec-authored maxLength gave neither the textarea cap nor the counter.
|
|
42
|
+
const maxLength = textareaField?.maxLength ?? textareaField?.max_length;
|
|
40
43
|
// Mobile fullscreen flag may arrive on the field metadata, on the form-field
|
|
41
44
|
// schema (when called via the form renderer where `field` is the ObjectQL
|
|
42
45
|
// metadata sub-object), or as an explicit widget prop.
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
* Empty-criteria guard for the sharing-rule criteria builder
|
|
11
|
+
* (objectstack#3896).
|
|
12
|
+
*
|
|
13
|
+
* This widget used to tell an admin that an empty criteria meant "All
|
|
14
|
+
* records" — the most dangerous thing it could say, because it was describing
|
|
15
|
+
* a bug as a feature. A sharing rule with no predicate was stored as
|
|
16
|
+
* `criteria_json: null` and then evaluated as `find(object, { filter: {} })`,
|
|
17
|
+
* i.e. every record of the object granted to the recipient. `SharingRuleSchema`
|
|
18
|
+
* had always forbidden that shape ("never seeded as a permissive match-all",
|
|
19
|
+
* ADR-0049); the REST and data-API entries just never checked.
|
|
20
|
+
*
|
|
21
|
+
* The server now refuses to save such a rule and shares nothing for one
|
|
22
|
+
* already stored. These tests pin the client-side mirror of that predicate, so
|
|
23
|
+
* the widget warns while the admin is still looking at the empty builder
|
|
24
|
+
* rather than after a rejected save.
|
|
25
|
+
*/
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { isMatchAllCriteria } from '../FilterConditionField';
|
|
28
|
+
|
|
29
|
+
describe('isMatchAllCriteria — shapes that would share every record', () => {
|
|
30
|
+
it('flags the empty and vacuous shapes', () => {
|
|
31
|
+
for (const shape of [
|
|
32
|
+
undefined,
|
|
33
|
+
null,
|
|
34
|
+
{}, // what a blank builder / blank textarea parses to
|
|
35
|
+
[],
|
|
36
|
+
true,
|
|
37
|
+
42,
|
|
38
|
+
{ $and: [] },
|
|
39
|
+
{ $or: [] },
|
|
40
|
+
{ $and: [{}] },
|
|
41
|
+
{ $or: [{ stage: 'won' }, {}] }, // one unconstrained branch opens the OR
|
|
42
|
+
]) {
|
|
43
|
+
expect(isMatchAllCriteria(shape), `${JSON.stringify(shape) ?? String(shape)}`).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('passes anything that actually narrows the result set', () => {
|
|
48
|
+
for (const shape of [
|
|
49
|
+
{ stage: 'won' },
|
|
50
|
+
{ amount: { $gte: 100000 } },
|
|
51
|
+
[{ stage: 'won' }],
|
|
52
|
+
{ $and: [{ stage: 'won' }] },
|
|
53
|
+
{ $or: [{ a: 1 }, { b: 2 }] },
|
|
54
|
+
{ health: 'red', budget: { $gt: 1 } },
|
|
55
|
+
]) {
|
|
56
|
+
expect(isMatchAllCriteria(shape), JSON.stringify(shape)).toBe(false);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('agrees with the server on the reported case: a rule saved with no criteria', () => {
|
|
61
|
+
// The builder emits '' for an empty group, which the widget parses to {}.
|
|
62
|
+
expect(isMatchAllCriteria({})).toBe(true);
|
|
63
|
+
// …and one real condition is enough to clear the gate.
|
|
64
|
+
expect(isMatchAllCriteria({ health: 'red' })).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|