@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +432 -0
  2. package/dist/FieldEditWidget.d.ts +7 -0
  3. package/dist/field-type-alias.d.ts +16 -0
  4. package/dist/index.cjs +5 -5
  5. package/dist/index.d.ts +14 -11
  6. package/dist/index.js +2428 -2100
  7. package/dist/widgets/FilterConditionField.d.ts +32 -0
  8. package/dist/widgets/ImageLightbox.d.ts +30 -0
  9. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  10. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  11. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  12. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  13. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  14. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  15. package/dist/widgets/file-value.d.ts +112 -0
  16. package/dist/widgets/file-value.test.d.ts +1 -0
  17. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  18. package/package.json +9 -8
  19. package/src/FieldEditWidget.test.ts +74 -2
  20. package/src/FieldEditWidget.tsx +44 -4
  21. package/src/field-type-alias.ts +100 -0
  22. package/src/index.tsx +94 -133
  23. package/src/widgets/CheckboxesField.tsx +8 -5
  24. package/src/widgets/FileField.tsx +88 -57
  25. package/src/widgets/FilterConditionField.tsx +109 -9
  26. package/src/widgets/ImageField.tsx +71 -33
  27. package/src/widgets/ImageLightbox.test.tsx +52 -0
  28. package/src/widgets/ImageLightbox.tsx +108 -0
  29. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  30. package/src/widgets/LookupField.idField.test.tsx +61 -0
  31. package/src/widgets/LookupField.tsx +106 -9
  32. package/src/widgets/MultiSelectField.tsx +6 -3
  33. package/src/widgets/ObjectRefField.tsx +9 -4
  34. package/src/widgets/PeoplePicker.test.tsx +20 -0
  35. package/src/widgets/PeoplePicker.tsx +15 -3
  36. package/src/widgets/RadioField.tsx +6 -3
  37. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  38. package/src/widgets/RecipientPickerField.tsx +36 -16
  39. package/src/widgets/TextAreaField.tsx +4 -1
  40. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  41. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  42. package/src/widgets/file-value.test.ts +230 -0
  43. package/src/widgets/file-value.ts +230 -0
  44. 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 type changes AFTER mount (an id for a
61
- // user is not a valid team/business-unit id). The ref starts null so the
62
- // initial render of an existing rule never clears its value.
63
- const prevType = React.useRef<string | null>(null);
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 !== null && prevType.current !== recipientType && value) {
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
- const res = await dataSource.find(mapping.object, { $top: 500, $orderby: 'name asc' });
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
- Select a recipient type first.
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 ? 'Loading…' : `Select a ${recipientType.replace(/_/g, ' ')}`
158
+ records === null
159
+ ? t('fields.recipient.loading')
160
+ : t(mapping.placeholderKey ?? 'fields.recipient.select')
141
161
  }
142
- searchPlaceholder="Search…"
143
- emptyText={records === null ? 'Loading…' : 'No matches'}
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
- const maxLength = textareaField?.max_length;
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
+ });