@object-ui/fields 3.3.2 → 4.0.1
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 +97 -0
- package/dist/ImageCropperDialog-BYRMqcYm.js +147 -0
- package/dist/ImageCropperDialog-DXLQcwWZ.cjs +1 -0
- package/dist/LookupField-CRKvDPFG.cjs +1 -0
- package/dist/{LookupField-De-GqsfT.js → LookupField-XRxsGgLS.js} +330 -295
- package/dist/dist-BoLAKgtw.cjs +1 -0
- package/dist/dist-qtNk7SzD.js +32 -0
- package/dist/index.cjs +4 -4
- package/dist/index.js +944 -758
- package/dist/packages/fields/src/index.d.ts +2 -0
- package/dist/packages/fields/src/lookup-depends-on.test.d.ts +1 -0
- package/dist/packages/fields/src/widgets/ImageCropperDialog.d.ts +14 -0
- package/dist/packages/fields/src/widgets/TextAreaField.d.ts +6 -1
- package/package.json +9 -8
- package/src/__tests__/cell-renderers.test.tsx +18 -18
- package/src/complex-widgets.test.tsx +46 -0
- package/src/index.tsx +27 -14
- package/src/lookup-depends-on.test.tsx +130 -0
- package/src/remaining-widgets.test.tsx +14 -1
- package/src/widgets/FileField.tsx +100 -17
- package/src/widgets/ImageCropperDialog.tsx +245 -0
- package/src/widgets/ImageField.tsx +121 -26
- package/src/widgets/LookupField.tsx +91 -5
- package/src/widgets/TextAreaField.tsx +88 -6
- package/dist/LookupField-CiIjeL_i.cjs +0 -1
|
@@ -201,6 +201,8 @@ export * from './widgets/LookupField';
|
|
|
201
201
|
export * from './widgets/RecordPickerDialog';
|
|
202
202
|
export * from './widgets/FileField';
|
|
203
203
|
export * from './widgets/ImageField';
|
|
204
|
+
export { ImageCropperDialog } from './widgets/ImageCropperDialog';
|
|
205
|
+
export type { ImageCropperDialogProps } from './widgets/ImageCropperDialog';
|
|
204
206
|
export * from './widgets/LocationField';
|
|
205
207
|
export * from './widgets/FormulaField';
|
|
206
208
|
export * from './widgets/SummaryField';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { default as React } from 'react';
|
|
2
|
+
export interface ImageCropperDialogProps {
|
|
3
|
+
open: boolean;
|
|
4
|
+
onOpenChange: (open: boolean) => void;
|
|
5
|
+
/** Source image URL or data URL */
|
|
6
|
+
src: string;
|
|
7
|
+
/** MIME type for the cropped output. Defaults to image/png. */
|
|
8
|
+
outputType?: string;
|
|
9
|
+
/** Suggested filename (passed back via onConfirm). Defaults to "cropped.png". */
|
|
10
|
+
outputName?: string;
|
|
11
|
+
/** Called when the user confirms the crop. */
|
|
12
|
+
onConfirm: (blob: Blob, name: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export declare const ImageCropperDialog: React.FC<ImageCropperDialogProps>;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { FieldWidgetProps } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* TextAreaField - Multi-line text input widget
|
|
4
|
-
* Supports configurable row count and preserves whitespace in readonly mode
|
|
4
|
+
* Supports configurable row count and preserves whitespace in readonly mode.
|
|
5
|
+
*
|
|
6
|
+
* Mobile UX (round 3): when the host form passes `mobileFullscreen` (or the
|
|
7
|
+
* field schema sets `mobile_fullscreen: true`), an "expand" affordance opens
|
|
8
|
+
* a fullscreen edit dialog — much easier on phones than tapping a 4-row
|
|
9
|
+
* textarea trapped between other fields.
|
|
5
10
|
*/
|
|
6
11
|
export declare function TextAreaField({ value, onChange, field, readonly, errorMessage, ...props }: FieldWidgetProps<string>): import("react/jsx-runtime").JSX.Element;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"clsx": "^2.1.1",
|
|
28
|
-
"lucide-react": "^1.
|
|
28
|
+
"lucide-react": "^1.14.0",
|
|
29
29
|
"tailwind-merge": "^3.5.0",
|
|
30
|
-
"@object-ui/components": "
|
|
31
|
-
"@object-ui/core": "
|
|
32
|
-
"@object-ui/i18n": "
|
|
33
|
-
"@object-ui/
|
|
34
|
-
"@object-ui/
|
|
30
|
+
"@object-ui/components": "4.0.1",
|
|
31
|
+
"@object-ui/core": "4.0.1",
|
|
32
|
+
"@object-ui/i18n": "4.0.1",
|
|
33
|
+
"@object-ui/providers": "4.0.1",
|
|
34
|
+
"@object-ui/react": "4.0.1",
|
|
35
|
+
"@object-ui/types": "4.0.1"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
38
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"@vitejs/plugin-react": "^6.0.1",
|
|
44
45
|
"typescript": "^6.0.3",
|
|
45
46
|
"vite": "^8.0.10",
|
|
46
|
-
"vite-plugin-dts": "^
|
|
47
|
+
"vite-plugin-dts": "^5.0.0"
|
|
47
48
|
},
|
|
48
49
|
"publishConfig": {
|
|
49
50
|
"access": "public"
|
|
@@ -146,7 +146,7 @@ describe('SelectCellRenderer', () => {
|
|
|
146
146
|
/>
|
|
147
147
|
);
|
|
148
148
|
expect(screen.getByText('In Progress')).toBeInTheDocument();
|
|
149
|
-
expect(container.querySelector('[class*="bg-blue-
|
|
149
|
+
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
it('should fall back to a deterministic palette color when no color configured and no semantic match', () => {
|
|
@@ -166,11 +166,11 @@ describe('SelectCellRenderer', () => {
|
|
|
166
166
|
);
|
|
167
167
|
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
|
168
168
|
const palette = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
|
169
|
-
const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-
|
|
169
|
+
const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-50"]`));
|
|
170
170
|
expect(matched).toBe(true);
|
|
171
171
|
|
|
172
172
|
// Same value → same color across renders (deterministic).
|
|
173
|
-
const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-
|
|
173
|
+
const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
|
|
174
174
|
rerender(
|
|
175
175
|
<SelectCellRenderer
|
|
176
176
|
value="Unknown"
|
|
@@ -181,7 +181,7 @@ describe('SelectCellRenderer', () => {
|
|
|
181
181
|
} as any}
|
|
182
182
|
/>
|
|
183
183
|
);
|
|
184
|
-
const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-
|
|
184
|
+
const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-50"]')?.className;
|
|
185
185
|
expect(secondClass).toBe(firstClass);
|
|
186
186
|
});
|
|
187
187
|
|
|
@@ -193,7 +193,7 @@ describe('SelectCellRenderer', () => {
|
|
|
193
193
|
/>
|
|
194
194
|
);
|
|
195
195
|
expect(screen.getByText('Critical')).toBeInTheDocument();
|
|
196
|
-
expect(container.querySelector('[class*="bg-red-
|
|
196
|
+
expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
|
|
197
197
|
});
|
|
198
198
|
|
|
199
199
|
it('should auto-detect priority semantic colors (High → orange)', () => {
|
|
@@ -203,7 +203,7 @@ describe('SelectCellRenderer', () => {
|
|
|
203
203
|
field={{ name: 'priority', type: 'select', options: [] } as any}
|
|
204
204
|
/>
|
|
205
205
|
);
|
|
206
|
-
expect(container.querySelector('[class*="bg-orange-
|
|
206
|
+
expect(container.querySelector('[class*="bg-orange-50"]')).toBeInTheDocument();
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
it('should auto-detect priority semantic colors (Medium → yellow)', () => {
|
|
@@ -213,7 +213,7 @@ describe('SelectCellRenderer', () => {
|
|
|
213
213
|
field={{ name: 'priority', type: 'select', options: [] } as any}
|
|
214
214
|
/>
|
|
215
215
|
);
|
|
216
|
-
expect(container.querySelector('[class*="bg-yellow-
|
|
216
|
+
expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
it('should auto-detect priority semantic colors (Low → gray)', () => {
|
|
@@ -223,7 +223,7 @@ describe('SelectCellRenderer', () => {
|
|
|
223
223
|
field={{ name: 'priority', type: 'select', options: [] } as any}
|
|
224
224
|
/>
|
|
225
225
|
);
|
|
226
|
-
expect(container.querySelector('[class*="bg-gray-
|
|
226
|
+
expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
it('should auto-detect status semantic colors (Paid → green)', () => {
|
|
@@ -233,7 +233,7 @@ describe('SelectCellRenderer', () => {
|
|
|
233
233
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
234
234
|
/>
|
|
235
235
|
);
|
|
236
|
-
expect(container.querySelector('[class*="bg-green-
|
|
236
|
+
expect(container.querySelector('[class*="bg-green-50"]')).toBeInTheDocument();
|
|
237
237
|
});
|
|
238
238
|
|
|
239
239
|
it('should auto-detect status semantic colors (Pending → yellow)', () => {
|
|
@@ -243,7 +243,7 @@ describe('SelectCellRenderer', () => {
|
|
|
243
243
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
244
244
|
/>
|
|
245
245
|
);
|
|
246
|
-
expect(container.querySelector('[class*="bg-yellow-
|
|
246
|
+
expect(container.querySelector('[class*="bg-yellow-50"]')).toBeInTheDocument();
|
|
247
247
|
});
|
|
248
248
|
|
|
249
249
|
it('should auto-detect status semantic colors (Shipped → blue)', () => {
|
|
@@ -253,7 +253,7 @@ describe('SelectCellRenderer', () => {
|
|
|
253
253
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
254
254
|
/>
|
|
255
255
|
);
|
|
256
|
-
expect(container.querySelector('[class*="bg-blue-
|
|
256
|
+
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
257
257
|
});
|
|
258
258
|
|
|
259
259
|
it('should auto-detect status semantic colors (Draft → gray)', () => {
|
|
@@ -263,7 +263,7 @@ describe('SelectCellRenderer', () => {
|
|
|
263
263
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
264
264
|
/>
|
|
265
265
|
);
|
|
266
|
-
expect(container.querySelector('[class*="bg-gray-
|
|
266
|
+
expect(container.querySelector('[class*="bg-gray-50"]')).toBeInTheDocument();
|
|
267
267
|
});
|
|
268
268
|
|
|
269
269
|
it('should auto-detect status semantic colors (Cancelled → red)', () => {
|
|
@@ -273,7 +273,7 @@ describe('SelectCellRenderer', () => {
|
|
|
273
273
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
274
274
|
/>
|
|
275
275
|
);
|
|
276
|
-
expect(container.querySelector('[class*="bg-red-
|
|
276
|
+
expect(container.querySelector('[class*="bg-red-50"]')).toBeInTheDocument();
|
|
277
277
|
});
|
|
278
278
|
|
|
279
279
|
it('should auto-detect status semantic colors (Delivered → purple)', () => {
|
|
@@ -283,7 +283,7 @@ describe('SelectCellRenderer', () => {
|
|
|
283
283
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
284
284
|
/>
|
|
285
285
|
);
|
|
286
|
-
expect(container.querySelector('[class*="bg-purple-
|
|
286
|
+
expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
|
|
287
287
|
});
|
|
288
288
|
|
|
289
289
|
it('should auto-detect status semantic colors with snake_case (in_progress → blue)', () => {
|
|
@@ -293,7 +293,7 @@ describe('SelectCellRenderer', () => {
|
|
|
293
293
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
294
294
|
/>
|
|
295
295
|
);
|
|
296
|
-
expect(container.querySelector('[class*="bg-blue-
|
|
296
|
+
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
297
297
|
});
|
|
298
298
|
|
|
299
299
|
it('should auto-detect status semantic colors with hyphen (in-progress → blue)', () => {
|
|
@@ -303,7 +303,7 @@ describe('SelectCellRenderer', () => {
|
|
|
303
303
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
304
304
|
/>
|
|
305
305
|
);
|
|
306
|
-
expect(container.querySelector('[class*="bg-blue-
|
|
306
|
+
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
307
307
|
});
|
|
308
308
|
|
|
309
309
|
it('should auto-detect status semantic colors with spaces (in progress → blue)', () => {
|
|
@@ -313,7 +313,7 @@ describe('SelectCellRenderer', () => {
|
|
|
313
313
|
field={{ name: 'status', type: 'select', options: [] } as any}
|
|
314
314
|
/>
|
|
315
315
|
);
|
|
316
|
-
expect(container.querySelector('[class*="bg-blue-
|
|
316
|
+
expect(container.querySelector('[class*="bg-blue-50"]')).toBeInTheDocument();
|
|
317
317
|
});
|
|
318
318
|
|
|
319
319
|
it('should render dash for null/empty value', () => {
|
|
@@ -356,7 +356,7 @@ describe('SelectCellRenderer', () => {
|
|
|
356
356
|
/>
|
|
357
357
|
);
|
|
358
358
|
// Explicit purple should override auto-detected orange
|
|
359
|
-
expect(container.querySelector('[class*="bg-purple-
|
|
359
|
+
expect(container.querySelector('[class*="bg-purple-50"]')).toBeInTheDocument();
|
|
360
360
|
});
|
|
361
361
|
});
|
|
362
362
|
|
|
@@ -566,5 +566,51 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
566
566
|
// Check for remove button existence implies it rendered correctly
|
|
567
567
|
// Typically icon X or Trash.
|
|
568
568
|
});
|
|
569
|
+
|
|
570
|
+
it('renders the camera button when capture is explicitly enabled', () => {
|
|
571
|
+
render(
|
|
572
|
+
<FileField
|
|
573
|
+
{...baseProps}
|
|
574
|
+
field={{ ...baseProps.field, capture: 'environment' } as any}
|
|
575
|
+
value={null}
|
|
576
|
+
/>,
|
|
577
|
+
);
|
|
578
|
+
expect(screen.getByTestId('file-field-camera-button')).toBeInTheDocument();
|
|
579
|
+
expect(screen.getByText(/Take photo/i)).toBeInTheDocument();
|
|
580
|
+
const cameraInput = screen.getByTestId('file-field-camera-input') as HTMLInputElement;
|
|
581
|
+
expect(cameraInput).toHaveAttribute('capture', 'environment');
|
|
582
|
+
expect(cameraInput).toHaveAttribute('accept', 'image/*');
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it('uses "Take selfie" label when capture is "user"', () => {
|
|
586
|
+
render(
|
|
587
|
+
<FileField
|
|
588
|
+
{...baseProps}
|
|
589
|
+
field={{ ...baseProps.field, capture: 'user' } as any}
|
|
590
|
+
value={null}
|
|
591
|
+
/>,
|
|
592
|
+
);
|
|
593
|
+
expect(screen.getByText(/Take selfie/i)).toBeInTheDocument();
|
|
594
|
+
const cameraInput = screen.getByTestId('file-field-camera-input') as HTMLInputElement;
|
|
595
|
+
expect(cameraInput).toHaveAttribute('capture', 'user');
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it('does not render the camera button when capture is explicitly false', () => {
|
|
599
|
+
render(
|
|
600
|
+
<FileField
|
|
601
|
+
{...baseProps}
|
|
602
|
+
field={{ ...baseProps.field, capture: false } as any}
|
|
603
|
+
value={null}
|
|
604
|
+
/>,
|
|
605
|
+
);
|
|
606
|
+
expect(screen.queryByTestId('file-field-camera-button')).not.toBeInTheDocument();
|
|
607
|
+
expect(screen.queryByTestId('file-field-camera-input')).not.toBeInTheDocument();
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('does not render the camera button on a non-touch device by default', () => {
|
|
611
|
+
// jsdom defaults to maxTouchPoints=0 and a non-mobile UA, so capture is auto-disabled.
|
|
612
|
+
render(<FileField {...baseProps} value={null} />);
|
|
613
|
+
expect(screen.queryByTestId('file-field-camera-button')).not.toBeInTheDocument();
|
|
614
|
+
});
|
|
569
615
|
});
|
|
570
616
|
});
|
package/src/index.tsx
CHANGED
|
@@ -366,15 +366,21 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
366
366
|
const dateField = field as any;
|
|
367
367
|
const style = dateField.format || 'relative';
|
|
368
368
|
const formatted = formatDate(safe as string | Date, style);
|
|
369
|
-
|
|
370
|
-
// Determine if date is overdue (in the past)
|
|
369
|
+
|
|
370
|
+
// Determine if date is overdue (in the past) — but only color it red when the
|
|
371
|
+
// field is *semantically* a due/deadline. A plain "start_date" or "created_at"
|
|
372
|
+
// in the past should not render in red.
|
|
371
373
|
const date = typeof safe === 'string' ? new Date(safe) : safe;
|
|
372
374
|
const isValidDate = date instanceof Date && !isNaN(date.getTime());
|
|
373
375
|
const startOfToday = new Date();
|
|
374
376
|
startOfToday.setHours(0, 0, 0, 0);
|
|
375
|
-
const
|
|
377
|
+
const fieldName = String(dateField?.name || dateField?.accessorKey || dateField?.key || '').toLowerCase();
|
|
378
|
+
const dueLike =
|
|
379
|
+
dateField?.dueLike === true ||
|
|
380
|
+
/(^|_)(due|deadline|expires?|expiry|expiration|expected_close|target_close|sla|return_by|renewal|next_action)(_|$)/.test(fieldName);
|
|
381
|
+
const isOverdue = dueLike && isValidDate && date < startOfToday;
|
|
376
382
|
const isoString = isValidDate ? date.toISOString() : String(safe);
|
|
377
|
-
|
|
383
|
+
|
|
378
384
|
return (
|
|
379
385
|
<span
|
|
380
386
|
className={`tabular-nums${isOverdue ? ' text-red-600' : ''}`}
|
|
@@ -452,16 +458,21 @@ const SEMANTIC_COLOR_MAP: Record<string, string> = {
|
|
|
452
458
|
};
|
|
453
459
|
|
|
454
460
|
// Color to Tailwind class mapping for custom Badge styling
|
|
461
|
+
// Color → Tailwind class mapping for status-style badges.
|
|
462
|
+
// Uses the modern "soft pill" pattern (Tailwind UI style): -50 background,
|
|
463
|
+
// -700 text, hairline -200 border. Dark mode mirrors with -950/40 surface
|
|
464
|
+
// and -300 text. This keeps status fields readable without the heavy,
|
|
465
|
+
// candy-colored look of the older -100/-300/-800 combination.
|
|
455
466
|
const BADGE_COLOR_MAP: Record<string, string> = {
|
|
456
|
-
gray: 'bg-gray-
|
|
457
|
-
red: 'bg-red-
|
|
458
|
-
orange: 'bg-orange-
|
|
459
|
-
yellow: 'bg-yellow-
|
|
460
|
-
green: 'bg-green-
|
|
461
|
-
blue: 'bg-blue-
|
|
462
|
-
indigo: 'bg-indigo-
|
|
463
|
-
purple: 'bg-purple-
|
|
464
|
-
pink: 'bg-pink-
|
|
467
|
+
gray: 'bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-800/50 dark:text-gray-200 dark:border-gray-700/60',
|
|
468
|
+
red: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-300 dark:border-red-900/60',
|
|
469
|
+
orange: 'bg-orange-50 text-orange-700 border-orange-200 dark:bg-orange-950/40 dark:text-orange-300 dark:border-orange-900/60',
|
|
470
|
+
yellow: 'bg-yellow-50 text-yellow-800 border-yellow-200 dark:bg-yellow-950/40 dark:text-yellow-300 dark:border-yellow-900/60',
|
|
471
|
+
green: 'bg-green-50 text-green-700 border-green-200 dark:bg-green-950/40 dark:text-green-300 dark:border-green-900/60',
|
|
472
|
+
blue: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-300 dark:border-blue-900/60',
|
|
473
|
+
indigo: 'bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-300 dark:border-indigo-900/60',
|
|
474
|
+
purple: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/40 dark:text-purple-300 dark:border-purple-900/60',
|
|
475
|
+
pink: 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-950/40 dark:text-pink-300 dark:border-pink-900/60',
|
|
465
476
|
};
|
|
466
477
|
|
|
467
478
|
// Color palette used by the deterministic fallback when no schema/semantic
|
|
@@ -746,7 +757,7 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
746
757
|
return (
|
|
747
758
|
<span
|
|
748
759
|
key={idx}
|
|
749
|
-
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-
|
|
760
|
+
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-50 text-gray-700 dark:bg-gray-800/50 dark:text-gray-200"
|
|
750
761
|
>
|
|
751
762
|
{label}
|
|
752
763
|
</span>
|
|
@@ -1361,6 +1372,8 @@ export * from './widgets/LookupField';
|
|
|
1361
1372
|
export * from './widgets/RecordPickerDialog';
|
|
1362
1373
|
export * from './widgets/FileField';
|
|
1363
1374
|
export * from './widgets/ImageField';
|
|
1375
|
+
export { ImageCropperDialog } from './widgets/ImageCropperDialog';
|
|
1376
|
+
export type { ImageCropperDialogProps } from './widgets/ImageCropperDialog';
|
|
1364
1377
|
export * from './widgets/LocationField';
|
|
1365
1378
|
export * from './widgets/FormulaField';
|
|
1366
1379
|
export * from './widgets/SummaryField';
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the dependent-lookups feature: `field.depends_on` should add
|
|
3
|
+
* an `$filter` chain when querying, gate the trigger when dependencies are
|
|
4
|
+
* empty, and re-fetch when the dependency value changes.
|
|
5
|
+
*/
|
|
6
|
+
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
import React, { useState } from 'react';
|
|
9
|
+
import { LookupField } from './widgets/LookupField';
|
|
10
|
+
|
|
11
|
+
const mockDataSource = {
|
|
12
|
+
find: vi.fn(),
|
|
13
|
+
findOne: vi.fn(),
|
|
14
|
+
create: vi.fn(),
|
|
15
|
+
update: vi.fn(),
|
|
16
|
+
delete: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
vi.clearAllMocks();
|
|
21
|
+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('LookupField — depends_on', () => {
|
|
25
|
+
const baseField: any = {
|
|
26
|
+
name: 'city',
|
|
27
|
+
type: 'lookup',
|
|
28
|
+
reference: 'cities',
|
|
29
|
+
display_field: 'name',
|
|
30
|
+
id_field: 'id',
|
|
31
|
+
depends_on: ['country'],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
it('disables the trigger when a dependency value is missing', () => {
|
|
35
|
+
render(
|
|
36
|
+
<LookupField
|
|
37
|
+
field={baseField}
|
|
38
|
+
value={null}
|
|
39
|
+
onChange={vi.fn()}
|
|
40
|
+
readonly={false}
|
|
41
|
+
dataSource={mockDataSource as any}
|
|
42
|
+
dependentValues={{}}
|
|
43
|
+
/>,
|
|
44
|
+
);
|
|
45
|
+
const trigger = screen.getByTestId('lookup-trigger-gated');
|
|
46
|
+
expect(trigger).toBeDisabled();
|
|
47
|
+
expect(trigger).toHaveTextContent(/Select country first/i);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('enables the trigger and includes a filter chain once the dependency is set', async () => {
|
|
51
|
+
render(
|
|
52
|
+
<LookupField
|
|
53
|
+
field={baseField}
|
|
54
|
+
value={null}
|
|
55
|
+
onChange={vi.fn()}
|
|
56
|
+
readonly={false}
|
|
57
|
+
dataSource={mockDataSource as any}
|
|
58
|
+
dependentValues={{ country: 'US' }}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
// Trigger is enabled (no gated test id).
|
|
62
|
+
expect(screen.queryByTestId('lookup-trigger-gated')).not.toBeInTheDocument();
|
|
63
|
+
|
|
64
|
+
// Open the popover to fire fetch.
|
|
65
|
+
await act(async () => {
|
|
66
|
+
fireEvent.click(screen.getByRole('button', { name: /select|search/i }));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await waitFor(() => expect(mockDataSource.find).toHaveBeenCalled());
|
|
70
|
+
const [, params] = mockDataSource.find.mock.calls[0];
|
|
71
|
+
expect(params.$filter).toEqual({ country: 'US' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('supports the explicit { field, param } shape', async () => {
|
|
75
|
+
const explicitField: any = {
|
|
76
|
+
...baseField,
|
|
77
|
+
depends_on: [{ field: 'country', param: 'country_id' }],
|
|
78
|
+
};
|
|
79
|
+
render(
|
|
80
|
+
<LookupField
|
|
81
|
+
field={explicitField}
|
|
82
|
+
value={null}
|
|
83
|
+
onChange={vi.fn()}
|
|
84
|
+
readonly={false}
|
|
85
|
+
dataSource={mockDataSource as any}
|
|
86
|
+
dependentValues={{ country: 42 }}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
await act(async () => {
|
|
90
|
+
fireEvent.click(screen.getByRole('button', { name: /select|search/i }));
|
|
91
|
+
});
|
|
92
|
+
await waitFor(() => expect(mockDataSource.find).toHaveBeenCalled());
|
|
93
|
+
const [, params] = mockDataSource.find.mock.calls[0];
|
|
94
|
+
// numeric value, no quoting
|
|
95
|
+
expect(params.$filter).toEqual({ country_id: 42 });
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('re-fetches when the dependency value changes while the picker is open', async () => {
|
|
99
|
+
function Harness() {
|
|
100
|
+
const [country, setCountry] = useState('US');
|
|
101
|
+
return (
|
|
102
|
+
<div>
|
|
103
|
+
<button data-testid="set-uk" onClick={() => setCountry('UK')}>UK</button>
|
|
104
|
+
<LookupField
|
|
105
|
+
field={baseField}
|
|
106
|
+
value={null}
|
|
107
|
+
onChange={vi.fn()}
|
|
108
|
+
readonly={false}
|
|
109
|
+
dataSource={mockDataSource as any}
|
|
110
|
+
dependentValues={{ country }}
|
|
111
|
+
/>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
render(<Harness />);
|
|
116
|
+
// Open the popover.
|
|
117
|
+
await act(async () => {
|
|
118
|
+
fireEvent.click(screen.getByRole('button', { name: /select|search/i }));
|
|
119
|
+
});
|
|
120
|
+
await waitFor(() => expect(mockDataSource.find).toHaveBeenCalledTimes(1));
|
|
121
|
+
|
|
122
|
+
// Change the dependency — should re-fetch.
|
|
123
|
+
await act(async () => {
|
|
124
|
+
fireEvent.click(screen.getByTestId('set-uk'));
|
|
125
|
+
});
|
|
126
|
+
await waitFor(() => expect(mockDataSource.find).toHaveBeenCalledTimes(2));
|
|
127
|
+
const [, paramsAfter] = mockDataSource.find.mock.calls[1];
|
|
128
|
+
expect(paramsAfter.$filter).toEqual({ country: 'UK' });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -133,7 +133,20 @@ describe('Remaining Field Widgets', () => {
|
|
|
133
133
|
expect(imgs).toHaveLength(2);
|
|
134
134
|
expect(imgs[0]).toHaveAttribute('src', 'http://example.com/1.jpg');
|
|
135
135
|
});
|
|
136
|
-
|
|
136
|
+
|
|
137
|
+
it('renders a Crop button per image when crop is enabled (default)', () => {
|
|
138
|
+
render(<ImageField {...baseProps} value={images} field={{ ...baseProps.field, multiple: true } as any} />);
|
|
139
|
+
// Two crop buttons (one per image) with stable test ids — opacity is hover-controlled
|
|
140
|
+
// but the buttons are still present in the DOM for a11y/keyboard users.
|
|
141
|
+
expect(screen.getByTestId('image-field-crop-0')).toBeInTheDocument();
|
|
142
|
+
expect(screen.getByTestId('image-field-crop-1')).toBeInTheDocument();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('hides the Crop button when field.crop = false', () => {
|
|
146
|
+
render(<ImageField {...baseProps} value={images} field={{ ...baseProps.field, multiple: true, crop: false } as any} />);
|
|
147
|
+
expect(screen.queryByTestId('image-field-crop-0')).not.toBeInTheDocument();
|
|
148
|
+
});
|
|
149
|
+
|
|
137
150
|
// Complex interaction omitted for brevity, focusing on render contracts
|
|
138
151
|
});
|
|
139
152
|
|