@object-ui/fields 6.2.3 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +203 -0
- package/dist/complex-widgets.test.d.ts +1 -0
- package/dist/currency.d.ts +13 -0
- package/dist/datetime-widgets.test.d.ts +1 -0
- package/dist/field-type-coverage.test.d.ts +1 -0
- package/dist/index.cjs +4 -3
- package/dist/index.d.ts +300 -2
- package/dist/index.js +2460 -1274
- package/dist/standard-widgets.test.d.ts +0 -0
- package/dist/{packages/fields/src/widgets → widgets}/AddressField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/AutoNumberField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/AvatarField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/BooleanField.d.ts +2 -1
- package/dist/widgets/CheckboxesField.d.ts +8 -0
- package/dist/{packages/fields/src/widgets → widgets}/CodeField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/ColorField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/CurrencyField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/DateField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/DateTimeField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/EmailField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/FileField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/FormulaField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/GeolocationField.d.ts +2 -1
- package/dist/widgets/GridField.d.ts +123 -0
- package/dist/widgets/GridField.test.d.ts +1 -0
- package/dist/{packages/fields/src/widgets → widgets}/ImageField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/LocationField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/LookupField.d.ts +2 -1
- package/dist/widgets/MarkdownContent.d.ts +9 -0
- package/dist/{packages/fields/src/widgets → widgets}/MasterDetailField.d.ts +2 -1
- package/dist/widgets/MultiSelectField.d.ts +9 -0
- package/dist/{packages/fields/src/widgets → widgets}/NumberField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/ObjectField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/PasswordField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/PercentField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/PhoneField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/QRCodeField.d.ts +2 -1
- package/dist/widgets/RadioField.d.ts +8 -0
- package/dist/{packages/fields/src/widgets → widgets}/RatingField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/RecordPickerDialog.d.ts +8 -2
- package/dist/{packages/fields/src/widgets → widgets}/RichTextField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SelectField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SignatureField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SliderField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/SummaryField.d.ts +2 -1
- package/dist/widgets/TagsField.d.ts +8 -0
- package/dist/{packages/fields/src/widgets → widgets}/TextAreaField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/TextField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/TimeField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/UrlField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/UserField.d.ts +2 -1
- package/dist/{packages/fields/src/widgets → widgets}/VectorField.d.ts +2 -1
- package/dist/widgets/deriveLookupColumns.d.ts +36 -0
- package/dist/widgets/deriveLookupColumns.test.d.ts +1 -0
- package/dist/widgets/recentLookups.d.ts +13 -0
- package/dist/widgets/recentLookups.test.d.ts +1 -0
- package/dist/{packages/fields/src/widgets → widgets}/types.d.ts +1 -1
- package/package.json +14 -11
- package/src/__tests__/registry-collision.test.ts +91 -0
- package/src/__tests__/resolveFieldCurrency.test.ts +20 -0
- package/src/coerce-safe-value.test.ts +34 -0
- package/src/complex-widgets.test.tsx +58 -7
- package/src/currency.ts +14 -0
- package/src/field-type-coverage.test.ts +65 -0
- package/src/index.tsx +372 -57
- package/src/widgets/CheckboxesField.tsx +53 -0
- package/src/widgets/CurrencyField.tsx +5 -1
- package/src/widgets/GridField.test.tsx +309 -0
- package/src/widgets/GridField.tsx +907 -33
- package/src/widgets/LookupField.tsx +258 -107
- package/src/widgets/MarkdownContent.tsx +19 -0
- package/src/widgets/MultiSelectField.tsx +59 -0
- package/src/widgets/PercentField.tsx +19 -7
- package/src/widgets/RadioField.tsx +41 -0
- package/src/widgets/RecordPickerDialog.tsx +22 -18
- package/src/widgets/SelectField.tsx +23 -2
- package/src/widgets/TagsField.tsx +67 -0
- package/src/widgets/deriveLookupColumns.test.ts +74 -0
- package/src/widgets/deriveLookupColumns.ts +104 -0
- package/src/widgets/recentLookups.test.ts +45 -0
- package/src/widgets/recentLookups.ts +47 -0
- package/src/widgets/useFieldTranslation.ts +30 -0
- package/dist/packages/fields/src/fields-edge-cases.stories.d.ts +0 -45
- package/dist/packages/fields/src/fields.stories.d.ts +0 -56
- package/dist/packages/fields/src/index.d.ts +0 -286
- package/dist/packages/fields/src/record-picker-dialog.stories.d.ts +0 -25
- package/dist/packages/fields/src/widgets/GridField.d.ts +0 -7
- package/src/fields-edge-cases.stories.tsx +0 -407
- package/src/fields.stories.tsx +0 -566
- package/src/record-picker-dialog.stories.tsx +0 -160
- /package/dist/{packages/fields/src/__tests__/validation-feedback.test.d.ts → __tests__/registry-collision.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/complex-widgets.test.d.ts → __tests__/resolveFieldCurrency.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/standard-widgets.test.d.ts → __tests__/validation-feedback.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/datetime-widgets.test.d.ts → coerce-safe-value.test.d.ts} +0 -0
- /package/dist/{packages/fields/src/widgets → widgets}/ImageCropperDialog.d.ts +0 -0
- /package/dist/{packages/fields/src/widgets → widgets}/_cell-renderer-bridge.d.ts +0 -0
- /package/dist/{packages/fields/src/widgets → widgets}/useFieldTranslation.d.ts +0 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recently-used lookup memory.
|
|
3
|
+
*
|
|
4
|
+
* Mainstream record pickers (Salesforce, Dataverse, SAP, ServiceNow) surface
|
|
5
|
+
* the user's recently-picked records the moment a lookup is focused — before
|
|
6
|
+
* any typing. We approximate that with a small per-object ring of the last few
|
|
7
|
+
* selected record ids, kept in localStorage (no backend dependency). When a
|
|
8
|
+
* recent-items API later exists, this module is the single seam to swap.
|
|
9
|
+
*/
|
|
10
|
+
/** Most-recent-first list of record ids previously chosen for `objectName`. */
|
|
11
|
+
export declare function getRecentLookupIds(objectName: string): Array<string | number>;
|
|
12
|
+
/** Record that `id` was just chosen for `objectName` (moves it to the front). */
|
|
13
|
+
export declare function pushRecentLookupId(objectName: string, id: string | number | null | undefined): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@object-ui/fields",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.1.0",
|
|
4
4
|
"description": "Field renderers and registry for Object UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,26 +25,29 @@
|
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"clsx": "^2.1.1",
|
|
28
|
-
"lucide-react": "^1.
|
|
28
|
+
"lucide-react": "^1.21.0",
|
|
29
|
+
"react-markdown": "^10.1.0",
|
|
30
|
+
"rehype-sanitize": "^6.0.0",
|
|
31
|
+
"remark-gfm": "^4.0.1",
|
|
29
32
|
"tailwind-merge": "^3.6.0",
|
|
30
|
-
"@object-ui/components": "
|
|
31
|
-
"@object-ui/core": "
|
|
32
|
-
"@object-ui/i18n": "
|
|
33
|
-
"@object-ui/providers": "
|
|
34
|
-
"@object-ui/react": "
|
|
35
|
-
"@object-ui/types": "
|
|
33
|
+
"@object-ui/components": "7.1.0",
|
|
34
|
+
"@object-ui/core": "7.1.0",
|
|
35
|
+
"@object-ui/i18n": "7.1.0",
|
|
36
|
+
"@object-ui/providers": "7.1.0",
|
|
37
|
+
"@object-ui/react": "7.1.0",
|
|
38
|
+
"@object-ui/types": "7.1.0"
|
|
36
39
|
},
|
|
37
40
|
"peerDependencies": {
|
|
38
41
|
"react": "^18.0.0 || ^19.0.0",
|
|
39
42
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
40
43
|
},
|
|
41
44
|
"devDependencies": {
|
|
42
|
-
"@types/react": "19.2.
|
|
45
|
+
"@types/react": "19.2.17",
|
|
43
46
|
"@types/react-dom": "19.2.3",
|
|
44
47
|
"@vitejs/plugin-react": "^6.0.2",
|
|
45
48
|
"typescript": "^6.0.3",
|
|
46
|
-
"vite": "^8.0.
|
|
47
|
-
"vite-plugin-dts": "^5.0.
|
|
49
|
+
"vite": "^8.0.16",
|
|
50
|
+
"vite-plugin-dts": "^5.0.2"
|
|
48
51
|
},
|
|
49
52
|
"publishConfig": {
|
|
50
53
|
"access": "public"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registry collision guard.
|
|
11
|
+
*
|
|
12
|
+
* `@object-ui/components` registers display widgets (`text`, `image`,
|
|
13
|
+
* `avatar`, `html`, `grid`) under their bare names. `@object-ui/fields`
|
|
14
|
+
* registers form-input widgets that — by name — collide with those.
|
|
15
|
+
*
|
|
16
|
+
* The fix: field registrations for these 5 types pass `skipFallback: true`,
|
|
17
|
+
* so the bare lookup keeps returning the display widget while the field
|
|
18
|
+
* input is still reachable via the `field:<type>` namespace.
|
|
19
|
+
*
|
|
20
|
+
* Regression contract:
|
|
21
|
+
* 1. `FIELD_TYPES_SKIP_FALLBACK` must contain every colliding name.
|
|
22
|
+
* 2. After `registerAllFields()`, bare lookups must NOT resolve to a
|
|
23
|
+
* field renderer for any of those names.
|
|
24
|
+
* 3. The `field:<type>` namespaced lookup must still work, so form
|
|
25
|
+
* schemas using `{ type: 'text' }` still find an input renderer
|
|
26
|
+
* when explicitly requested.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
30
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
31
|
+
|
|
32
|
+
// Importing the fields entry runs `registerAllFields()` at module load.
|
|
33
|
+
// Importing the components entry runs display-widget registration the
|
|
34
|
+
// same way. Both must be loaded before we inspect the registry.
|
|
35
|
+
import '@object-ui/components';
|
|
36
|
+
import { registerAllFields } from '../index';
|
|
37
|
+
|
|
38
|
+
const COLLIDING_TYPES = ['text', 'html', 'image', 'avatar', 'grid'] as const;
|
|
39
|
+
|
|
40
|
+
describe('registry collision (fields ↔ components)', () => {
|
|
41
|
+
beforeAll(() => {
|
|
42
|
+
// Defensive: in case the auto-register at import time hasn't run yet
|
|
43
|
+
// under the test runner's module isolation.
|
|
44
|
+
registerAllFields();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it.each(COLLIDING_TYPES)(
|
|
48
|
+
'bare "%s" lookup is NOT clobbered by the field renderer',
|
|
49
|
+
(type) => {
|
|
50
|
+
// The bare lookup must resolve, and it must resolve to a display widget
|
|
51
|
+
// (or a FallbackComponent / null) — never to the lazy field renderer.
|
|
52
|
+
// The field renderer is a React.lazy() result; the display widget is a
|
|
53
|
+
// plain function component. We don't pin to a specific identity, but we
|
|
54
|
+
// DO pin that whatever the bare lookup returns, the namespaced
|
|
55
|
+
// `field:<type>` lookup returns something different.
|
|
56
|
+
const bare = ComponentRegistry.get(type);
|
|
57
|
+
const namespaced = ComponentRegistry.get(`field:${type}`);
|
|
58
|
+
|
|
59
|
+
// Namespaced field renderer must exist for every colliding type —
|
|
60
|
+
// that's the whole point of keeping the field accessible.
|
|
61
|
+
expect(
|
|
62
|
+
namespaced,
|
|
63
|
+
`field:${type} must be registered so forms can still use it`,
|
|
64
|
+
).toBeTruthy();
|
|
65
|
+
|
|
66
|
+
// The bare and namespaced renderers must NOT be identical. If they
|
|
67
|
+
// are, it means the field renderer wrote to the bare fallback and
|
|
68
|
+
// would render an empty <input> instead of the display widget.
|
|
69
|
+
expect(
|
|
70
|
+
bare,
|
|
71
|
+
`bare "${type}" must not be the same component as field:${type}`,
|
|
72
|
+
).not.toBe(namespaced);
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it('non-colliding field types DO populate the bare fallback', () => {
|
|
77
|
+
// Sanity check: a field type with no display-widget collision (e.g.
|
|
78
|
+
// `email`, `password`, `number`) must still register both at the
|
|
79
|
+
// namespaced AND bare keys, otherwise we'd silently break forms.
|
|
80
|
+
for (const type of ['email', 'password', 'number', 'date', 'select']) {
|
|
81
|
+
expect(
|
|
82
|
+
ComponentRegistry.get(`field:${type}`),
|
|
83
|
+
`field:${type} must be registered`,
|
|
84
|
+
).toBeTruthy();
|
|
85
|
+
expect(
|
|
86
|
+
ComponentRegistry.get(type),
|
|
87
|
+
`bare "${type}" must also be reachable (no collision)`,
|
|
88
|
+
).toBeTruthy();
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveFieldCurrency } from '../index';
|
|
3
|
+
|
|
4
|
+
describe('resolveFieldCurrency', () => {
|
|
5
|
+
it('prefers the field explicit currency over everything', () => {
|
|
6
|
+
expect(resolveFieldCurrency({ currency: 'JPY', currencyConfig: { defaultCurrency: 'EUR' } }, 'USD')).toBe('JPY');
|
|
7
|
+
});
|
|
8
|
+
it('falls back to currencyConfig.defaultCurrency, then legacy defaultCurrency', () => {
|
|
9
|
+
expect(resolveFieldCurrency({ currencyConfig: { defaultCurrency: 'EUR' } }, 'USD')).toBe('EUR');
|
|
10
|
+
expect(resolveFieldCurrency({ defaultCurrency: 'GBP' } as any, 'USD')).toBe('GBP');
|
|
11
|
+
});
|
|
12
|
+
it('falls back to the tenant default when the field omits its own', () => {
|
|
13
|
+
expect(resolveFieldCurrency({}, 'CNY')).toBe('CNY');
|
|
14
|
+
expect(resolveFieldCurrency(null, 'CNY')).toBe('CNY');
|
|
15
|
+
});
|
|
16
|
+
it('returns undefined when nothing is known (→ renderer shows a plain number)', () => {
|
|
17
|
+
expect(resolveFieldCurrency({})).toBeUndefined();
|
|
18
|
+
expect(resolveFieldCurrency(undefined, undefined)).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { coerceToSafeValue } from './index';
|
|
3
|
+
|
|
4
|
+
describe('coerceToSafeValue — reference / lookup values', () => {
|
|
5
|
+
it('extracts a label from a JSON-string reference (unresolved external-id ref)', () => {
|
|
6
|
+
// Regression: a master_detail/lookup value can arrive as a JSON-encoded
|
|
7
|
+
// string; it must render a label, not raw '{"externalId":"..."}'.
|
|
8
|
+
expect(coerceToSafeValue('{"externalId":"Website Relaunch"}')).toBe('Website Relaunch');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('extracts a label from a reference object, name > label > externalId > id', () => {
|
|
12
|
+
expect(coerceToSafeValue({ externalId: 'X' })).toBe('X');
|
|
13
|
+
expect(coerceToSafeValue({ name: 'N', externalId: 'X' })).toBe('N');
|
|
14
|
+
expect(coerceToSafeValue({ label: 'L', externalId: 'X' })).toBe('L');
|
|
15
|
+
expect(coerceToSafeValue({ id: 'id1' })).toBe('id1');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('handles a JSON-string array of references', () => {
|
|
19
|
+
expect(coerceToSafeValue('[{"name":"A"},{"externalId":"B"}]')).toBe('A, B');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('leaves plain strings and non-JSON-looking strings untouched', () => {
|
|
23
|
+
expect(coerceToSafeValue('Website Relaunch')).toBe('Website Relaunch');
|
|
24
|
+
expect(coerceToSafeValue('{not valid json')).toBe('{not valid json');
|
|
25
|
+
expect(coerceToSafeValue('hello')).toBe('hello');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('passes through primitives and null/undefined', () => {
|
|
29
|
+
expect(coerceToSafeValue(42)).toBe(42);
|
|
30
|
+
expect(coerceToSafeValue(true)).toBe(true);
|
|
31
|
+
expect(coerceToSafeValue(null)).toBe(null);
|
|
32
|
+
expect(coerceToSafeValue(undefined)).toBe(undefined);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -84,6 +84,7 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
84
84
|
|
|
85
85
|
beforeEach(() => {
|
|
86
86
|
vi.clearAllMocks();
|
|
87
|
+
try { localStorage.clear(); } catch { /* jsdom */ }
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
it('fetches data from DataSource when dialog opens', async () => {
|
|
@@ -312,6 +313,53 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
312
313
|
expect(onCreateNew).toHaveBeenCalledWith('');
|
|
313
314
|
});
|
|
314
315
|
|
|
316
|
+
it('quick-creates via dataSource.create when allow_create is set', async () => {
|
|
317
|
+
mockDataSource.find.mockResolvedValue({ data: [], total: 0 });
|
|
318
|
+
mockDataSource.create.mockResolvedValue({ id: 'new-1', name: 'Acme' });
|
|
319
|
+
const onChange = vi.fn();
|
|
320
|
+
const field = { ...dynamicField, allow_create: true } as any;
|
|
321
|
+
|
|
322
|
+
render(<LookupField {...dynamicProps} field={field} onChange={onChange} />);
|
|
323
|
+
|
|
324
|
+
await act(async () => {
|
|
325
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
const input = await screen.findByRole('combobox');
|
|
329
|
+
await act(async () => {
|
|
330
|
+
fireEvent.change(input, { target: { value: 'Acme' } });
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const createBtn = await screen.findByText('Create new "Acme"');
|
|
334
|
+
await act(async () => {
|
|
335
|
+
fireEvent.click(createBtn);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await waitFor(() => {
|
|
339
|
+
expect(mockDataSource.create).toHaveBeenCalledWith('customers', { name: 'Acme' });
|
|
340
|
+
});
|
|
341
|
+
await waitFor(() => {
|
|
342
|
+
expect(onChange).toHaveBeenCalledWith('new-1');
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('shows a recently-used section on empty focus', async () => {
|
|
347
|
+
localStorage.setItem('objectui:lookup:recent:customers', JSON.stringify(['r1']));
|
|
348
|
+
mockDataSource.find.mockResolvedValue({ data: [{ id: 'a', name: 'Acme Corp' }], total: 1 });
|
|
349
|
+
mockDataSource.findOne.mockResolvedValue({ id: 'r1', name: 'Recent Co' });
|
|
350
|
+
|
|
351
|
+
render(<LookupField {...dynamicProps} />);
|
|
352
|
+
|
|
353
|
+
await act(async () => {
|
|
354
|
+
fireEvent.click(screen.getByRole('button', { name: /Select/i }));
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await waitFor(() => {
|
|
358
|
+
expect(screen.getByText('Recently used')).toBeInTheDocument();
|
|
359
|
+
expect(screen.getByText('Recent Co')).toBeInTheDocument();
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
315
363
|
it('navigates options with arrow keys and selects with Enter', async () => {
|
|
316
364
|
const onChange = vi.fn();
|
|
317
365
|
mockDataSource.find.mockResolvedValue({
|
|
@@ -522,8 +570,8 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
522
570
|
|
|
523
571
|
describe('GridField', () => {
|
|
524
572
|
const columns = [
|
|
525
|
-
{
|
|
526
|
-
{
|
|
573
|
+
{ field: 'name', label: 'Name' },
|
|
574
|
+
{ field: 'age', label: 'Age', type: 'number' }
|
|
527
575
|
];
|
|
528
576
|
const data = [
|
|
529
577
|
{ name: 'Alice', age: 30 },
|
|
@@ -534,17 +582,20 @@ describe('Complex & Relationship Widgets', () => {
|
|
|
534
582
|
field: { ...mockField, columns }
|
|
535
583
|
};
|
|
536
584
|
|
|
537
|
-
it('renders
|
|
585
|
+
it('renders the rows as text in readonly mode', () => {
|
|
538
586
|
render(<GridField {...gridProps} readonly value={data} />);
|
|
539
|
-
expect(screen.getByText('
|
|
587
|
+
expect(screen.getByText('Alice')).toBeInTheDocument();
|
|
588
|
+
expect(screen.getByText('Bob')).toBeInTheDocument();
|
|
589
|
+
expect(screen.getByText('30')).toBeInTheDocument();
|
|
540
590
|
});
|
|
541
591
|
|
|
542
|
-
it('renders table with
|
|
592
|
+
it('renders an editable table with values in edit mode', () => {
|
|
543
593
|
render(<GridField {...gridProps} value={data} />);
|
|
544
594
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
|
545
595
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
|
546
|
-
|
|
547
|
-
expect(screen.
|
|
596
|
+
// edit mode renders inputs whose values are the row data
|
|
597
|
+
expect(screen.getByDisplayValue('Alice')).toBeInTheDocument();
|
|
598
|
+
expect(screen.getByDisplayValue('30')).toBeInTheDocument();
|
|
548
599
|
});
|
|
549
600
|
});
|
|
550
601
|
|
package/src/currency.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the MIT license found in the LICENSE file.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `resolveFieldCurrency` now lives in `@object-ui/i18n` (co-located with
|
|
10
|
+
* `useLocalization`, which supplies the tenant default). This module re-exports
|
|
11
|
+
* it so the long-standing `@object-ui/fields` import path keeps working for
|
|
12
|
+
* every field/cell renderer that already depends on it.
|
|
13
|
+
*/
|
|
14
|
+
export { resolveFieldCurrency } from '@object-ui/i18n';
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObjectUI
|
|
3
|
+
* Copyright (c) 2024-present ObjectStack Inc.
|
|
4
|
+
*
|
|
5
|
+
* Regression guard for the field-type → renderer registries. These mappings
|
|
6
|
+
* regressed once already: ~25 specialized field types silently fell back to a
|
|
7
|
+
* plain text input (form) and the raw text cell renderer (read), because the
|
|
8
|
+
* mapping tables were incomplete. This test locks them in so a dropped entry
|
|
9
|
+
* fails CI instead of shipping a broken widget.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { mapFieldTypeToFormType, getCellRenderer, TextCellRenderer } from './index';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Field types that MUST resolve to a dedicated form widget — never the
|
|
16
|
+
* `field:text` fallback. (Plain text-ish types like text/textarea are
|
|
17
|
+
* intentionally excluded.)
|
|
18
|
+
*/
|
|
19
|
+
const FORM_WIDGET_TYPES = [
|
|
20
|
+
'textarea', 'email', 'url', 'phone', 'password', 'secret',
|
|
21
|
+
'markdown', 'html', 'richtext',
|
|
22
|
+
'number', 'currency', 'percent', 'slider', 'progress', 'rating',
|
|
23
|
+
'date', 'datetime', 'time',
|
|
24
|
+
'boolean', 'toggle',
|
|
25
|
+
'select', 'multiselect', 'radio', 'checkboxes', 'tags',
|
|
26
|
+
'lookup', 'master_detail', 'tree',
|
|
27
|
+
'file', 'image', 'avatar', 'video', 'audio', 'signature',
|
|
28
|
+
'location', 'geolocation', 'address', 'color', 'code', 'json', 'qrcode', 'vector',
|
|
29
|
+
'object', 'composite', 'record', 'repeater',
|
|
30
|
+
'formula', 'summary', 'auto_number',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Field types that MUST resolve to a dedicated read/cell renderer — never the
|
|
35
|
+
* generic text cell renderer. (markdown/html/richtext render formatted;
|
|
36
|
+
* code/qrcode/time legitimately stay textual and are excluded.)
|
|
37
|
+
*/
|
|
38
|
+
const CELL_RENDERER_TYPES = [
|
|
39
|
+
'email', 'url', 'phone', 'number', 'currency', 'percent', 'progress',
|
|
40
|
+
'boolean', 'toggle', 'date', 'datetime',
|
|
41
|
+
'select', 'multiselect', 'radio', 'checkboxes', 'tags',
|
|
42
|
+
'lookup', 'master_detail', 'tree',
|
|
43
|
+
'file', 'video', 'audio', 'image', 'avatar', 'signature',
|
|
44
|
+
'markdown', 'html', 'richtext',
|
|
45
|
+
'location', 'geolocation', 'address', 'color', 'json',
|
|
46
|
+
'formula', 'summary', 'user', 'owner',
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
describe('field-type renderer coverage (regression guard)', () => {
|
|
50
|
+
it.each(FORM_WIDGET_TYPES)('maps "%s" to a dedicated form widget (not field:text)', (type) => {
|
|
51
|
+
const widget = mapFieldTypeToFormType(type);
|
|
52
|
+
expect(widget).toMatch(/^field:/);
|
|
53
|
+
expect(widget).not.toBe('field:text');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it.each(CELL_RENDERER_TYPES)('resolves "%s" to a dedicated cell renderer (not TextCellRenderer)', (type) => {
|
|
57
|
+
const renderer = getCellRenderer(type);
|
|
58
|
+
expect(renderer).toBeTypeOf('function');
|
|
59
|
+
expect(renderer).not.toBe(TextCellRenderer);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('falls back to field:text for unknown types', () => {
|
|
63
|
+
expect(mapFieldTypeToFormType('something-unknown')).toBe('field:text');
|
|
64
|
+
});
|
|
65
|
+
});
|