@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 { FilterBuilder, 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
|
* FilterConditionField — visual criteria builder for a stored FilterCondition
|
|
@@ -101,7 +102,13 @@ function toArray(value: any): any[] {
|
|
|
101
102
|
return value == null ? [] : [value];
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Builder condition → `$`-operator criteria. Exported for tests: this is the
|
|
107
|
+
* chokepoint where a builder token becomes a spec `FieldOperatorsSchema` key,
|
|
108
|
+
* and a wrong spelling here is rejected downstream by `convertFiltersToAST`
|
|
109
|
+
* rather than at authoring time. @internal
|
|
110
|
+
*/
|
|
111
|
+
export function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
|
|
105
112
|
const { field, operator, value } = c || ({} as BuilderCondition);
|
|
106
113
|
if (!field) return null;
|
|
107
114
|
const t = typeOf(field);
|
|
@@ -110,9 +117,23 @@ function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefi
|
|
|
110
117
|
case 'equals': return { [field]: cv };
|
|
111
118
|
case 'notEquals': return { [field]: { $ne: cv } };
|
|
112
119
|
case 'contains': return { [field]: { $contains: value } };
|
|
113
|
-
|
|
120
|
+
// `$notContains` is the spec spelling (FieldOperatorsSchema, data/filter.zod.ts).
|
|
121
|
+
// This emitted `$ncontains` — a token that appears nowhere in @objectstack/spec and
|
|
122
|
+
// that convertFiltersToAST throws on, so every "does not contain" rule authored here
|
|
123
|
+
// was rejected downstream. See kvToCondition for reading the old spelling back.
|
|
124
|
+
case 'notContains': return { [field]: { $notContains: value } };
|
|
125
|
+
// String-specific spec operators — previously unreachable from the
|
|
126
|
+
// builder UI even though FieldOperatorsSchema accepts them (#2942).
|
|
127
|
+
case 'startsWith': return { [field]: { $startsWith: value } };
|
|
128
|
+
case 'endsWith': return { [field]: { $endsWith: value } };
|
|
114
129
|
case 'isEmpty': return { [field]: { $in: [null, ''] } };
|
|
115
130
|
case 'isNotEmpty': return { [field]: { $nin: [null, ''] } };
|
|
131
|
+
// Null / existence spec operators. Distinct from isEmpty/isNotEmpty,
|
|
132
|
+
// which also treat '' as empty.
|
|
133
|
+
case 'isNull': return { [field]: { $null: true } };
|
|
134
|
+
case 'isNotNull': return { [field]: { $null: false } };
|
|
135
|
+
case 'exists': return { [field]: { $exists: true } };
|
|
136
|
+
case 'notExists': return { [field]: { $exists: false } };
|
|
116
137
|
case 'greaterThan':
|
|
117
138
|
case 'after': return { [field]: { $gt: cv } };
|
|
118
139
|
case 'lessThan':
|
|
@@ -133,7 +154,11 @@ function filterGroupToMongo(group: BuilderGroup, typeOf: (f: string) => string |
|
|
|
133
154
|
const frags = (group?.conditions ?? [])
|
|
134
155
|
.map((c) => condToMongo(c, typeOf))
|
|
135
156
|
.filter((x): x is Record<string, any> => !!x);
|
|
136
|
-
|
|
157
|
+
// No conditions → no predicate. NOT "match all": a sharing rule with an
|
|
158
|
+
// empty criteria is refused on save, and one already stored shares nothing
|
|
159
|
+
// (objectstack#3896 / ADR-0049). `null` becomes an empty stored value, which
|
|
160
|
+
// is what the required-criteria messaging below keys off.
|
|
161
|
+
if (frags.length === 0) return null;
|
|
137
162
|
if (group.logic === 'or') return { $or: frags };
|
|
138
163
|
if (frags.length === 1) return frags[0];
|
|
139
164
|
const keys = frags.flatMap((f) => Object.keys(f));
|
|
@@ -145,7 +170,13 @@ function arraysEqual(a: any, b: any[]): boolean {
|
|
|
145
170
|
return Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
|
|
146
171
|
}
|
|
147
172
|
|
|
148
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Criteria → builder condition (the reverse of {@link condToMongo}). Returning
|
|
175
|
+
* `null` makes the builder refuse to load the rule ("criteria can't be
|
|
176
|
+
* represented"), so this must keep accepting spellings previously written.
|
|
177
|
+
* @internal
|
|
178
|
+
*/
|
|
179
|
+
export function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
|
|
149
180
|
const id = `c_${idx}_${field}`;
|
|
150
181
|
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
|
|
151
182
|
return { id, field, operator: 'equals', value: v };
|
|
@@ -157,11 +188,19 @@ function kvToCondition(field: string, v: any, idx: number): BuilderCondition | n
|
|
|
157
188
|
switch (op) {
|
|
158
189
|
case '$ne': return { id, field, operator: 'notEquals', value: val };
|
|
159
190
|
case '$contains': return { id, field, operator: 'contains', value: val };
|
|
191
|
+
// `$ncontains` is the pre-fix spelling this widget used to emit. Criteria saved
|
|
192
|
+
// before the fix still carry it, so keep reading it — dropping it here would make
|
|
193
|
+
// those rules fail to load ("criteria can't be represented") instead of migrating.
|
|
194
|
+
case '$notContains':
|
|
160
195
|
case '$ncontains': return { id, field, operator: 'notContains', value: val };
|
|
161
196
|
case '$gt': return { id, field, operator: 'greaterThan', value: val };
|
|
162
197
|
case '$lt': return { id, field, operator: 'lessThan', value: val };
|
|
163
198
|
case '$gte': return { id, field, operator: 'greaterOrEqual', value: val };
|
|
164
199
|
case '$lte': return { id, field, operator: 'lessOrEqual', value: val };
|
|
200
|
+
case '$startsWith': return { id, field, operator: 'startsWith', value: val };
|
|
201
|
+
case '$endsWith': return { id, field, operator: 'endsWith', value: val };
|
|
202
|
+
case '$null': return { id, field, operator: val === false ? 'isNotNull' : 'isNull', value: '' };
|
|
203
|
+
case '$exists': return { id, field, operator: val === false ? 'notExists' : 'exists', value: '' };
|
|
165
204
|
case '$in':
|
|
166
205
|
return arraysEqual(val, [null, ''])
|
|
167
206
|
? { id, field, operator: 'isEmpty', value: '' }
|
|
@@ -212,6 +251,37 @@ function mongoToFilterGroup(mongo: any): BuilderGroup | null {
|
|
|
212
251
|
return { id: 'root', logic: 'and', conditions };
|
|
213
252
|
}
|
|
214
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Would this criteria select EVERY record of the object?
|
|
256
|
+
*
|
|
257
|
+
* Mirrors the server's `isMatchAllCriteria` (objectstack `plugin-sharing`,
|
|
258
|
+
* #3896) closely enough to warn before the round-trip: blank, `{}`, `[]`, and
|
|
259
|
+
* the vacuous combinators. Deliberately conservative in the same direction —
|
|
260
|
+
* the cost of a false positive is one extra hint, the cost of a false negative
|
|
261
|
+
* is a save that fails with a toast. The server stays authoritative.
|
|
262
|
+
*
|
|
263
|
+
* @internal exported for tests
|
|
264
|
+
*/
|
|
265
|
+
export function isMatchAllCriteria(parsed: any): boolean {
|
|
266
|
+
if (parsed == null) return true;
|
|
267
|
+
if (Array.isArray(parsed)) return parsed.every(isMatchAllCriteria);
|
|
268
|
+
if (typeof parsed !== 'object') return true;
|
|
269
|
+
const entries = Object.entries(parsed);
|
|
270
|
+
if (entries.length === 0) return true;
|
|
271
|
+
for (const [key, value] of entries) {
|
|
272
|
+
if (key === '$and') {
|
|
273
|
+
if (!Array.isArray(value) || value.every(isMatchAllCriteria)) continue;
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
if (key === '$or') {
|
|
277
|
+
if (!Array.isArray(value) || value.length === 0 || value.some(isMatchAllCriteria)) continue;
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
215
285
|
function stringifyValue(value: string | object | undefined | null): string {
|
|
216
286
|
if (value == null || value === '') return '';
|
|
217
287
|
if (typeof value === 'string') return value;
|
|
@@ -230,6 +300,7 @@ export function FilterConditionField({
|
|
|
230
300
|
...props
|
|
231
301
|
}: FieldWidgetProps<string | object>) {
|
|
232
302
|
const ctx = React.useContext(SchemaRendererContext);
|
|
303
|
+
const { t } = useFieldTranslation();
|
|
233
304
|
const dataSource: any = (props as any).dataSource ?? (ctx as any)?.dataSource ?? null;
|
|
234
305
|
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
|
|
235
306
|
const objectName = String(dependentValues.object_name ?? '');
|
|
@@ -269,6 +340,13 @@ export function FilterConditionField({
|
|
|
269
340
|
[parsed],
|
|
270
341
|
);
|
|
271
342
|
|
|
343
|
+
// Only flag a criteria that PARSES to match-all. Unparsable JSON has its own
|
|
344
|
+
// message (`invalidJson`) and must not collect a second, contradictory one.
|
|
345
|
+
const isEmptyCriteria = React.useMemo(
|
|
346
|
+
() => parsed.ok && isMatchAllCriteria(parsed.mongo),
|
|
347
|
+
[parsed],
|
|
348
|
+
);
|
|
349
|
+
|
|
272
350
|
// Raw JSON mode: forced when the stored value can't be represented in the
|
|
273
351
|
// builder; otherwise opt-in via the toggle.
|
|
274
352
|
const representable = parsed.ok && group !== null;
|
|
@@ -290,14 +368,22 @@ export function FilterConditionField({
|
|
|
290
368
|
if (!objectName) {
|
|
291
369
|
return (
|
|
292
370
|
<p className={cn('text-sm text-muted-foreground', className)}>
|
|
293
|
-
|
|
371
|
+
{t('fields.filterCondition.selectObjectFirst')}
|
|
294
372
|
</p>
|
|
295
373
|
);
|
|
296
374
|
}
|
|
297
375
|
|
|
298
376
|
if (readonly) {
|
|
299
377
|
if (!rawValue.trim()) {
|
|
300
|
-
|
|
378
|
+
// Used to read "All records" — which was both wrong and the most
|
|
379
|
+
// dangerous thing this widget could say (objectstack#3896). A rule with
|
|
380
|
+
// no criteria has never usefully shared everything; it now shares
|
|
381
|
+
// nothing and is refused on save, so name that instead.
|
|
382
|
+
return (
|
|
383
|
+
<span className={cn('text-sm text-destructive', className)}>
|
|
384
|
+
{t('fields.filterCondition.noCriteria')}
|
|
385
|
+
</span>
|
|
386
|
+
);
|
|
301
387
|
}
|
|
302
388
|
return (
|
|
303
389
|
<pre className={cn('overflow-x-auto rounded bg-muted/40 p-2 text-xs', className)}>
|
|
@@ -317,7 +403,7 @@ export function FilterConditionField({
|
|
|
317
403
|
onChange={(e) => onChange(e.target.value as any)}
|
|
318
404
|
/>
|
|
319
405
|
{!parsed.ok && (
|
|
320
|
-
<span className="text-xs text-destructive">
|
|
406
|
+
<span className="text-xs text-destructive">{t('fields.filterCondition.invalidJson')}</span>
|
|
321
407
|
)}
|
|
322
408
|
</>
|
|
323
409
|
) : (
|
|
@@ -327,14 +413,28 @@ export function FilterConditionField({
|
|
|
327
413
|
onChange={handleBuilderChange as any}
|
|
328
414
|
/>
|
|
329
415
|
)}
|
|
416
|
+
{/*
|
|
417
|
+
The server refuses to save a criteria that would select every record
|
|
418
|
+
(objectstack#3896), but that rejection only arrives as a toast after
|
|
419
|
+
the admin hits Save. Say it here, while they are still looking at the
|
|
420
|
+
empty builder — and never imply that leaving it empty means "share
|
|
421
|
+
everything", which is what this widget used to do.
|
|
422
|
+
*/}
|
|
423
|
+
{isEmptyCriteria && (
|
|
424
|
+
<span className="text-xs text-destructive">
|
|
425
|
+
{t('fields.filterCondition.criteriaRequired')}
|
|
426
|
+
</span>
|
|
427
|
+
)}
|
|
330
428
|
<button
|
|
331
429
|
type="button"
|
|
332
430
|
className="self-start text-xs text-muted-foreground underline-offset-2 hover:underline"
|
|
333
431
|
onClick={() => setRawMode((m) => !m)}
|
|
334
432
|
disabled={!representable && !rawMode}
|
|
335
|
-
title={!representable ? '
|
|
433
|
+
title={!representable ? t('fields.filterCondition.jsonOnly') : undefined}
|
|
336
434
|
>
|
|
337
|
-
{rawMode
|
|
435
|
+
{rawMode
|
|
436
|
+
? t('fields.filterCondition.useVisualBuilder')
|
|
437
|
+
: t('fields.filterCondition.editAsJson')}
|
|
338
438
|
</button>
|
|
339
439
|
</div>
|
|
340
440
|
);
|
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import React, { useRef, useState, useCallback, lazy, Suspense } from 'react';
|
|
2
2
|
import { Button, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { useUpload } from '@object-ui/providers';
|
|
4
|
-
import {
|
|
4
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
5
|
+
import { X, Image as ImageIcon, Crop as CropIcon, Loader2 } from 'lucide-react';
|
|
5
6
|
import { FieldWidgetProps } from './types';
|
|
7
|
+
import { ImageLightbox } from './ImageLightbox';
|
|
6
8
|
import { useUploadingSignal } from './useUploadingSignal';
|
|
9
|
+
import {
|
|
10
|
+
fileValueForSubmit,
|
|
11
|
+
readFileValues,
|
|
12
|
+
uploadResultView,
|
|
13
|
+
withRecentUploads,
|
|
14
|
+
type FileValueView,
|
|
15
|
+
} from './file-value';
|
|
7
16
|
|
|
8
17
|
// Lazy-load the cropper so the dialog (canvas + crop logic) is not in the initial
|
|
9
18
|
// ImageField bundle. Consumers that never crop pay zero cost.
|
|
@@ -25,13 +34,26 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
25
34
|
*/
|
|
26
35
|
const cropEnabled = imageField?.crop !== false;
|
|
27
36
|
const [cropTarget, setCropTarget] = useState<{ index: number; src: string; name: string } | null>(null);
|
|
37
|
+
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
28
38
|
const { upload } = useUpload();
|
|
39
|
+
const { t } = useObjectTranslation();
|
|
29
40
|
const [uploading, setUploading] = useState(false);
|
|
41
|
+
// Display details of just-uploaded images, keyed by their new `sys_file` id.
|
|
42
|
+
// Submitting the reference form means the field value no longer carries the
|
|
43
|
+
// URL a thumbnail needs; these keep the preview visible until the next read
|
|
44
|
+
// returns the expanded form. See `file-value`.
|
|
45
|
+
const [recent, setRecent] = useState<Record<string, FileValueView>>({});
|
|
30
46
|
useUploadingSignal(uploading, onUploadingChange);
|
|
31
47
|
|
|
32
48
|
// Derived value + memoized handlers must run before the readonly early return
|
|
33
49
|
// so hook order stays stable across renders.
|
|
34
50
|
const images = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
51
|
+
const views = withRecentUploads(readFileValues(value, 'Image'), recent);
|
|
52
|
+
|
|
53
|
+
const remember = useCallback((result: any, originalName: string) => {
|
|
54
|
+
const view = uploadResultView(result, originalName);
|
|
55
|
+
if (view.id) setRecent((prev) => ({ ...prev, [view.id as string]: view }));
|
|
56
|
+
}, []);
|
|
35
57
|
|
|
36
58
|
const handleCropConfirm = useCallback(
|
|
37
59
|
async (blob: Blob, name: string) => {
|
|
@@ -39,13 +61,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
39
61
|
setUploading(true);
|
|
40
62
|
try {
|
|
41
63
|
const result = await upload(blob);
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
original_name: name,
|
|
45
|
-
size: result.size,
|
|
46
|
-
mime_type: result.mimeType,
|
|
47
|
-
url: result.url,
|
|
48
|
-
};
|
|
64
|
+
remember(result, name);
|
|
65
|
+
const next = fileValueForSubmit(result, name);
|
|
49
66
|
if (multiple) {
|
|
50
67
|
const updated = [...images];
|
|
51
68
|
updated[cropTarget.index] = next;
|
|
@@ -58,32 +75,53 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
58
75
|
setCropTarget(null);
|
|
59
76
|
}
|
|
60
77
|
},
|
|
61
|
-
[cropTarget, images, multiple, onChange, upload],
|
|
78
|
+
[cropTarget, images, multiple, onChange, upload, remember],
|
|
62
79
|
);
|
|
63
80
|
|
|
64
81
|
const openCropper = useCallback(
|
|
65
82
|
(index: number) => {
|
|
66
|
-
const img =
|
|
83
|
+
const img = views[index];
|
|
67
84
|
if (!img?.url) return;
|
|
68
85
|
setCropTarget({ index, src: img.url, name: img.name || `image-${index}.png` });
|
|
69
86
|
},
|
|
70
|
-
[
|
|
87
|
+
[views],
|
|
71
88
|
);
|
|
72
89
|
|
|
90
|
+
const lightboxImages = views.filter((v) => v.url).map((v) => ({ url: v.url as string, name: v.name }));
|
|
91
|
+
|
|
73
92
|
if (readonly) {
|
|
74
|
-
if (!value) return <EmptyValue />;
|
|
93
|
+
if (!value || lightboxImages.length === 0) return <EmptyValue />;
|
|
75
94
|
|
|
76
95
|
return (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
96
|
+
<>
|
|
97
|
+
<div className="flex flex-wrap gap-2">
|
|
98
|
+
{lightboxImages.map((img, idx) => (
|
|
99
|
+
<button
|
|
100
|
+
key={idx}
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() => setLightboxIndex(idx)}
|
|
103
|
+
className="group relative overflow-hidden rounded-md border border-border focus:outline-none focus:ring-2 focus:ring-ring"
|
|
104
|
+
aria-label={t('fields.image.enlarge', { name: img.name || t('fields.image.imageAlt', { index: idx + 1 }) })}
|
|
105
|
+
>
|
|
106
|
+
<img
|
|
107
|
+
src={img.url}
|
|
108
|
+
alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
|
|
109
|
+
className="size-20 object-cover transition-transform duration-150 group-hover:scale-105"
|
|
110
|
+
/>
|
|
111
|
+
<span className="pointer-events-none absolute inset-0 bg-black/0 transition-colors group-hover:bg-black/10" />
|
|
112
|
+
</button>
|
|
113
|
+
))}
|
|
114
|
+
</div>
|
|
115
|
+
{lightboxIndex !== null && (
|
|
116
|
+
<ImageLightbox
|
|
117
|
+
images={lightboxImages}
|
|
118
|
+
index={lightboxIndex}
|
|
119
|
+
open
|
|
120
|
+
onOpenChange={(o) => !o && setLightboxIndex(null)}
|
|
121
|
+
onIndexChange={setLightboxIndex}
|
|
84
122
|
/>
|
|
85
|
-
)
|
|
86
|
-
|
|
123
|
+
)}
|
|
124
|
+
</>
|
|
87
125
|
);
|
|
88
126
|
}
|
|
89
127
|
|
|
@@ -96,13 +134,8 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
96
134
|
const imageObjects = await Promise.all(
|
|
97
135
|
selectedFiles.map(async (file) => {
|
|
98
136
|
const result = await upload(file);
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
original_name: file.name,
|
|
102
|
-
size: result.size,
|
|
103
|
-
mime_type: result.mimeType,
|
|
104
|
-
url: result.url,
|
|
105
|
-
};
|
|
137
|
+
remember(result, file.name);
|
|
138
|
+
return fileValueForSubmit(result, file.name);
|
|
106
139
|
}),
|
|
107
140
|
);
|
|
108
141
|
|
|
@@ -139,13 +172,13 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
139
172
|
/>
|
|
140
173
|
|
|
141
174
|
<div className="space-y-2">
|
|
142
|
-
{
|
|
175
|
+
{views.length > 0 && (
|
|
143
176
|
<div className="grid grid-cols-4 gap-2">
|
|
144
|
-
{
|
|
177
|
+
{views.map((img, idx) => (
|
|
145
178
|
<div key={idx} className="relative group">
|
|
146
179
|
<img
|
|
147
180
|
src={img.url || ''}
|
|
148
|
-
alt={img.name ||
|
|
181
|
+
alt={img.name || t('fields.image.imageAlt', { index: idx + 1 })}
|
|
149
182
|
className="size-20 rounded-md object-cover border border-gray-200"
|
|
150
183
|
/>
|
|
151
184
|
<div className="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
@@ -156,7 +189,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
156
189
|
size="sm"
|
|
157
190
|
onClick={() => openCropper(idx)}
|
|
158
191
|
className="h-6 w-6 p-0"
|
|
159
|
-
aria-label={
|
|
192
|
+
aria-label={t('fields.image.crop', { index: idx + 1 })}
|
|
160
193
|
data-testid={`image-field-crop-${idx}`}
|
|
161
194
|
>
|
|
162
195
|
<CropIcon className="size-3" />
|
|
@@ -168,6 +201,7 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
168
201
|
size="sm"
|
|
169
202
|
onClick={() => handleRemove(idx)}
|
|
170
203
|
className="h-6 w-6 p-0"
|
|
204
|
+
aria-label={t('fields.image.remove', { index: idx + 1 })}
|
|
171
205
|
>
|
|
172
206
|
<X className="size-3" />
|
|
173
207
|
</Button>
|
|
@@ -190,7 +224,11 @@ export function ImageField({ value, onChange, field, readonly, onUploadingChange
|
|
|
190
224
|
) : (
|
|
191
225
|
<ImageIcon className="size-4 mr-2" />
|
|
192
226
|
)}
|
|
193
|
-
{uploading
|
|
227
|
+
{uploading
|
|
228
|
+
? t('fields.image.uploading')
|
|
229
|
+
: images.length > 0
|
|
230
|
+
? t('fields.image.addMore')
|
|
231
|
+
: t('fields.image.upload')}
|
|
194
232
|
</Button>
|
|
195
233
|
</div>
|
|
196
234
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { I18nProvider } from '@object-ui/i18n';
|
|
4
|
+
import { ImageLightbox, type ImageLightboxProps } from './ImageLightbox';
|
|
5
|
+
|
|
6
|
+
const imgs = [
|
|
7
|
+
{ url: '/api/v1/storage/files/a', name: 'a.png' },
|
|
8
|
+
{ url: '/api/v1/storage/files/b', name: 'b.png' },
|
|
9
|
+
{ url: '/api/v1/storage/files/c', name: 'c.png' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const renderLightbox = (props: ImageLightboxProps) =>
|
|
13
|
+
render(
|
|
14
|
+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
|
|
15
|
+
<ImageLightbox {...props} />
|
|
16
|
+
</I18nProvider>,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
describe('ImageLightbox', () => {
|
|
20
|
+
it('renders the image at the requested index', () => {
|
|
21
|
+
renderLightbox({ images: imgs, index: 1, open: true, onOpenChange: () => {} });
|
|
22
|
+
const img = screen.getByRole('img') as HTMLImageElement;
|
|
23
|
+
expect(img.src).toContain('/api/v1/storage/files/b');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('shows navigation and a position counter for multiple images', () => {
|
|
27
|
+
renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {} });
|
|
28
|
+
expect(screen.getByText('1 / 3')).toBeInTheDocument();
|
|
29
|
+
expect(screen.getByLabelText('Next image')).toBeInTheDocument();
|
|
30
|
+
expect(screen.getByLabelText('Previous image')).toBeInTheDocument();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('omits navigation for a single image', () => {
|
|
34
|
+
renderLightbox({ images: [imgs[0]], index: 0, open: true, onOpenChange: () => {} });
|
|
35
|
+
expect(screen.queryByLabelText('Next image')).not.toBeInTheDocument();
|
|
36
|
+
expect(screen.queryByText('1 / 1')).not.toBeInTheDocument();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('advances to the next image and wraps around', () => {
|
|
40
|
+
const seen: number[] = [];
|
|
41
|
+
renderLightbox({ images: imgs, index: 2, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
|
|
42
|
+
fireEvent.click(screen.getByLabelText('Next image'));
|
|
43
|
+
expect(seen).toEqual([0]); // wrapped 2 -> 0
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('steps to the previous image and wraps around', () => {
|
|
47
|
+
const seen: number[] = [];
|
|
48
|
+
renderLightbox({ images: imgs, index: 0, open: true, onOpenChange: () => {}, onIndexChange: (i) => seen.push(i) });
|
|
49
|
+
fireEvent.click(screen.getByLabelText('Previous image'));
|
|
50
|
+
expect(seen).toEqual([2]); // wrapped 0 -> 2
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as React from 'react';
|
|
10
|
+
import { Dialog, DialogContent, DialogTitle } from '@object-ui/components';
|
|
11
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
12
|
+
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
export interface LightboxImage {
|
|
15
|
+
url: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ImageLightboxProps {
|
|
20
|
+
images: LightboxImage[];
|
|
21
|
+
/** Index of the image shown when the lightbox opens. */
|
|
22
|
+
index: number;
|
|
23
|
+
open: boolean;
|
|
24
|
+
onOpenChange: (open: boolean) => void;
|
|
25
|
+
/** Called when the user navigates to another image (prev/next, arrow keys). */
|
|
26
|
+
onIndexChange?: (index: number) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Full-screen image preview with prev/next navigation for a set of images.
|
|
31
|
+
*
|
|
32
|
+
* Controlled: the caller owns `open` and the current `index`. Built on the
|
|
33
|
+
* shared `Dialog`, so backdrop-click and Escape close it for free; this adds
|
|
34
|
+
* left/right arrow-key navigation and a position counter for the multi-image
|
|
35
|
+
* case. A single image simply omits the arrows and counter.
|
|
36
|
+
*/
|
|
37
|
+
export function ImageLightbox({ images, index, open, onOpenChange, onIndexChange }: ImageLightboxProps) {
|
|
38
|
+
const { t } = useObjectTranslation();
|
|
39
|
+
const count = images.length;
|
|
40
|
+
const current = images[index];
|
|
41
|
+
|
|
42
|
+
const go = React.useCallback(
|
|
43
|
+
(delta: number) => {
|
|
44
|
+
if (count <= 1) return;
|
|
45
|
+
const next = (index + delta + count) % count;
|
|
46
|
+
onIndexChange?.(next);
|
|
47
|
+
},
|
|
48
|
+
[count, index, onIndexChange],
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const handleKeyDown = React.useCallback(
|
|
52
|
+
(e: React.KeyboardEvent) => {
|
|
53
|
+
if (e.key === 'ArrowRight') {
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
go(1);
|
|
56
|
+
} else if (e.key === 'ArrowLeft') {
|
|
57
|
+
e.preventDefault();
|
|
58
|
+
go(-1);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
[go],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
if (!current) return null;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
68
|
+
<DialogContent
|
|
69
|
+
className="max-w-[92vw] sm:max-w-4xl p-0 bg-transparent border-0 shadow-none"
|
|
70
|
+
onKeyDown={handleKeyDown}
|
|
71
|
+
>
|
|
72
|
+
{/* Title is required for a labelled dialog but visually redundant here. */}
|
|
73
|
+
<DialogTitle className="sr-only">{current.name || t('fields.image.preview')}</DialogTitle>
|
|
74
|
+
<div className="relative flex items-center justify-center">
|
|
75
|
+
<img
|
|
76
|
+
src={current.url}
|
|
77
|
+
alt={current.name || t('fields.image.imageAlt', { index: index + 1 })}
|
|
78
|
+
className="max-h-[85vh] max-w-full rounded-md object-contain"
|
|
79
|
+
/>
|
|
80
|
+
|
|
81
|
+
{count > 1 && (
|
|
82
|
+
<>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
onClick={() => go(-1)}
|
|
86
|
+
aria-label={t('fields.image.previous')}
|
|
87
|
+
className="absolute left-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
|
88
|
+
>
|
|
89
|
+
<ChevronLeft className="size-5" />
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
onClick={() => go(1)}
|
|
94
|
+
aria-label={t('fields.image.next')}
|
|
95
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors"
|
|
96
|
+
>
|
|
97
|
+
<ChevronRight className="size-5" />
|
|
98
|
+
</button>
|
|
99
|
+
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white">
|
|
100
|
+
{t('fields.image.counter', { current: index + 1, total: count })}
|
|
101
|
+
</div>
|
|
102
|
+
</>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
</DialogContent>
|
|
106
|
+
</Dialog>
|
|
107
|
+
);
|
|
108
|
+
}
|