@object-ui/fields 3.3.0 → 3.3.2
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 +151 -0
- package/README.md +24 -0
- package/dist/{LookupField-BHr7uTX1.cjs → LookupField-CiIjeL_i.cjs} +1 -1
- package/dist/{LookupField-B6MOSQVJ.js → LookupField-De-GqsfT.js} +176 -179
- package/dist/index.cjs +3 -3
- package/dist/index.js +633 -655
- package/package.json +34 -9
- package/src/__tests__/cell-renderers.test.tsx +39 -18
- package/src/__tests__/datetime-cell.test.tsx +2 -2
- package/src/__tests__/datetime-timezone.test.tsx +9 -9
- package/src/__tests__/validation-feedback.test.tsx +8 -8
- package/src/datetime-widgets.test.tsx +1 -1
- package/src/index.tsx +54 -28
- package/src/record-picker.test.tsx +30 -23
- package/src/remaining-widgets.test.tsx +1 -1
- package/src/widgets/AddressField.tsx +4 -3
- package/src/widgets/AutoNumberField.tsx +5 -3
- package/src/widgets/CodeField.tsx +2 -2
- package/src/widgets/ColorField.tsx +2 -2
- package/src/widgets/CurrencyField.tsx +2 -2
- package/src/widgets/DateField.tsx +2 -2
- package/src/widgets/DateTimeField.tsx +2 -2
- package/src/widgets/EmailField.tsx +2 -2
- package/src/widgets/FileField.tsx +2 -2
- package/src/widgets/FormulaField.tsx +14 -12
- package/src/widgets/GeolocationField.tsx +4 -3
- package/src/widgets/GridField.tsx +3 -3
- package/src/widgets/ImageField.tsx +2 -2
- package/src/widgets/LocationField.tsx +2 -2
- package/src/widgets/LookupField.tsx +3 -5
- package/src/widgets/NumberField.tsx +2 -2
- package/src/widgets/ObjectField.tsx +2 -2
- package/src/widgets/PercentField.tsx +2 -2
- package/src/widgets/PhoneField.tsx +2 -2
- package/src/widgets/QRCodeField.tsx +2 -2
- package/src/widgets/RichTextField.tsx +2 -2
- package/src/widgets/SelectField.tsx +9 -7
- package/src/widgets/SummaryField.tsx +12 -10
- package/src/widgets/TextAreaField.tsx +2 -2
- package/src/widgets/TextField.tsx +2 -2
- package/src/widgets/TimeField.tsx +2 -2
- package/src/widgets/UrlField.tsx +2 -2
- package/src/widgets/UserField.tsx +2 -2
- package/src/widgets/VectorField.tsx +2 -1
- package/dist/AddressField-Do46PdqT.cjs +0 -1
- package/dist/AutoNumberField-cS2tsxd-.cjs +0 -1
- package/dist/AvatarField-CxhGS3wF.cjs +0 -1
- package/dist/BooleanField-BgkbTHPY.cjs +0 -1
- package/dist/CodeField-DE3dLruS.cjs +0 -1
- package/dist/ColorField-DBTFFYpv.cjs +0 -1
- package/dist/CurrencyField-C28LkJts.cjs +0 -1
- package/dist/DateField-WGWwddDF.cjs +0 -1
- package/dist/DateTimeField-BYsZs8f7.cjs +0 -1
- package/dist/EmailField-1PW03WW8.cjs +0 -1
- package/dist/FileField-BqZkp1O6.cjs +0 -1
- package/dist/FormulaField-MZ9le4RE.cjs +0 -1
- package/dist/GeolocationField-onbAOdIe.cjs +0 -1
- package/dist/GridField-D4OWfaU4.cjs +0 -1
- package/dist/ImageField-BZf-AL2e.cjs +0 -1
- package/dist/LocationField-DhGJtUzZ.cjs +0 -1
- package/dist/MasterDetailField-YTAU1hzg.cjs +0 -1
- package/dist/NumberField-BCk42NjA.cjs +0 -1
- package/dist/ObjectField-iYK5eab_.cjs +0 -1
- package/dist/PasswordField-BAReSDz7.cjs +0 -1
- package/dist/PercentField-nzACEXtC.cjs +0 -1
- package/dist/PhoneField-CvbkGpN3.cjs +0 -1
- package/dist/QRCodeField-D8fov03S.cjs +0 -1
- package/dist/RatingField-DBgj9cgE.cjs +0 -1
- package/dist/RichTextField-7VwN6GcU.cjs +0 -1
- package/dist/SelectField-BUOTiYaS.cjs +0 -1
- package/dist/SignatureField-DW_Cz6t9.cjs +0 -1
- package/dist/SliderField-Bgi3-TAD.cjs +0 -1
- package/dist/SummaryField-Ed2wJBvs.cjs +0 -1
- package/dist/TextAreaField-O503xJff.cjs +0 -1
- package/dist/TextField-BrwtPIQC.cjs +0 -1
- package/dist/TimeField-Bhoxv2yp.cjs +0 -1
- package/dist/UrlField-VFO089Gr.cjs +0 -1
- package/dist/UserField-BRe9SCCk.cjs +0 -1
- package/dist/VectorField-ByrWkiud.cjs +0 -1
- /package/dist/{useFieldTranslation-BiQad4ar.cjs → useFieldTranslation-BYPwtuNZ.cjs} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.2",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,17 +19,19 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"dist",
|
|
21
21
|
"src",
|
|
22
|
-
"README.md"
|
|
22
|
+
"README.md",
|
|
23
|
+
"CHANGELOG.md",
|
|
24
|
+
"LICENSE"
|
|
23
25
|
],
|
|
24
26
|
"dependencies": {
|
|
25
27
|
"clsx": "^2.1.1",
|
|
26
28
|
"lucide-react": "^1.8.0",
|
|
27
29
|
"tailwind-merge": "^3.5.0",
|
|
28
|
-
"@object-ui/components": "3.3.
|
|
29
|
-
"@object-ui/core": "3.3.
|
|
30
|
-
"@object-ui/i18n": "3.3.
|
|
31
|
-
"@object-ui/react": "3.3.
|
|
32
|
-
"@object-ui/types": "3.3.
|
|
30
|
+
"@object-ui/components": "3.3.2",
|
|
31
|
+
"@object-ui/core": "3.3.2",
|
|
32
|
+
"@object-ui/i18n": "3.3.2",
|
|
33
|
+
"@object-ui/react": "3.3.2",
|
|
34
|
+
"@object-ui/types": "3.3.2"
|
|
33
35
|
},
|
|
34
36
|
"peerDependencies": {
|
|
35
37
|
"react": "^18.0.0 || ^19.0.0",
|
|
@@ -39,13 +41,36 @@
|
|
|
39
41
|
"@types/react": "19.2.14",
|
|
40
42
|
"@types/react-dom": "19.2.3",
|
|
41
43
|
"@vitejs/plugin-react": "^6.0.1",
|
|
42
|
-
"typescript": "^6.0.
|
|
43
|
-
"vite": "^8.0.
|
|
44
|
+
"typescript": "^6.0.3",
|
|
45
|
+
"vite": "^8.0.10",
|
|
44
46
|
"vite-plugin-dts": "^4.5.4"
|
|
45
47
|
},
|
|
46
48
|
"publishConfig": {
|
|
47
49
|
"access": "public"
|
|
48
50
|
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"objectui",
|
|
53
|
+
"sdui",
|
|
54
|
+
"schema-driven-ui",
|
|
55
|
+
"react",
|
|
56
|
+
"tailwind",
|
|
57
|
+
"shadcn",
|
|
58
|
+
"objectstack",
|
|
59
|
+
"fields",
|
|
60
|
+
"form-fields",
|
|
61
|
+
"renderer",
|
|
62
|
+
"widgets"
|
|
63
|
+
],
|
|
64
|
+
"author": "ObjectStack Team <team@objectstack.ai>",
|
|
65
|
+
"repository": {
|
|
66
|
+
"type": "git",
|
|
67
|
+
"url": "git+https://github.com/objectstack-ai/objectui.git",
|
|
68
|
+
"directory": "packages/fields"
|
|
69
|
+
},
|
|
70
|
+
"bugs": {
|
|
71
|
+
"url": "https://github.com/objectstack-ai/objectui/issues"
|
|
72
|
+
},
|
|
73
|
+
"homepage": "https://www.objectui.org/docs/fields",
|
|
49
74
|
"scripts": {
|
|
50
75
|
"build": "tsc && vite build",
|
|
51
76
|
"clean": "rm -rf dist",
|
|
@@ -106,17 +106,17 @@ describe('TextCellRenderer', () => {
|
|
|
106
106
|
|
|
107
107
|
it('should render dash for null', () => {
|
|
108
108
|
render(<TextCellRenderer value={null} field={{ name: 'title', type: 'text' } as any} />);
|
|
109
|
-
expect(screen.getByText('
|
|
109
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
it('should render dash for undefined', () => {
|
|
113
113
|
render(<TextCellRenderer value={undefined} field={{ name: 'title', type: 'text' } as any} />);
|
|
114
|
-
expect(screen.getByText('
|
|
114
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it('should render dash for empty string', () => {
|
|
118
118
|
render(<TextCellRenderer value="" field={{ name: 'title', type: 'text' } as any} />);
|
|
119
|
-
expect(screen.getByText('
|
|
119
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it('should render "0" for numeric zero (not dash)', () => {
|
|
@@ -149,8 +149,12 @@ describe('SelectCellRenderer', () => {
|
|
|
149
149
|
expect(container.querySelector('[class*="bg-blue-100"]')).toBeInTheDocument();
|
|
150
150
|
});
|
|
151
151
|
|
|
152
|
-
it('should
|
|
153
|
-
|
|
152
|
+
it('should fall back to a deterministic palette color when no color configured and no semantic match', () => {
|
|
153
|
+
// Hash-based fallback: distinct values get distinct colors so badges
|
|
154
|
+
// never all collapse to muted gray. The exact color is deterministic
|
|
155
|
+
// (stable across renders) but value-derived; we just assert one of the
|
|
156
|
+
// palette tones is applied.
|
|
157
|
+
const { container, rerender } = render(
|
|
154
158
|
<SelectCellRenderer
|
|
155
159
|
value="Unknown"
|
|
156
160
|
field={{
|
|
@@ -161,7 +165,24 @@ describe('SelectCellRenderer', () => {
|
|
|
161
165
|
/>
|
|
162
166
|
);
|
|
163
167
|
expect(screen.getByText('Unknown')).toBeInTheDocument();
|
|
164
|
-
|
|
168
|
+
const palette = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
|
169
|
+
const matched = palette.some((c) => container.querySelector(`[class*="bg-${c}-100"]`));
|
|
170
|
+
expect(matched).toBe(true);
|
|
171
|
+
|
|
172
|
+
// Same value → same color across renders (deterministic).
|
|
173
|
+
const firstClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-100"]')?.className;
|
|
174
|
+
rerender(
|
|
175
|
+
<SelectCellRenderer
|
|
176
|
+
value="Unknown"
|
|
177
|
+
field={{
|
|
178
|
+
name: 'status',
|
|
179
|
+
type: 'select',
|
|
180
|
+
options: [{ value: 'Unknown', label: 'Unknown' }],
|
|
181
|
+
} as any}
|
|
182
|
+
/>
|
|
183
|
+
);
|
|
184
|
+
const secondClass = container.querySelector('[data-slot="badge"], [class*="bg-"][class*="-100"]')?.className;
|
|
185
|
+
expect(secondClass).toBe(firstClass);
|
|
165
186
|
});
|
|
166
187
|
|
|
167
188
|
it('should auto-detect priority semantic colors (Critical → red)', () => {
|
|
@@ -302,7 +323,7 @@ describe('SelectCellRenderer', () => {
|
|
|
302
323
|
field={{ name: 'status', type: 'select' } as any}
|
|
303
324
|
/>
|
|
304
325
|
);
|
|
305
|
-
expect(screen.getByText('
|
|
326
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
306
327
|
});
|
|
307
328
|
|
|
308
329
|
it('should render multiple badges for array values', () => {
|
|
@@ -413,7 +434,7 @@ describe('DateCellRenderer', () => {
|
|
|
413
434
|
field={{ name: 'due_date', type: 'date' } as any}
|
|
414
435
|
/>
|
|
415
436
|
);
|
|
416
|
-
expect(container.textContent).toBe('
|
|
437
|
+
expect(container.textContent).toBe('—');
|
|
417
438
|
});
|
|
418
439
|
|
|
419
440
|
it('should use explicit format when provided', () => {
|
|
@@ -590,7 +611,7 @@ describe('EmailCellRenderer', () => {
|
|
|
590
611
|
|
|
591
612
|
it('should render dash for empty value', () => {
|
|
592
613
|
render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
|
|
593
|
-
expect(screen.getByText('
|
|
614
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
594
615
|
});
|
|
595
616
|
});
|
|
596
617
|
|
|
@@ -613,7 +634,7 @@ describe('PhoneCellRenderer', () => {
|
|
|
613
634
|
|
|
614
635
|
it('should render dash for empty value', () => {
|
|
615
636
|
render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
|
|
616
|
-
expect(screen.getByText('
|
|
637
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
617
638
|
});
|
|
618
639
|
});
|
|
619
640
|
|
|
@@ -632,15 +653,15 @@ describe('formatRelativeDate', () => {
|
|
|
632
653
|
});
|
|
633
654
|
|
|
634
655
|
it('should return "-" for null', () => {
|
|
635
|
-
expect(formatRelativeDate(null as any)).toBe('
|
|
656
|
+
expect(formatRelativeDate(null as any)).toBe('—');
|
|
636
657
|
});
|
|
637
658
|
|
|
638
659
|
it('should return "-" for empty string', () => {
|
|
639
|
-
expect(formatRelativeDate('')).toBe('
|
|
660
|
+
expect(formatRelativeDate('')).toBe('—');
|
|
640
661
|
});
|
|
641
662
|
|
|
642
663
|
it('should return "-" for invalid date', () => {
|
|
643
|
-
expect(formatRelativeDate('not-a-date')).toBe('
|
|
664
|
+
expect(formatRelativeDate('not-a-date')).toBe('—');
|
|
644
665
|
});
|
|
645
666
|
|
|
646
667
|
it('should return "Today" for today', () => {
|
|
@@ -694,11 +715,11 @@ describe('formatRelativeDate', () => {
|
|
|
694
715
|
// =========================================================================
|
|
695
716
|
describe('formatDate', () => {
|
|
696
717
|
it('should return "-" for null', () => {
|
|
697
|
-
expect(formatDate(null as any)).toBe('
|
|
718
|
+
expect(formatDate(null as any)).toBe('—');
|
|
698
719
|
});
|
|
699
720
|
|
|
700
721
|
it('should return "-" for invalid date', () => {
|
|
701
|
-
expect(formatDate('invalid')).toBe('
|
|
722
|
+
expect(formatDate('invalid')).toBe('—');
|
|
702
723
|
});
|
|
703
724
|
|
|
704
725
|
it('should format as relative when style is "relative"', () => {
|
|
@@ -731,7 +752,7 @@ describe('LookupCellRenderer', () => {
|
|
|
731
752
|
field={{ name: 'customer', type: 'lookup' } as any}
|
|
732
753
|
/>
|
|
733
754
|
);
|
|
734
|
-
expect(screen.getByText('
|
|
755
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
735
756
|
});
|
|
736
757
|
|
|
737
758
|
it('should render dash for empty string', () => {
|
|
@@ -741,7 +762,7 @@ describe('LookupCellRenderer', () => {
|
|
|
741
762
|
field={{ name: 'customer', type: 'lookup' } as any}
|
|
742
763
|
/>
|
|
743
764
|
);
|
|
744
|
-
expect(screen.getByText('
|
|
765
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
745
766
|
});
|
|
746
767
|
|
|
747
768
|
it('should resolve primitive ID to label via field options', () => {
|
|
@@ -858,7 +879,7 @@ describe('UserCellRenderer', () => {
|
|
|
858
879
|
field={{ name: 'owner', type: 'user' } as any}
|
|
859
880
|
/>
|
|
860
881
|
);
|
|
861
|
-
expect(screen.getByText('
|
|
882
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
862
883
|
});
|
|
863
884
|
|
|
864
885
|
it('should render text for primitive user ID (number)', () => {
|
|
@@ -31,7 +31,7 @@ describe('DateTimeCellRenderer', () => {
|
|
|
31
31
|
field={{ name: 'created_at', type: 'datetime' } as any}
|
|
32
32
|
/>
|
|
33
33
|
);
|
|
34
|
-
expect(container.textContent).toBe('
|
|
34
|
+
expect(container.textContent).toBe('—');
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
it('should show dash for invalid date', () => {
|
|
@@ -41,6 +41,6 @@ describe('DateTimeCellRenderer', () => {
|
|
|
41
41
|
field={{ name: 'created_at', type: 'datetime' } as any}
|
|
42
42
|
/>
|
|
43
43
|
);
|
|
44
|
-
expect(container.textContent).toBe('
|
|
44
|
+
expect(container.textContent).toBe('—');
|
|
45
45
|
});
|
|
46
46
|
});
|
|
@@ -347,7 +347,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
347
347
|
readonly={true}
|
|
348
348
|
/>
|
|
349
349
|
);
|
|
350
|
-
expect(container.textContent).toBe('
|
|
350
|
+
expect(container.textContent).toBe('—');
|
|
351
351
|
});
|
|
352
352
|
|
|
353
353
|
it('DateField readonly shows "-" for null', () => {
|
|
@@ -359,7 +359,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
359
359
|
readonly={true}
|
|
360
360
|
/>
|
|
361
361
|
);
|
|
362
|
-
expect(container.textContent).toBe('
|
|
362
|
+
expect(container.textContent).toBe('—');
|
|
363
363
|
});
|
|
364
364
|
|
|
365
365
|
it('DateField readonly shows "-" for undefined', () => {
|
|
@@ -371,7 +371,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
371
371
|
readonly={true}
|
|
372
372
|
/>
|
|
373
373
|
);
|
|
374
|
-
expect(container.textContent).toBe('
|
|
374
|
+
expect(container.textContent).toBe('—');
|
|
375
375
|
});
|
|
376
376
|
|
|
377
377
|
it('TimeField readonly shows "-" for empty string', () => {
|
|
@@ -383,7 +383,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
383
383
|
readonly={true}
|
|
384
384
|
/>
|
|
385
385
|
);
|
|
386
|
-
expect(container.textContent).toBe('
|
|
386
|
+
expect(container.textContent).toBe('—');
|
|
387
387
|
});
|
|
388
388
|
|
|
389
389
|
it('TimeField readonly shows "-" for null', () => {
|
|
@@ -395,7 +395,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
395
395
|
readonly={true}
|
|
396
396
|
/>
|
|
397
397
|
);
|
|
398
|
-
expect(container.textContent).toBe('
|
|
398
|
+
expect(container.textContent).toBe('—');
|
|
399
399
|
});
|
|
400
400
|
|
|
401
401
|
it('TimeField readonly shows "-" for undefined', () => {
|
|
@@ -407,7 +407,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
407
407
|
readonly={true}
|
|
408
408
|
/>
|
|
409
409
|
);
|
|
410
|
-
expect(container.textContent).toBe('
|
|
410
|
+
expect(container.textContent).toBe('—');
|
|
411
411
|
});
|
|
412
412
|
|
|
413
413
|
it('DateTimeField readonly shows "-" for empty string', () => {
|
|
@@ -419,7 +419,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
419
419
|
readonly={true}
|
|
420
420
|
/>
|
|
421
421
|
);
|
|
422
|
-
expect(container.textContent).toBe('
|
|
422
|
+
expect(container.textContent).toBe('—');
|
|
423
423
|
});
|
|
424
424
|
|
|
425
425
|
it('DateTimeField readonly shows "-" for null', () => {
|
|
@@ -431,7 +431,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
431
431
|
readonly={true}
|
|
432
432
|
/>
|
|
433
433
|
);
|
|
434
|
-
expect(container.textContent).toBe('
|
|
434
|
+
expect(container.textContent).toBe('—');
|
|
435
435
|
});
|
|
436
436
|
|
|
437
437
|
it('DateTimeField readonly shows "-" for undefined', () => {
|
|
@@ -443,7 +443,7 @@ describe('P3.2 Date/Time Timezone & Locale Formatting', () => {
|
|
|
443
443
|
readonly={true}
|
|
444
444
|
/>
|
|
445
445
|
);
|
|
446
|
-
expect(container.textContent).toBe('
|
|
446
|
+
expect(container.textContent).toBe('—');
|
|
447
447
|
});
|
|
448
448
|
|
|
449
449
|
it('DateField editable uses empty string for null value', () => {
|
|
@@ -191,7 +191,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
191
191
|
readonly={true}
|
|
192
192
|
/>
|
|
193
193
|
);
|
|
194
|
-
expect(container.textContent).toBe('
|
|
194
|
+
expect(container.textContent).toBe('—');
|
|
195
195
|
});
|
|
196
196
|
|
|
197
197
|
it('NumberField shows dash for null value in readonly', () => {
|
|
@@ -203,7 +203,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
203
203
|
readonly={true}
|
|
204
204
|
/>
|
|
205
205
|
);
|
|
206
|
-
expect(container.textContent).toBe('
|
|
206
|
+
expect(container.textContent).toBe('—');
|
|
207
207
|
});
|
|
208
208
|
|
|
209
209
|
it('EmailField shows dash for empty value in readonly', () => {
|
|
@@ -215,7 +215,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
215
215
|
readonly={true}
|
|
216
216
|
/>
|
|
217
217
|
);
|
|
218
|
-
expect(container.textContent).toBe('
|
|
218
|
+
expect(container.textContent).toBe('—');
|
|
219
219
|
});
|
|
220
220
|
|
|
221
221
|
it('UrlField shows dash for empty value in readonly', () => {
|
|
@@ -227,7 +227,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
227
227
|
readonly={true}
|
|
228
228
|
/>
|
|
229
229
|
);
|
|
230
|
-
expect(container.textContent).toBe('
|
|
230
|
+
expect(container.textContent).toBe('—');
|
|
231
231
|
});
|
|
232
232
|
|
|
233
233
|
it('PhoneField shows dash for empty value in readonly', () => {
|
|
@@ -239,7 +239,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
239
239
|
readonly={true}
|
|
240
240
|
/>
|
|
241
241
|
);
|
|
242
|
-
expect(container.textContent).toBe('
|
|
242
|
+
expect(container.textContent).toBe('—');
|
|
243
243
|
});
|
|
244
244
|
|
|
245
245
|
it('TextAreaField shows dash for empty value in readonly', () => {
|
|
@@ -251,7 +251,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
251
251
|
readonly={true}
|
|
252
252
|
/>
|
|
253
253
|
);
|
|
254
|
-
expect(container.textContent).toBe('
|
|
254
|
+
expect(container.textContent).toBe('—');
|
|
255
255
|
});
|
|
256
256
|
|
|
257
257
|
it('BooleanField shows Yes/No in readonly', () => {
|
|
@@ -285,7 +285,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
285
285
|
readonly={true}
|
|
286
286
|
/>
|
|
287
287
|
);
|
|
288
|
-
expect(container.textContent).toBe('
|
|
288
|
+
expect(container.textContent).toBe('—');
|
|
289
289
|
});
|
|
290
290
|
|
|
291
291
|
it('CurrencyField shows dash for null value in readonly', () => {
|
|
@@ -297,7 +297,7 @@ describe('P3.2 Validation Feedback', () => {
|
|
|
297
297
|
readonly={true}
|
|
298
298
|
/>
|
|
299
299
|
);
|
|
300
|
-
expect(container.textContent).toBe('
|
|
300
|
+
expect(container.textContent).toBe('—');
|
|
301
301
|
});
|
|
302
302
|
});
|
|
303
303
|
|
|
@@ -42,7 +42,7 @@ describe('Date/Time Widgets', () => {
|
|
|
42
42
|
|
|
43
43
|
it('renders dash for empty readonly value', () => {
|
|
44
44
|
render(<DateField {...baseProps} readonly value={''} />);
|
|
45
|
-
expect(screen.getByText('
|
|
45
|
+
expect(screen.getByText('—')).toBeInTheDocument();
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
48
|
|
package/src/index.tsx
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import React from 'react';
|
|
10
10
|
import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
|
|
11
11
|
import { ComponentRegistry } from '@object-ui/core';
|
|
12
|
-
import { Badge, Avatar, AvatarFallback, Button, Checkbox } from '@object-ui/components';
|
|
12
|
+
import { Badge, Avatar, AvatarFallback, Button, Checkbox, EmptyValue } from '@object-ui/components';
|
|
13
13
|
import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
|
|
14
14
|
|
|
15
15
|
import { TextField } from './widgets/TextField';
|
|
@@ -154,9 +154,9 @@ export function humanizeLabel(value: string): string {
|
|
|
154
154
|
* Format date as relative time (e.g., "2 days ago", "Today", "Overdue 3d")
|
|
155
155
|
*/
|
|
156
156
|
export function formatRelativeDate(value: string | Date): string {
|
|
157
|
-
if (!value) return '
|
|
157
|
+
if (!value) return '—';
|
|
158
158
|
const date = typeof value === 'string' ? new Date(value) : value;
|
|
159
|
-
if (isNaN(date.getTime())) return '
|
|
159
|
+
if (isNaN(date.getTime())) return '—';
|
|
160
160
|
|
|
161
161
|
const now = new Date();
|
|
162
162
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
@@ -180,9 +180,9 @@ export function formatRelativeDate(value: string | Date): string {
|
|
|
180
180
|
* Format date value
|
|
181
181
|
*/
|
|
182
182
|
export function formatDate(value: string | Date, style?: string): string {
|
|
183
|
-
if (!value) return '
|
|
183
|
+
if (!value) return '—';
|
|
184
184
|
const date = typeof value === 'string' ? new Date(value) : value;
|
|
185
|
-
if (isNaN(date.getTime())) return '
|
|
185
|
+
if (isNaN(date.getTime())) return '—';
|
|
186
186
|
|
|
187
187
|
if (style === 'short') {
|
|
188
188
|
// Compact format for mobile: "Jan 15, '24"
|
|
@@ -208,9 +208,9 @@ export function formatDate(value: string | Date, style?: string): string {
|
|
|
208
208
|
* Format datetime value
|
|
209
209
|
*/
|
|
210
210
|
export function formatDateTime(value: string | Date): string {
|
|
211
|
-
if (!value) return '
|
|
211
|
+
if (!value) return '—';
|
|
212
212
|
const date = typeof value === 'string' ? new Date(value) : value;
|
|
213
|
-
if (isNaN(date.getTime())) return '
|
|
213
|
+
if (isNaN(date.getTime())) return '—';
|
|
214
214
|
|
|
215
215
|
return date.toLocaleDateString(undefined, {
|
|
216
216
|
year: 'numeric',
|
|
@@ -226,14 +226,15 @@ export function formatDateTime(value: string | Date): string {
|
|
|
226
226
|
*/
|
|
227
227
|
export function TextCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
228
228
|
const safe = coerceToSafeValue(value);
|
|
229
|
-
|
|
229
|
+
if (safe == null || safe === '') return <EmptyValue />;
|
|
230
|
+
return <span className="truncate">{String(safe)}</span>;
|
|
230
231
|
}
|
|
231
232
|
|
|
232
233
|
/**
|
|
233
234
|
* Number field cell renderer
|
|
234
235
|
*/
|
|
235
236
|
export function NumberCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
236
|
-
if (value == null) return <
|
|
237
|
+
if (value == null) return <EmptyValue />;
|
|
237
238
|
|
|
238
239
|
const safe = coerceToSafeValue(value);
|
|
239
240
|
const numField = field as any;
|
|
@@ -250,7 +251,7 @@ export function NumberCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
250
251
|
* Currency field cell renderer
|
|
251
252
|
*/
|
|
252
253
|
export function CurrencyCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
253
|
-
if (value == null) return <
|
|
254
|
+
if (value == null) return <EmptyValue />;
|
|
254
255
|
|
|
255
256
|
const safe = coerceToSafeValue(value);
|
|
256
257
|
const currencyField = field as any;
|
|
@@ -268,7 +269,7 @@ const WHOLE_PERCENT_FIELD_PATTERN = /progress|completion/;
|
|
|
268
269
|
* Percent field cell renderer with mini progress bar
|
|
269
270
|
*/
|
|
270
271
|
export function PercentCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
271
|
-
if (value == null) return <
|
|
272
|
+
if (value == null) return <EmptyValue />;
|
|
272
273
|
|
|
273
274
|
const safe = coerceToSafeValue(value);
|
|
274
275
|
const percentField = field as any;
|
|
@@ -317,7 +318,7 @@ const STATUS_FIELD_NAMES = new Set([
|
|
|
317
318
|
*/
|
|
318
319
|
export function BooleanCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
319
320
|
if (value == null) {
|
|
320
|
-
return <span className="
|
|
321
|
+
return <span className="flex items-center justify-center"><EmptyValue /></span>;
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
// Semantic rendering for completion fields (green circle indicator)
|
|
@@ -360,7 +361,7 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
|
|
|
360
361
|
* Date field cell renderer
|
|
361
362
|
*/
|
|
362
363
|
export function DateCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
363
|
-
if (!value) return <
|
|
364
|
+
if (!value) return <EmptyValue />;
|
|
364
365
|
const safe = coerceToSafeValue(value);
|
|
365
366
|
const dateField = field as any;
|
|
366
367
|
const style = dateField.format || 'relative';
|
|
@@ -388,10 +389,10 @@ export function DateCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
388
389
|
* DateTime field cell renderer (Airtable-style with date and time visually separated)
|
|
389
390
|
*/
|
|
390
391
|
export function DateTimeCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
391
|
-
if (!value) return <
|
|
392
|
+
if (!value) return <EmptyValue />;
|
|
392
393
|
const safe = coerceToSafeValue(value);
|
|
393
394
|
const date = typeof safe === 'string' ? new Date(safe) : safe;
|
|
394
|
-
if (!(date instanceof Date) || isNaN(date.getTime())) return <
|
|
395
|
+
if (!(date instanceof Date) || isNaN(date.getTime())) return <EmptyValue />;
|
|
395
396
|
|
|
396
397
|
const datePart = date.toLocaleDateString(undefined, {
|
|
397
398
|
month: 'numeric',
|
|
@@ -463,10 +464,34 @@ const BADGE_COLOR_MAP: Record<string, string> = {
|
|
|
463
464
|
pink: 'bg-pink-100 text-pink-800 border-pink-300',
|
|
464
465
|
};
|
|
465
466
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
467
|
+
// Color palette used by the deterministic fallback when no schema/semantic
|
|
468
|
+
// color matches. Excludes 'gray' to ensure visual contrast between values.
|
|
469
|
+
const BADGE_FALLBACK_PALETTE: readonly string[] = [
|
|
470
|
+
'blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red',
|
|
471
|
+
];
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Stable string hash (djb2-ish) → palette index.
|
|
475
|
+
* Same value always yields the same color across renders/sessions.
|
|
476
|
+
*/
|
|
477
|
+
function hashToColor(value: string): string {
|
|
478
|
+
let h = 5381;
|
|
479
|
+
for (let i = 0; i < value.length; i++) {
|
|
480
|
+
h = ((h << 5) + h) ^ value.charCodeAt(i);
|
|
481
|
+
}
|
|
482
|
+
const idx = Math.abs(h) % BADGE_FALLBACK_PALETTE.length;
|
|
483
|
+
return BADGE_FALLBACK_PALETTE[idx];
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function getBadgeColorClasses(color?: string, val?: unknown): string {
|
|
487
|
+
if (color && BADGE_COLOR_MAP[color]) return BADGE_COLOR_MAP[color];
|
|
488
|
+
if (val == null || val === '') return 'bg-muted text-muted-foreground border-border';
|
|
489
|
+
const key = String(val).toLowerCase().replace(/[\s-]/g, '_');
|
|
490
|
+
const semantic = SEMANTIC_COLOR_MAP[key];
|
|
491
|
+
if (semantic && BADGE_COLOR_MAP[semantic]) return BADGE_COLOR_MAP[semantic];
|
|
492
|
+
// Deterministic fallback so distinct values are visually distinguishable
|
|
493
|
+
// even when metadata declares no colors.
|
|
494
|
+
return BADGE_COLOR_MAP[hashToColor(key)];
|
|
470
495
|
}
|
|
471
496
|
|
|
472
497
|
/**
|
|
@@ -476,7 +501,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
476
501
|
const selectField = field as any;
|
|
477
502
|
const options: SelectOptionMetadata[] = selectField.options || [];
|
|
478
503
|
|
|
479
|
-
if (
|
|
504
|
+
if (value == null || value === '') return <EmptyValue />;
|
|
480
505
|
|
|
481
506
|
// Handle multiple values
|
|
482
507
|
if (Array.isArray(value)) {
|
|
@@ -520,7 +545,7 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
520
545
|
* Email field cell renderer
|
|
521
546
|
*/
|
|
522
547
|
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
523
|
-
if (!value) return <
|
|
548
|
+
if (!value) return <EmptyValue />;
|
|
524
549
|
|
|
525
550
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
526
551
|
const [copied, setCopied] = React.useState(false);
|
|
@@ -568,7 +593,7 @@ export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
568
593
|
* URL field cell renderer
|
|
569
594
|
*/
|
|
570
595
|
export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
571
|
-
if (!value) return <
|
|
596
|
+
if (!value) return <EmptyValue />;
|
|
572
597
|
|
|
573
598
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
574
599
|
return (
|
|
@@ -593,7 +618,7 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
|
|
|
593
618
|
* Phone field cell renderer
|
|
594
619
|
*/
|
|
595
620
|
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
596
|
-
if (!value) return <
|
|
621
|
+
if (!value) return <EmptyValue />;
|
|
597
622
|
|
|
598
623
|
const safe = String(coerceToSafeValue(value) ?? '');
|
|
599
624
|
const [copied, setCopied] = React.useState(false);
|
|
@@ -637,7 +662,7 @@ export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
637
662
|
* File field cell renderer
|
|
638
663
|
*/
|
|
639
664
|
export function FileCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
640
|
-
if (!value) return <
|
|
665
|
+
if (!value) return <EmptyValue />;
|
|
641
666
|
|
|
642
667
|
const fileField = field as any;
|
|
643
668
|
const isMultiple = fileField.multiple;
|
|
@@ -659,7 +684,7 @@ export function FileCellRenderer({ value, field }: CellRendererProps): React.Rea
|
|
|
659
684
|
* Image field cell renderer (with thumbnails)
|
|
660
685
|
*/
|
|
661
686
|
export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
662
|
-
if (!value) return <
|
|
687
|
+
if (!value) return <EmptyValue />;
|
|
663
688
|
|
|
664
689
|
if (Array.isArray(value)) {
|
|
665
690
|
return (
|
|
@@ -694,7 +719,7 @@ export function ImageCellRenderer({ value }: CellRendererProps): React.ReactElem
|
|
|
694
719
|
* Lookup/Master-Detail field cell renderer
|
|
695
720
|
*/
|
|
696
721
|
export function LookupCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
|
|
697
|
-
if (value == null || value === '') return <
|
|
722
|
+
if (value == null || value === '') return <EmptyValue />;
|
|
698
723
|
|
|
699
724
|
const options: Array<{ value: unknown; label: string }> =
|
|
700
725
|
(field as { options?: Array<{ value: unknown; label: string }> }).options || [];
|
|
@@ -745,9 +770,10 @@ export function LookupCellRenderer({ value, field }: CellRendererProps): React.R
|
|
|
745
770
|
*/
|
|
746
771
|
export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
747
772
|
const safe = coerceToSafeValue(value);
|
|
773
|
+
if (safe == null || safe === '') return <EmptyValue />;
|
|
748
774
|
return (
|
|
749
775
|
<span className="text-gray-700 font-mono text-sm">
|
|
750
|
-
{
|
|
776
|
+
{String(safe)}
|
|
751
777
|
</span>
|
|
752
778
|
);
|
|
753
779
|
}
|
|
@@ -756,7 +782,7 @@ export function FormulaCellRenderer({ value }: CellRendererProps): React.ReactEl
|
|
|
756
782
|
* User/Owner field cell renderer (with avatars)
|
|
757
783
|
*/
|
|
758
784
|
export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
|
|
759
|
-
if (!value) return <
|
|
785
|
+
if (!value) return <EmptyValue />;
|
|
760
786
|
|
|
761
787
|
// Primitive value: just display the ID/username as text
|
|
762
788
|
if (typeof value !== 'object') {
|