@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +630 -0
  2. package/README.md +45 -7
  3. package/dist/FieldEditWidget.d.ts +7 -0
  4. package/dist/field-type-alias.d.ts +16 -0
  5. package/dist/index.cjs +5 -5
  6. package/dist/index.d.ts +35 -11
  7. package/dist/index.js +2241 -1853
  8. package/dist/widgets/CheckboxesField.cascade.test.d.ts +0 -0
  9. package/dist/widgets/CheckboxesField.d.ts +8 -1
  10. package/dist/widgets/FileField.d.ts +1 -1
  11. package/dist/widgets/FilterConditionField.d.ts +32 -0
  12. package/dist/widgets/ImageField.d.ts +1 -1
  13. package/dist/widgets/ImageLightbox.d.ts +30 -0
  14. package/dist/widgets/ImageLightbox.test.d.ts +1 -0
  15. package/dist/widgets/LookupField.hydration.test.d.ts +1 -0
  16. package/dist/widgets/LookupField.idField.test.d.ts +1 -0
  17. package/dist/widgets/MultiSelectField.cascade.test.d.ts +0 -0
  18. package/dist/widgets/MultiSelectField.d.ts +10 -2
  19. package/dist/widgets/RadioField.cascade.test.d.ts +0 -0
  20. package/dist/widgets/RadioField.d.ts +8 -1
  21. package/dist/widgets/RecipientPickerField.test.d.ts +0 -0
  22. package/dist/widgets/SelectField.d.ts +13 -9
  23. package/dist/widgets/__tests__/FilterConditionField.matchAll.test.d.ts +8 -0
  24. package/dist/widgets/__tests__/FilterConditionField.operators.test.d.ts +8 -0
  25. package/dist/widgets/deriveLookupColumns.d.ts +1 -0
  26. package/dist/widgets/file-value.d.ts +112 -0
  27. package/dist/widgets/file-value.test.d.ts +1 -0
  28. package/dist/widgets/types.d.ts +6 -0
  29. package/dist/widgets/useCascadingOptions.d.ts +18 -0
  30. package/dist/widgets/useFieldTranslation.d.ts +2 -0
  31. package/dist/widgets/useUploadingSignal.d.ts +9 -0
  32. package/dist/widgets/useUploadingSignal.test.d.ts +1 -0
  33. package/package.json +11 -10
  34. package/src/FieldEditWidget.test.ts +74 -2
  35. package/src/FieldEditWidget.tsx +44 -4
  36. package/src/field-type-alias.ts +100 -0
  37. package/src/field-type-coverage.test.ts +3 -1
  38. package/src/index.tsx +137 -135
  39. package/src/standard-widgets.test.tsx +44 -0
  40. package/src/widgets/CheckboxesField.cascade.test.tsx +143 -0
  41. package/src/widgets/CheckboxesField.tsx +77 -11
  42. package/src/widgets/FileField.tsx +91 -52
  43. package/src/widgets/FilterConditionField.tsx +109 -9
  44. package/src/widgets/ImageField.tsx +106 -65
  45. package/src/widgets/ImageLightbox.test.tsx +52 -0
  46. package/src/widgets/ImageLightbox.tsx +108 -0
  47. package/src/widgets/LookupField.hydration.test.tsx +136 -0
  48. package/src/widgets/LookupField.idField.test.tsx +61 -0
  49. package/src/widgets/LookupField.tsx +106 -9
  50. package/src/widgets/MultiSelectField.cascade.test.tsx +143 -0
  51. package/src/widgets/MultiSelectField.tsx +78 -11
  52. package/src/widgets/ObjectRefField.tsx +9 -4
  53. package/src/widgets/PeoplePicker.test.tsx +20 -0
  54. package/src/widgets/PeoplePicker.tsx +15 -3
  55. package/src/widgets/RadioField.cascade.test.tsx +141 -0
  56. package/src/widgets/RadioField.tsx +69 -9
  57. package/src/widgets/RecipientPickerField.test.tsx +157 -0
  58. package/src/widgets/RecipientPickerField.tsx +36 -16
  59. package/src/widgets/SelectField.tsx +33 -31
  60. package/src/widgets/TextAreaField.tsx +8 -3
  61. package/src/widgets/__tests__/FilterConditionField.matchAll.test.ts +66 -0
  62. package/src/widgets/__tests__/FilterConditionField.operators.test.ts +142 -0
  63. package/src/widgets/deriveLookupColumns.test.ts +13 -0
  64. package/src/widgets/deriveLookupColumns.ts +5 -15
  65. package/src/widgets/file-value.test.ts +230 -0
  66. package/src/widgets/file-value.ts +230 -0
  67. package/src/widgets/types.ts +6 -0
  68. package/src/widgets/useCascadingOptions.ts +46 -0
  69. package/src/widgets/useFieldTranslation.ts +30 -0
  70. package/src/widgets/useUploadingSignal.test.tsx +62 -0
  71. 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
- interface Option { label: string; value: string }
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({ value, onChange, field, readonly, className, ...props }: FieldWidgetProps<string[]>) {
13
- const config = (field || (props as any).schema) as any;
14
- const options: Option[] = config?.options || [];
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
- const opt = options.find((o) => o.value === v);
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 className={className}>
95
+ <div
96
+ className={className}
97
+ data-testid={fieldName ? `checkboxes-${fieldName}` : undefined}
98
+ >
37
99
  {options.map((opt) => {
38
- const id = `${groupId}-${opt.value}`;
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={opt.value} className="flex items-center space-x-2 py-0.5">
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(opt.value)}
44
- onCheckedChange={(checked) => toggle(opt.value, !!checked)}
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(`"${file.name}" exceeds max size (${maxMB} MB)`);
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 fileObjects = await Promise.all(
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(`Failed to upload "${file.name}": ${(err as Error).message}`);
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 = fileObjects.filter(Boolean) as any[];
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, ...successful]);
100
+ onChange([...files, ...nextValues]);
70
101
  } else {
71
- onChange(successful[0]);
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
- {readonlyFiles.map((file: any, idx: number) => (
190
+ {views.map((file, idx) => (
155
191
  <span key={idx} className="text-sm truncate max-w-xs">
156
- {file.name || file.original_name || 'File'}
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="Camera capture"
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 ? 'Drop files here' : 'Drag & drop files here'}
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
- or click to browse{cameraEnabled ? ' • use the camera button below' : ''}
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' ? 'Take selfie' : 'Take photo'}
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
- Uploading…
262
- {Object.keys(uploadProgress).length > 0 &&
263
- ` (${Math.round(
264
- (Object.values(uploadProgress).reduce((s, v) => s + v, 0) /
265
- Object.keys(uploadProgress).length) * 100,
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
- {files.length > 0 && (
320
+ {views.length > 0 && (
282
321
  <div className="space-y-1">
283
- {files.map((file: any, idx: number) => (
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
- {isImage(file) && file.url ? (
328
+ {isImageValue(file) && file.url ? (
290
329
  <img src={file.url} alt={file.name} className="size-8 object-cover rounded shrink-0" />
291
- ) : isImage(file) ? (
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 || file.original_name || 'File'}
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
- {files.map((file: any, idx: number) => (
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={nameOf(file)}
433
+ title={file.name}
395
434
  data-testid="file-cell-chip"
396
435
  >
397
- {isImage(file) && file.url ? (
398
- <img src={file.url} alt={nameOf(file)} className="size-5 shrink-0 rounded object-cover" />
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">{nameOf(file)}</span>
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 ${nameOf(file)}`}
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
- function condToMongo(c: BuilderCondition, typeOf: (f: string) => string | undefined): Record<string, any> | null {
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
- case 'notContains': return { [field]: { $ncontains: value } };
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
- if (frags.length === 0) return null; // empty = match all
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
- function kvToCondition(field: string, v: any, idx: number): BuilderCondition | null {
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
- Select an object first.
371
+ {t('fields.filterCondition.selectObjectFirst')}
294
372
  </p>
295
373
  );
296
374
  }
297
375
 
298
376
  if (readonly) {
299
377
  if (!rawValue.trim()) {
300
- return <span className={cn('text-sm text-muted-foreground', className)}>All records</span>;
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">Invalid JSON — the rule will match no records until fixed.</span>
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 ? 'This criteria can only be edited as JSON' : undefined}
433
+ title={!representable ? t('fields.filterCondition.jsonOnly') : undefined}
336
434
  >
337
- {rawMode ? 'Use visual builder' : 'Edit as JSON'}
435
+ {rawMode
436
+ ? t('fields.filterCondition.useVisualBuilder')
437
+ : t('fields.filterCondition.editAsJson')}
338
438
  </button>
339
439
  </div>
340
440
  );