@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
|
@@ -1,48 +1,114 @@
|
|
|
1
|
-
import React, { useId } from 'react';
|
|
1
|
+
import React, { useId, useEffect } from 'react';
|
|
2
2
|
import { Checkbox, Label, EmptyValue, Badge } from '@object-ui/components';
|
|
3
|
+
import type { OptionLike } from '@object-ui/core';
|
|
3
4
|
import { FieldWidgetProps } from './types';
|
|
5
|
+
import { useCascadingOptions } from './useCascadingOptions';
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
type Option = OptionLike;
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* CheckboxesField - select zero or more values from a fixed option set, rendered
|
|
9
11
|
* as a list of checkboxes. The stored value is a string[]. Used for the
|
|
10
12
|
* `checkboxes` field type.
|
|
13
|
+
*
|
|
14
|
+
* Options support the same per-option `visibleWhen` cascading + `dependsOn`
|
|
15
|
+
* gating as `MultiSelectField` (ADR-0058 / #2715), resolved through the shared
|
|
16
|
+
* {@link useCascadingOptions} hook: the offered boxes narrow against the live
|
|
17
|
+
* form record + `current_user`, the control is gated behind a "select the parent
|
|
18
|
+
* first" hint while a dependency is empty, and selections no longer offered
|
|
19
|
+
* (parent changed / predicate flipped) are pruned from the array.
|
|
11
20
|
*/
|
|
12
|
-
export function CheckboxesField({
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
export function CheckboxesField({
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
field,
|
|
25
|
+
readonly,
|
|
26
|
+
className,
|
|
27
|
+
schema,
|
|
28
|
+
dependentValues,
|
|
29
|
+
dependsOn: dependsOnProp,
|
|
30
|
+
emptyHint: _emptyHint,
|
|
31
|
+
dataSource: _dataSource,
|
|
32
|
+
...props
|
|
33
|
+
}: FieldWidgetProps<string[]>) {
|
|
34
|
+
const config = (field || schema) as any;
|
|
35
|
+
const rawOptions: Option[] = config?.options || [];
|
|
15
36
|
const selected: string[] = Array.isArray(value) ? value : value == null ? [] : [value as unknown as string];
|
|
16
37
|
const groupId = useId();
|
|
38
|
+
const fieldName = (props as any).name || config?.name || (props as any).id || '';
|
|
39
|
+
|
|
40
|
+
const dependsOn = config?.dependsOn ?? dependsOnProp;
|
|
41
|
+
const { options, gated, dependsOnFields } = useCascadingOptions<Option>(
|
|
42
|
+
rawOptions,
|
|
43
|
+
dependsOn,
|
|
44
|
+
dependentValues,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Cascade clear: prune selected values the offered set no longer includes
|
|
48
|
+
// (parent changed / predicate flipped), keeping the ones still valid — the
|
|
49
|
+
// per-element clear the multi-value case needs (cf. the scalar select/radio).
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (readonly) return;
|
|
52
|
+
if (selected.length === 0) return;
|
|
53
|
+
const stillOffered = selected.filter((v) => options.some((o) => o.value === v));
|
|
54
|
+
if (stillOffered.length !== selected.length) onChange(stillOffered);
|
|
55
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
56
|
+
}, [options, gated]);
|
|
17
57
|
|
|
18
58
|
if (readonly) {
|
|
19
59
|
if (selected.length === 0) return <EmptyValue />;
|
|
20
60
|
return (
|
|
21
61
|
<div className="flex flex-wrap gap-1">
|
|
22
62
|
{selected.map((v) => {
|
|
23
|
-
|
|
63
|
+
// Label from the raw set so a stored value hidden by `visibleWhen`
|
|
64
|
+
// still renders its label rather than a bare id.
|
|
65
|
+
const opt = rawOptions.find((o) => o.value === v);
|
|
24
66
|
return <Badge key={v} variant="outline">{opt?.label || v}</Badge>;
|
|
25
67
|
})}
|
|
26
68
|
</div>
|
|
27
69
|
);
|
|
28
70
|
}
|
|
29
71
|
|
|
72
|
+
// No offered options is unfillable — surface a legible state instead of an
|
|
73
|
+
// empty checkbox list: a dependency-gated list prompts for its controlling
|
|
74
|
+
// field; an unconfigured / fully-filtered list says so. Mirrors the select.
|
|
75
|
+
if (options.length === 0) {
|
|
76
|
+
const hint = gated
|
|
77
|
+
? `Select ${dependsOnFields.join(' / ')} first`
|
|
78
|
+
: 'No options available';
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
data-testid={fieldName ? `checkboxes-empty-${fieldName}` : undefined}
|
|
82
|
+
className="flex min-h-9 w-full items-center rounded-md border border-input bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
|
|
83
|
+
>
|
|
84
|
+
{hint}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
30
89
|
const toggle = (v: string, checked: boolean) => {
|
|
31
90
|
const next = checked ? [...selected.filter((x) => x !== v), v] : selected.filter((x) => x !== v);
|
|
32
91
|
onChange(next);
|
|
33
92
|
};
|
|
34
93
|
|
|
35
94
|
return (
|
|
36
|
-
<div
|
|
95
|
+
<div
|
|
96
|
+
className={className}
|
|
97
|
+
data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
|
|
98
|
+
>
|
|
37
99
|
{options.map((opt) => {
|
|
38
|
-
|
|
100
|
+
// Multi-value fields store string arrays — stringify the (possibly
|
|
101
|
+
// numeric, #3090-widened) authored value at this boundary.
|
|
102
|
+
const value = String(opt.value);
|
|
103
|
+
const id = `${groupId}-${value}`;
|
|
39
104
|
return (
|
|
40
|
-
<div key={
|
|
105
|
+
<div key={value} className="flex items-center space-x-2 py-0.5">
|
|
41
106
|
<Checkbox
|
|
42
107
|
id={id}
|
|
43
|
-
checked={selected.includes(
|
|
44
|
-
onCheckedChange={(checked) => toggle(
|
|
108
|
+
checked={selected.includes(value)}
|
|
109
|
+
onCheckedChange={(checked) => toggle(value, !!checked)}
|
|
45
110
|
disabled={(props as any).disabled}
|
|
111
|
+
data-testid={`checkboxes-option-${value}`}
|
|
46
112
|
/>
|
|
47
113
|
<Label htmlFor={id} className="font-normal">{opt.label}</Label>
|
|
48
114
|
</div>
|
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
import React, { useRef, useState, useCallback } from 'react';
|
|
2
2
|
import { Button, EmptyValue } from '@object-ui/components';
|
|
3
3
|
import { useUpload } from '@object-ui/providers';
|
|
4
|
+
import { useObjectTranslation } from '@object-ui/i18n';
|
|
4
5
|
import { Upload, X, File as FileIcon, ImageIcon, Camera, Loader2 } from 'lucide-react';
|
|
5
6
|
import { FieldWidgetProps } from './types';
|
|
7
|
+
import { useUploadingSignal } from './useUploadingSignal';
|
|
8
|
+
import {
|
|
9
|
+
fileValueForSubmit,
|
|
10
|
+
readFileValues,
|
|
11
|
+
uploadResultView,
|
|
12
|
+
withRecentUploads,
|
|
13
|
+
isImageValue,
|
|
14
|
+
type FileValueView,
|
|
15
|
+
} from './file-value';
|
|
6
16
|
|
|
7
17
|
/**
|
|
8
18
|
* Shared upload pipeline for the file widgets: validates size, uploads through
|
|
@@ -10,6 +20,13 @@ import { FieldWidgetProps } from './types';
|
|
|
10
20
|
* into the field value — append for `multiple`, replace otherwise. Extracted so
|
|
11
21
|
* the full-size FileField and the compact grid-cell {@link FileCell} stay
|
|
12
22
|
* behaviourally identical (same value shape, same error handling).
|
|
23
|
+
*
|
|
24
|
+
* Stores the **reference form** — a bare `sys_file` id — when the upload
|
|
25
|
+
* adapter surfaced one, and the legacy inline blob when it did not, so the same
|
|
26
|
+
* build works against a backend that has adopted file-as-reference and one that
|
|
27
|
+
* has not (see `file-value`). Because a bare id carries no name or URL, each
|
|
28
|
+
* completed upload's display details are kept in `recent`, keyed by id, so the
|
|
29
|
+
* file renders immediately instead of as a bare token until a read enriches it.
|
|
13
30
|
*/
|
|
14
31
|
function useFileUploads(opts: {
|
|
15
32
|
files: any[];
|
|
@@ -19,9 +36,11 @@ function useFileUploads(opts: {
|
|
|
19
36
|
}) {
|
|
20
37
|
const { files, multiple, maxSize, onChange } = opts;
|
|
21
38
|
const { upload } = useUpload();
|
|
39
|
+
const { t } = useObjectTranslation();
|
|
22
40
|
const [errors, setErrors] = useState<string[]>([]);
|
|
23
41
|
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
|
24
42
|
const [uploading, setUploading] = useState(false);
|
|
43
|
+
const [recent, setRecent] = useState<Record<string, FileValueView>>({});
|
|
25
44
|
|
|
26
45
|
const processFiles = useCallback(async (selectedFiles: File[]) => {
|
|
27
46
|
if (selectedFiles.length === 0) return;
|
|
@@ -30,7 +49,10 @@ function useFileUploads(opts: {
|
|
|
30
49
|
const validFiles = selectedFiles.filter(file => {
|
|
31
50
|
if (maxSize && file.size > maxSize) {
|
|
32
51
|
const maxMB = (maxSize / (1024 * 1024)).toFixed(1);
|
|
33
|
-
newErrors.push(
|
|
52
|
+
newErrors.push(t('fields.file.exceedsMaxSize', {
|
|
53
|
+
defaultValue: `"${file.name}" exceeds max size (${maxMB} MB)`,
|
|
54
|
+
name: file.name, max: maxMB,
|
|
55
|
+
}));
|
|
34
56
|
return false;
|
|
35
57
|
}
|
|
36
58
|
return true;
|
|
@@ -41,42 +63,51 @@ function useFileUploads(opts: {
|
|
|
41
63
|
|
|
42
64
|
setUploading(true);
|
|
43
65
|
try {
|
|
44
|
-
const
|
|
66
|
+
const uploaded = await Promise.all(
|
|
45
67
|
validFiles.map(async (file) => {
|
|
46
68
|
try {
|
|
47
69
|
const result = await upload(file, {
|
|
48
70
|
onProgress: (ratio) =>
|
|
49
71
|
setUploadProgress((prev) => ({ ...prev, [file.name]: ratio })),
|
|
50
72
|
});
|
|
51
|
-
return {
|
|
52
|
-
name: result.name,
|
|
53
|
-
original_name: file.name,
|
|
54
|
-
size: result.size,
|
|
55
|
-
mime_type: result.mimeType,
|
|
56
|
-
url: result.url,
|
|
57
|
-
};
|
|
73
|
+
return { result, originalName: file.name };
|
|
58
74
|
} catch (err) {
|
|
59
|
-
newErrors.push(
|
|
75
|
+
newErrors.push(t('fields.file.uploadFailed', {
|
|
76
|
+
defaultValue: `Failed to upload "${file.name}": ${(err as Error).message}`,
|
|
77
|
+
name: file.name, error: (err as Error).message,
|
|
78
|
+
}));
|
|
60
79
|
setErrors([...newErrors]);
|
|
61
80
|
return null;
|
|
62
81
|
}
|
|
63
82
|
}),
|
|
64
83
|
);
|
|
65
|
-
const successful =
|
|
84
|
+
const successful = uploaded.filter(Boolean) as Array<{ result: any; originalName: string }>;
|
|
66
85
|
if (successful.length === 0) return;
|
|
67
86
|
|
|
87
|
+
// Remember what each new id looks like so it can render before the next
|
|
88
|
+
// read expands it back into `{ id, name, size, mimeType, url }`.
|
|
89
|
+
const views: Record<string, FileValueView> = {};
|
|
90
|
+
for (const { result, originalName } of successful) {
|
|
91
|
+
const view = uploadResultView(result, originalName);
|
|
92
|
+
if (view.id) views[view.id] = view;
|
|
93
|
+
}
|
|
94
|
+
if (Object.keys(views).length > 0) setRecent((prev) => ({ ...prev, ...views }));
|
|
95
|
+
|
|
96
|
+
const nextValues = successful.map(({ result, originalName }) =>
|
|
97
|
+
fileValueForSubmit(result, originalName),
|
|
98
|
+
);
|
|
68
99
|
if (multiple) {
|
|
69
|
-
onChange([...files, ...
|
|
100
|
+
onChange([...files, ...nextValues]);
|
|
70
101
|
} else {
|
|
71
|
-
onChange(
|
|
102
|
+
onChange(nextValues[0]);
|
|
72
103
|
}
|
|
73
104
|
} finally {
|
|
74
105
|
setUploading(false);
|
|
75
106
|
setUploadProgress({});
|
|
76
107
|
}
|
|
77
|
-
}, [files, multiple, onChange, maxSize, upload]);
|
|
108
|
+
}, [files, multiple, onChange, maxSize, upload, t]);
|
|
78
109
|
|
|
79
|
-
return { processFiles, errors, uploading, uploadProgress };
|
|
110
|
+
return { processFiles, errors, uploading, uploadProgress, recent };
|
|
80
111
|
}
|
|
81
112
|
|
|
82
113
|
/**
|
|
@@ -84,7 +115,8 @@ function useFileUploads(opts: {
|
|
|
84
115
|
* Supports single and multiple file uploads with configurable accepted file types.
|
|
85
116
|
* L2: File size validation, per-file progress indicators, error messages.
|
|
86
117
|
*/
|
|
87
|
-
export function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>) {
|
|
118
|
+
export function FileField({ value, onChange, field, readonly, onUploadingChange, ...props }: FieldWidgetProps<any>) {
|
|
119
|
+
const { t } = useObjectTranslation();
|
|
88
120
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
89
121
|
const cameraRef = useRef<HTMLInputElement>(null);
|
|
90
122
|
const fileField = (field || (props as any).schema) as any;
|
|
@@ -108,9 +140,14 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
108
140
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
109
141
|
|
|
110
142
|
const files = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
111
|
-
const { processFiles, errors, uploading, uploadProgress } = useFileUploads({
|
|
143
|
+
const { processFiles, errors, uploading, uploadProgress, recent } = useFileUploads({
|
|
112
144
|
files, multiple, maxSize, onChange,
|
|
113
145
|
});
|
|
146
|
+
const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
|
|
147
|
+
// Normalised for display: accepts a bare reference id, the expanded
|
|
148
|
+
// `{ id, name, size, mimeType, url }`, or a legacy inline blob.
|
|
149
|
+
const views = withRecentUploads(readFileValues(value, fallbackName), recent);
|
|
150
|
+
useUploadingSignal(uploading, onUploadingChange);
|
|
114
151
|
|
|
115
152
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
116
153
|
e.preventDefault();
|
|
@@ -147,13 +184,12 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
147
184
|
|
|
148
185
|
if (readonly) {
|
|
149
186
|
if (!value) return <EmptyValue />;
|
|
150
|
-
|
|
151
|
-
const readonlyFiles = Array.isArray(value) ? value : [value];
|
|
187
|
+
|
|
152
188
|
return (
|
|
153
189
|
<div className="flex flex-wrap gap-2">
|
|
154
|
-
{
|
|
190
|
+
{views.map((file, idx) => (
|
|
155
191
|
<span key={idx} className="text-sm truncate max-w-xs">
|
|
156
|
-
{file.name
|
|
192
|
+
{file.name}
|
|
157
193
|
</span>
|
|
158
194
|
))}
|
|
159
195
|
</div>
|
|
@@ -173,11 +209,6 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
173
209
|
}
|
|
174
210
|
};
|
|
175
211
|
|
|
176
|
-
const isImage = (file: any) => {
|
|
177
|
-
const mime = file.mime_type || '';
|
|
178
|
-
return mime.startsWith('image/');
|
|
179
|
-
};
|
|
180
|
-
|
|
181
212
|
return (
|
|
182
213
|
<div className={props.className}>
|
|
183
214
|
<input
|
|
@@ -196,7 +227,7 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
196
227
|
capture={cameraEnabled}
|
|
197
228
|
onChange={handleFileChange}
|
|
198
229
|
className="hidden"
|
|
199
|
-
aria-label=
|
|
230
|
+
aria-label={t('fields.file.cameraCapture', { defaultValue: 'Camera capture' })}
|
|
200
231
|
data-testid="file-field-camera-input"
|
|
201
232
|
/>
|
|
202
233
|
)}
|
|
@@ -228,10 +259,14 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
228
259
|
<Upload className={`size-8 ${isDragOver ? 'text-primary' : 'text-muted-foreground'}`} />
|
|
229
260
|
<div className="text-center">
|
|
230
261
|
<p className="text-sm font-medium">
|
|
231
|
-
{isDragOver
|
|
262
|
+
{isDragOver
|
|
263
|
+
? t('fields.file.dropFilesHere', { defaultValue: 'Drop files here' })
|
|
264
|
+
: t('fields.file.dragDropHere', { defaultValue: 'Drag & drop files here' })}
|
|
232
265
|
</p>
|
|
233
266
|
<p className="text-xs text-muted-foreground mt-1">
|
|
234
|
-
|
|
267
|
+
{cameraEnabled
|
|
268
|
+
? t('fields.file.browseHintCamera', { defaultValue: 'or click to browse • use the camera button below' })
|
|
269
|
+
: t('fields.file.browseHint', { defaultValue: 'or click to browse' })}
|
|
235
270
|
</p>
|
|
236
271
|
</div>
|
|
237
272
|
</div>
|
|
@@ -249,7 +284,9 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
249
284
|
data-testid="file-field-camera-button"
|
|
250
285
|
>
|
|
251
286
|
<Camera className="size-4 mr-2" />
|
|
252
|
-
{cameraEnabled === 'user'
|
|
287
|
+
{cameraEnabled === 'user'
|
|
288
|
+
? t('fields.file.takeSelfie', { defaultValue: 'Take selfie' })
|
|
289
|
+
: t('fields.file.takePhoto', { defaultValue: 'Take photo' })}
|
|
253
290
|
</Button>
|
|
254
291
|
)}
|
|
255
292
|
|
|
@@ -258,12 +295,14 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
258
295
|
<div className="flex items-center gap-2 text-xs text-muted-foreground" data-testid="file-field-uploading">
|
|
259
296
|
<Loader2 className="size-3 animate-spin" />
|
|
260
297
|
<span>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
)
|
|
298
|
+
{(() => {
|
|
299
|
+
const keys = Object.keys(uploadProgress);
|
|
300
|
+
if (keys.length === 0) return t('fields.file.uploading', { defaultValue: 'Uploading…' });
|
|
301
|
+
const pct = Math.round(
|
|
302
|
+
(Object.values(uploadProgress).reduce((s, v) => s + v, 0) / keys.length) * 100,
|
|
303
|
+
);
|
|
304
|
+
return t('fields.file.uploadingPct', { defaultValue: `Uploading… (${pct}%)`, pct });
|
|
305
|
+
})()}
|
|
267
306
|
</span>
|
|
268
307
|
</div>
|
|
269
308
|
)}
|
|
@@ -278,23 +317,23 @@ export function FileField({ value, onChange, field, readonly, ...props }: FieldW
|
|
|
278
317
|
)}
|
|
279
318
|
|
|
280
319
|
{/* File list */}
|
|
281
|
-
{
|
|
320
|
+
{views.length > 0 && (
|
|
282
321
|
<div className="space-y-1">
|
|
283
|
-
{
|
|
322
|
+
{views.map((file, idx) => (
|
|
284
323
|
<div
|
|
285
324
|
key={idx}
|
|
286
325
|
className="flex items-center justify-between gap-2 p-2 bg-muted/50 rounded-md border"
|
|
287
326
|
>
|
|
288
327
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
289
|
-
{
|
|
328
|
+
{isImageValue(file) && file.url ? (
|
|
290
329
|
<img src={file.url} alt={file.name} className="size-8 object-cover rounded shrink-0" />
|
|
291
|
-
) :
|
|
330
|
+
) : isImageValue(file) ? (
|
|
292
331
|
<ImageIcon className="size-4 text-muted-foreground shrink-0" />
|
|
293
332
|
) : (
|
|
294
333
|
<FileIcon className="size-4 text-muted-foreground shrink-0" />
|
|
295
334
|
)}
|
|
296
335
|
<span className="text-sm truncate">
|
|
297
|
-
{file.name
|
|
336
|
+
{file.name}
|
|
298
337
|
</span>
|
|
299
338
|
{file.size && (
|
|
300
339
|
<span className="text-xs text-muted-foreground">
|
|
@@ -354,11 +393,14 @@ export function FileCell({
|
|
|
354
393
|
/** Focus-grid coordinate (see GridField keyboard navigation). */
|
|
355
394
|
'data-cell'?: string;
|
|
356
395
|
}) {
|
|
396
|
+
const { t } = useObjectTranslation();
|
|
357
397
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
358
398
|
const files = value ? (Array.isArray(value) ? value : [value]) : [];
|
|
359
|
-
const { processFiles, errors, uploading } = useFileUploads({
|
|
399
|
+
const { processFiles, errors, uploading, recent } = useFileUploads({
|
|
360
400
|
files, multiple: !!multiple, maxSize, onChange,
|
|
361
401
|
});
|
|
402
|
+
const fallbackName = t('fields.file.fileFallback', { defaultValue: 'File' });
|
|
403
|
+
const views = withRecentUploads(readFileValues(value, fallbackName), recent);
|
|
362
404
|
|
|
363
405
|
const removeAt = (index: number) => {
|
|
364
406
|
if (multiple) {
|
|
@@ -369,9 +411,6 @@ export function FileCell({
|
|
|
369
411
|
}
|
|
370
412
|
};
|
|
371
413
|
|
|
372
|
-
const isImage = (file: any) => String(file?.mime_type || '').startsWith('image/');
|
|
373
|
-
const nameOf = (file: any) =>
|
|
374
|
-
typeof file === 'string' ? file : file?.name || file?.original_name || 'File';
|
|
375
414
|
const showUpload = !disabled && !uploading && (multiple || files.length === 0);
|
|
376
415
|
|
|
377
416
|
return (
|
|
@@ -387,24 +426,24 @@ export function FileCell({
|
|
|
387
426
|
}}
|
|
388
427
|
className="hidden"
|
|
389
428
|
/>
|
|
390
|
-
{
|
|
429
|
+
{views.map((file, idx) => (
|
|
391
430
|
<span
|
|
392
431
|
key={idx}
|
|
393
432
|
className="inline-flex max-w-40 items-center gap-1 rounded border bg-muted/50 px-1 py-0.5 text-xs"
|
|
394
|
-
title={
|
|
433
|
+
title={file.name}
|
|
395
434
|
data-testid="file-cell-chip"
|
|
396
435
|
>
|
|
397
|
-
{
|
|
398
|
-
<img src={file.url} alt={
|
|
436
|
+
{isImageValue(file) && file.url ? (
|
|
437
|
+
<img src={file.url} alt={file.name} className="size-5 shrink-0 rounded object-cover" />
|
|
399
438
|
) : (
|
|
400
439
|
<FileIcon className="size-3 shrink-0 text-muted-foreground" />
|
|
401
440
|
)}
|
|
402
|
-
<span className="truncate">{
|
|
441
|
+
<span className="truncate">{file.name}</span>
|
|
403
442
|
{!disabled && (
|
|
404
443
|
<button
|
|
405
444
|
type="button"
|
|
406
445
|
className="shrink-0 rounded p-0.5 text-muted-foreground hover:text-foreground"
|
|
407
|
-
aria-label={`Remove ${
|
|
446
|
+
aria-label={t('fields.file.remove', { defaultValue: `Remove ${file.name}`, name: file.name })}
|
|
408
447
|
onClick={() => removeAt(idx)}
|
|
409
448
|
>
|
|
410
449
|
<X className="size-3" />
|
|
@@ -432,7 +471,7 @@ export function FileCell({
|
|
|
432
471
|
disabled={disabled}
|
|
433
472
|
>
|
|
434
473
|
<Upload className="size-3.5" />
|
|
435
|
-
{files.length === 0 && 'Upload'}
|
|
474
|
+
{files.length === 0 && t('fields.file.upload', { defaultValue: 'Upload' })}
|
|
436
475
|
</Button>
|
|
437
476
|
)}
|
|
438
477
|
{errors.length > 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
|
);
|