@object-ui/fields 14.0.0 → 15.0.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.
@@ -6,3 +6,25 @@ import { FieldWidgetProps } from './types';
6
6
  * L2: File size validation, per-file progress indicators, error messages.
7
7
  */
8
8
  export declare function FileField({ value, onChange, field, readonly, ...props }: FieldWidgetProps<any>): React.JSX.Element;
9
+ /**
10
+ * FileCell — compact upload control for a line-item grid cell (objectui#2360).
11
+ *
12
+ * Same value shape and upload pipeline as {@link FileField}, sized for a 32px
13
+ * grid row: existing files render as removable chips (image thumbnail / file
14
+ * icon + name) and a small button opens the native file picker. No
15
+ * drag-and-drop zone — a grid cell has no room for one; the per-row expand
16
+ * form still offers the full-size FileField.
17
+ */
18
+ export declare function FileCell({ value, onChange, disabled, multiple, accept, maxSize, 'aria-label': ariaLabel, 'data-cell': dataCell, }: {
19
+ value: any;
20
+ onChange: (value: any) => void;
21
+ disabled?: boolean;
22
+ multiple?: boolean;
23
+ /** Comma-joined accept list for the native picker (e.g. `"image/*,.pdf"`). */
24
+ accept?: string;
25
+ /** Max file size in bytes (oversize picks are rejected with an inline error). */
26
+ maxSize?: number;
27
+ 'aria-label'?: string;
28
+ /** Focus-grid coordinate (see GridField keyboard navigation). */
29
+ 'data-cell'?: string;
30
+ }): React.JSX.Element;
@@ -11,7 +11,7 @@ import { FieldWidgetProps } from './types';
11
11
  *
12
12
  * Column config (a subset of `GridColumnDefinition`):
13
13
  * { field, label?, type?, options?, width?, required?, prefix?, step? }
14
- * type ∈ 'text' | 'number' | 'currency' | 'date' | 'select'
14
+ * type ∈ 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file'
15
15
  *
16
16
  * Field-level config (from `GridFieldMetadata`):
17
17
  * columns, min_rows, max_rows, allow_add, allow_delete, total_field
@@ -19,7 +19,7 @@ import { FieldWidgetProps } from './types';
19
19
  export interface GridColumn {
20
20
  field: string;
21
21
  label?: string;
22
- type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup';
22
+ type?: 'text' | 'number' | 'currency' | 'date' | 'select' | 'lookup' | 'file';
23
23
  options?: Array<{
24
24
  label: string;
25
25
  value: string;
@@ -32,7 +32,11 @@ export interface GridColumn {
32
32
  reference?: string;
33
33
  displayField?: string;
34
34
  idField?: string;
35
+ /** Multi-value column: multi-record lookup, or multi-file upload cell. */
35
36
  multiple?: boolean;
37
+ /** For `type: 'file'` — accepted MIME types / extensions for the picker
38
+ * (e.g. `['image/*', '.pdf']`). Omit to accept anything. */
39
+ accept?: string[];
36
40
  /**
37
41
  * Hidden from the grid by default but revealable via the column chooser.
38
42
  * Set by `deriveColumns` for fields beyond the default-visible budget — the
@@ -6,7 +6,7 @@ import { FieldWidgetProps } from './types';
6
6
  * Supports cascading / role-gated options (#2284): each option may carry a
7
7
  * `visibleWhen` CEL predicate, evaluated against the live form record +
8
8
  * `current_user`, so the offered set narrows as a controlling field changes
9
- * (`record.country == 'cn'`) or by role (`'admin' in current_user.roles`). A
9
+ * (`record.country == 'cn'`) or by role (`'admin' in current_user.positions`). A
10
10
  * field declares which sibling fields drive its list via `dependsOn`; while any
11
11
  * of those is empty the control is gated with a "select the parent first" hint,
12
12
  * mirroring the dependent-lookup UX.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/fields",
3
- "version": "14.0.0",
3
+ "version": "15.0.0",
4
4
  "description": "Field renderers and registry for Object UI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,12 +30,12 @@
30
30
  "rehype-sanitize": "^6.0.0",
31
31
  "remark-gfm": "^4.0.1",
32
32
  "tailwind-merge": "^3.6.0",
33
- "@object-ui/components": "14.0.0",
34
- "@object-ui/core": "14.0.0",
35
- "@object-ui/i18n": "14.0.0",
36
- "@object-ui/providers": "14.0.0",
37
- "@object-ui/react": "14.0.0",
38
- "@object-ui/types": "14.0.0"
33
+ "@object-ui/components": "15.0.0",
34
+ "@object-ui/core": "15.0.0",
35
+ "@object-ui/i18n": "15.0.0",
36
+ "@object-ui/providers": "15.0.0",
37
+ "@object-ui/react": "15.0.0",
38
+ "@object-ui/types": "15.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "react": "^18.0.0 || ^19.0.0",
@@ -23,16 +23,39 @@ describe('formatRelativeDate', () => {
23
23
  expect(formatRelativeDate(daysAgo(3), { dueLike: true })).toBe('Overdue 3d');
24
24
  });
25
25
 
26
- it('renders neutral "Nd ago" for a past date that is not due-like', () => {
27
- expect(formatRelativeDate(daysAgo(3))).toBe('3d ago');
28
- expect(formatRelativeDate(daysAgo(3), { dueLike: false })).toBe('3d ago');
26
+ it('renders neutral "N days ago" for a past date that is not due-like', () => {
27
+ expect(formatRelativeDate(daysAgo(3))).toBe('3 days ago');
28
+ expect(formatRelativeDate(daysAgo(3), { dueLike: false })).toBe('3 days ago');
29
+ });
30
+
31
+ it('localizes the near-today window per locale (framework#3040)', () => {
32
+ expect(formatRelativeDate(daysAgo(0), { locale: 'zh-CN' })).toBe('今天');
33
+ expect(formatRelativeDate(daysAgo(-1), { locale: 'zh-CN' })).toBe('明天');
34
+ expect(formatRelativeDate(daysAgo(1), { locale: 'zh-CN' })).toBe('昨天');
35
+ expect(formatRelativeDate(daysAgo(-3), { locale: 'zh-CN' })).toBe('3天后');
36
+ expect(formatRelativeDate(daysAgo(3), { locale: 'zh-CN' })).toBe('3天前');
37
+ // English output is sentence-cased.
38
+ expect(formatRelativeDate(daysAgo(-1))).toBe('Tomorrow');
39
+ expect(formatRelativeDate(daysAgo(-3))).toBe('In 3 days');
40
+ });
41
+
42
+ it('resolves the overdue phrase through the i18n translate fn when provided', () => {
43
+ const t = (key: string, params?: Record<string, unknown>) =>
44
+ key === 'fields.relativeDate.overdue' ? `逾期 ${params?.count} 天` : key;
45
+ expect(formatRelativeDate(daysAgo(3), { dueLike: true, t })).toBe('逾期 3 天');
46
+ // A t() that misses (returns the key) keeps the English fallback.
47
+ expect(formatRelativeDate(daysAgo(3), { dueLike: true, t: (k) => k })).toBe('Overdue 3d');
48
+ });
49
+
50
+ it('degrades to English when the locale tag is invalid', () => {
51
+ expect(formatRelativeDate(daysAgo(3), { locale: 'not a locale' })).toBe('3 days ago');
29
52
  });
30
53
  });
31
54
 
32
55
  describe('DateCellRenderer', () => {
33
56
  it('does not label a past start_date as "Overdue" (regression: field-role-agnostic formatter)', () => {
34
57
  render(<DateCellRenderer value={daysAgo(6)} field={{ type: 'date', name: 'start_date' } as any} />);
35
- expect(screen.getByText('6d ago')).toBeInTheDocument();
58
+ expect(screen.getByText('6 days ago')).toBeInTheDocument();
36
59
  expect(screen.queryByText(/Overdue/)).not.toBeInTheDocument();
37
60
  });
38
61
 
@@ -0,0 +1,107 @@
1
+ /**
2
+ * [framework#2926 ⑧] LookupCellRenderer must honor the target object's
3
+ * configured display field. ObjectGrid forwards `display_field` on the
4
+ * column meta (RELATIONAL_META_KEYS) exactly like `reference`, but the read
5
+ * cell used to ignore it and always ran the hardcoded heuristics — `name`
6
+ * first — so a target object whose displayNameField is a localized/label
7
+ * field still rendered the raw API `name`.
8
+ */
9
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { render, screen, waitFor } from '@testing-library/react';
11
+ import '@testing-library/jest-dom';
12
+ import React from 'react';
13
+
14
+ import { LookupCellRenderer } from '../index';
15
+ import { SchemaRendererProvider } from '@object-ui/react';
16
+
17
+ const ID_A = '01ARZ3NDEKTSV4RRFFQ69G5FAA';
18
+ const ID_B = '01ARZ3NDEKTSV4RRFFQ69G5FAB';
19
+
20
+ function makeDataSource() {
21
+ const findOne = vi.fn(async (object: string, id: string) => {
22
+ if (object === 'showcase_category') {
23
+ // Record carries BOTH the raw API name and a localized label field.
24
+ if (id === ID_A) return { id, name: 'cat_hardware', label_zh: '硬件' };
25
+ if (id === ID_B) return { id, name: 'cat_software', label_zh: '软件' };
26
+ }
27
+ return null;
28
+ });
29
+ return { findOne, find: vi.fn() } as any;
30
+ }
31
+
32
+ describe('LookupCellRenderer — display_field resolution', () => {
33
+ it('prefers the configured display_field over the heuristic `name`', async () => {
34
+ const ds = makeDataSource();
35
+ render(
36
+ <SchemaRendererProvider dataSource={ds}>
37
+ <LookupCellRenderer
38
+ value={ID_A}
39
+ field={{ type: 'lookup', reference: 'showcase_category', display_field: 'label_zh' } as any}
40
+ />
41
+ </SchemaRendererProvider>,
42
+ );
43
+ await waitFor(() => {
44
+ expect(screen.getByText('硬件')).toBeInTheDocument();
45
+ });
46
+ expect(screen.queryByText('cat_hardware')).not.toBeInTheDocument();
47
+ });
48
+
49
+ it('keeps the heuristic (`name` first) when no display_field is configured', async () => {
50
+ const ds = makeDataSource();
51
+ render(
52
+ <SchemaRendererProvider dataSource={ds}>
53
+ <LookupCellRenderer
54
+ value={ID_A}
55
+ field={{ type: 'lookup', reference: 'showcase_category' } as any}
56
+ />
57
+ </SchemaRendererProvider>,
58
+ );
59
+ await waitFor(() => {
60
+ expect(screen.getByText('cat_hardware')).toBeInTheDocument();
61
+ });
62
+ });
63
+
64
+ it('uses display_field on server-expanded nested objects (no fetch path)', () => {
65
+ const ds = makeDataSource();
66
+ render(
67
+ <SchemaRendererProvider dataSource={ds}>
68
+ <LookupCellRenderer
69
+ value={{ id: ID_A, name: 'cat_hardware', label_zh: '硬件' } as any}
70
+ field={{ type: 'lookup', reference: 'showcase_category', display_field: 'label_zh' } as any}
71
+ />
72
+ </SchemaRendererProvider>,
73
+ );
74
+ expect(screen.getByText('硬件')).toBeInTheDocument();
75
+ expect(ds.findOne).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it('does not serve a cached heuristic name to a display_field column (cache key isolation)', async () => {
79
+ const ds = makeDataSource();
80
+ // First: a column WITHOUT display_field resolves and caches the heuristic name.
81
+ const first = render(
82
+ <SchemaRendererProvider dataSource={ds}>
83
+ <LookupCellRenderer
84
+ value={ID_B}
85
+ field={{ type: 'lookup', reference: 'showcase_category' } as any}
86
+ />
87
+ </SchemaRendererProvider>,
88
+ );
89
+ await waitFor(() => {
90
+ expect(screen.getByText('cat_software')).toBeInTheDocument();
91
+ });
92
+ first.unmount();
93
+ // Then: a column WITH display_field for the same record must show the
94
+ // configured field, not the previously cached heuristic name.
95
+ render(
96
+ <SchemaRendererProvider dataSource={ds}>
97
+ <LookupCellRenderer
98
+ value={ID_B}
99
+ field={{ type: 'lookup', reference: 'showcase_category', display_field: 'label_zh' } as any}
100
+ />
101
+ </SchemaRendererProvider>,
102
+ );
103
+ await waitFor(() => {
104
+ expect(screen.getByText('软件')).toBeInTheDocument();
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Regression for objectstack-ai/objectui#2357: the lookup chip
3
+ * (LookupCellRenderer — record header highlight + detail sections) resolved
4
+ * display names via the local key heuristic (`pickRecordDisplayName`), whose
5
+ * `_number`/`_code` suffix scan surfaced an autonumber (`0001`) over the
6
+ * record's real name whenever the referenced object declared its title in
7
+ * `nameField`/`titleFormat`. The picker (LookupField) already resolved via the
8
+ * unified ADR-0079 resolver, so the two surfaces disagreed.
9
+ *
10
+ * The chip must now resolve through the referenced object's schema
11
+ * (`dataSource.getObjectSchema`) with the precedence
12
+ * `displayField → nameField/titleFormat → type-aware derivation`, keeping the
13
+ * key heuristic only as the no-schema fallback.
14
+ */
15
+ import { describe, it, expect, vi } from 'vitest';
16
+ import { render, screen, waitFor } from '@testing-library/react';
17
+ import '@testing-library/jest-dom';
18
+ import React from 'react';
19
+
20
+ import { LookupCellRenderer } from '../index';
21
+ import { SchemaRendererProvider } from '@object-ui/react';
22
+
23
+ // The expanded record: the autonumber (`req_number`) precedes the real name
24
+ // (`req_name`) in key order, so the OLD heuristic-only path rendered "0001".
25
+ const EXPANDED = { id: 'rec1', req_number: '0001', req_name: 'Annual Maintenance' };
26
+
27
+ function makeDataSource(objectName: string, schema: any, record?: any) {
28
+ return {
29
+ find: vi.fn(),
30
+ findOne: vi.fn(async (object: string, id: string) =>
31
+ object === objectName && record && id === record.id ? record : null,
32
+ ),
33
+ getObjectSchema: vi.fn(async (object: string) =>
34
+ object === objectName ? schema : null,
35
+ ),
36
+ } as any;
37
+ }
38
+
39
+ describe('LookupCellRenderer — schema-aware display name (issue #2357)', () => {
40
+ it('expanded object: nameField beats the autonumber suffix heuristic', async () => {
41
+ const ds = makeDataSource('mtc_request_a', { nameField: 'req_name' });
42
+ render(
43
+ <SchemaRendererProvider dataSource={ds}>
44
+ <LookupCellRenderer
45
+ value={EXPANDED}
46
+ field={{ type: 'lookup', reference_to: 'mtc_request_a' } as any}
47
+ />
48
+ </SchemaRendererProvider>,
49
+ );
50
+ await waitFor(() => {
51
+ expect(screen.getByText('Annual Maintenance')).toBeInTheDocument();
52
+ });
53
+ expect(screen.queryByText('0001')).not.toBeInTheDocument();
54
+ });
55
+
56
+ it('expanded object: titleFormat template resolves when no nameField', async () => {
57
+ const ds = makeDataSource('mtc_request_b', { titleFormat: '{req_name} - {req_number}' });
58
+ render(
59
+ <SchemaRendererProvider dataSource={ds}>
60
+ <LookupCellRenderer
61
+ value={{ ...EXPANDED }}
62
+ field={{ type: 'lookup', reference_to: 'mtc_request_b' } as any}
63
+ />
64
+ </SchemaRendererProvider>,
65
+ );
66
+ await waitFor(() => {
67
+ expect(screen.getByText('Annual Maintenance - 0001')).toBeInTheDocument();
68
+ });
69
+ });
70
+
71
+ it('explicit displayField wins over the schema nameField', async () => {
72
+ const ds = makeDataSource('mtc_request_c', { nameField: 'req_name' });
73
+ render(
74
+ <SchemaRendererProvider dataSource={ds}>
75
+ <LookupCellRenderer
76
+ value={{ ...EXPANDED }}
77
+ field={{ type: 'lookup', reference_to: 'mtc_request_c', display_field: 'req_number' } as any}
78
+ />
79
+ </SchemaRendererProvider>,
80
+ );
81
+ // Author explicitly asked for the number — honour it (also the documented
82
+ // workaround in the issue).
83
+ expect(await screen.findByText('0001')).toBeInTheDocument();
84
+ });
85
+
86
+ it('fetch-on-demand primitive id resolves via the schema nameField too', async () => {
87
+ // Opaque ULID-ish id so the old path would have resolved the fetched
88
+ // record through the heuristic and shown its autonumber.
89
+ const id = '01ARZ3NDEKTSV4RRFFQ69G5FBB';
90
+ const record = { id, req_number: '0007', req_name: 'Pump Overhaul' };
91
+ const ds = makeDataSource('mtc_request_d', { nameField: 'req_name' }, record);
92
+ render(
93
+ <SchemaRendererProvider dataSource={ds}>
94
+ <LookupCellRenderer
95
+ value={id}
96
+ field={{ type: 'lookup', reference_to: 'mtc_request_d' } as any}
97
+ />
98
+ </SchemaRendererProvider>,
99
+ );
100
+ await waitFor(() => {
101
+ expect(screen.getByText('Pump Overhaul')).toBeInTheDocument();
102
+ });
103
+ expect(screen.queryByText('0007')).not.toBeInTheDocument();
104
+ expect(ds.getObjectSchema).toHaveBeenCalledWith('mtc_request_d');
105
+ });
106
+
107
+ it('falls back to the key heuristic when the data source has no schema API', () => {
108
+ const ds = { find: vi.fn(), findOne: vi.fn() } as any; // no getObjectSchema
109
+ render(
110
+ <SchemaRendererProvider dataSource={ds}>
111
+ <LookupCellRenderer
112
+ value={{ id: 'rec2', framework_name: 'SOX' }}
113
+ field={{ type: 'lookup', reference_to: 'mtc_request_e' } as any}
114
+ />
115
+ </SchemaRendererProvider>,
116
+ );
117
+ expect(screen.getByText('SOX')).toBeInTheDocument();
118
+ });
119
+ });