Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,34 @@ The `FlowDesigner` is a canvas-based flow editor that bridges the gap between th

**Tests:** Added 3 new tests: 1 in `DashboardRenderer.widgetData.test.tsx` verifying metric widgets with I18nLabel trend labels render correctly, and 2 in `MetricCard.test.tsx` verifying I18nLabel resolution for title and description. All 159 dashboard tests pass.

### Field Type Display Issues — Lookup, User, Select, Status Renderers (February 2026)

**Root Cause:** Multiple renderer defects caused incorrect field value display across views:

1. **`LookupCellRenderer`** — Destructured only `value`, ignoring the `field` prop. When the API returned a raw primitive ID (e.g. `customer: 2`), the renderer fell through to `String(value)` and showed `"2"` instead of the related record's name. No attempt was made to resolve IDs via `field.options`.

2. **`UserCellRenderer`** — Did not guard against primitive values (number/string user IDs). Accessing `.name` / `.username` on a number returned `undefined`, silently falling through to `"User"` as the generic label.

3. **`getCellRenderer` standardMap** — `lookup` and `master_detail` were mapped to `SelectCellRenderer` instead of `LookupCellRenderer` in the fallback map. Although the fieldRegistry pre-registration shadowed this bug, it was semantically incorrect.

4. **`status`, `user`, `owner` types** — Not pre-registered in `fieldRegistry`. All went through the `standardMap` path, making their association with renderers implicit and invisible.

**Fix:**
- `LookupCellRenderer`: now accepts the `field` prop and resolves primitive IDs against `field.options` (matching by `String(opt.value) === String(val)` for type-safe comparison). Arrays of primitive IDs are resolved via the same logic. Null/empty-string guard updated from `!value` to `value == null || value === ''` to handle `0` correctly.
- `UserCellRenderer`: primitive values (typeof !== 'object') return a plain `<span>` with the string representation. Array items that are not objects are also handled gracefully.
- `getCellRenderer` standardMap: `lookup` and `master_detail` now correctly reference `LookupCellRenderer`.
- `fieldRegistry` now explicitly registers `status` → `SelectCellRenderer`, `user` → `UserCellRenderer`, and `owner` → `UserCellRenderer` alongside the existing `lookup`/`master_detail`/`select` registrations.

**Tests:** Added 36 new tests in `cell-renderers.test.tsx`:
- `getCellRenderer` registry assertions for `lookup`, `master_detail`, `status`, `user`, `owner` types
- `TextCellRenderer`: null, undefined, empty string, numeric zero (0 renders "0" not "-"), boolean false
- `LookupCellRenderer`: null, empty-string, primitive ID (number), primitive ID (string), unresolved primitive, object with name/label/_id, array of objects, array of primitive IDs resolved via options
- `UserCellRenderer`: null, primitive number ID, primitive string ID, object with name, object with username, array of user objects

5. **`TextCellRenderer`** — Used `value || '-'` which incorrectly rendered `'-'` for numeric `0` (falsy zero). Updated to `(value != null && value !== '') ? String(value) : '-'` for consistent null-only suppression.

All 313 `@object-ui/fields` tests pass.

---

## ⚠️ Risk Management
Expand Down
259 changes: 258 additions & 1 deletion packages/fields/src/__tests__/cell-renderers.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import React from 'react';
import {
getCellRenderer,
SelectCellRenderer,
LookupCellRenderer,
UserCellRenderer,
TextCellRenderer,
DateCellRenderer,
BooleanCellRenderer,
formatDate,
Expand Down Expand Up @@ -61,10 +64,70 @@ describe('getCellRenderer', () => {
const renderer = getCellRenderer('unknown-type');
expect(renderer).toBeDefined();
});

it('should return LookupCellRenderer for lookup type', () => {
const renderer = getCellRenderer('lookup');
expect(renderer).toBe(LookupCellRenderer);
});

it('should return LookupCellRenderer for master_detail type', () => {
const renderer = getCellRenderer('master_detail');
expect(renderer).toBe(LookupCellRenderer);
});

it('should return SelectCellRenderer for status type', () => {
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate test case detected. There's already a test case with the same name "should return SelectCellRenderer for status type" at lines 40-51 (outside this diff but in the same file). The earlier test performs a more comprehensive check by rendering the component and verifying the output, while this one only checks the renderer identity. Consider either removing this duplicate or renaming it to clarify the specific aspect being tested (e.g., "should return correct renderer identity for status type").

Suggested change
it('should return SelectCellRenderer for status type', () => {
it('should return correct renderer identity for status type', () => {

Copilot uses AI. Check for mistakes.
const renderer = getCellRenderer('status');
expect(renderer).toBe(SelectCellRenderer);
});

it('should return UserCellRenderer for user type', () => {
const renderer = getCellRenderer('user');
expect(renderer).toBe(UserCellRenderer);
});

it('should return UserCellRenderer for owner type', () => {
const renderer = getCellRenderer('owner');
expect(renderer).toBe(UserCellRenderer);
});
});

// =========================================================================
// 2. TextCellRenderer
// =========================================================================
describe('TextCellRenderer', () => {
it('should render text value', () => {
render(<TextCellRenderer value="hello" field={{ name: 'title', type: 'text' } as any} />);
expect(screen.getByText('hello')).toBeInTheDocument();
});

it('should render dash for null', () => {
render(<TextCellRenderer value={null} field={{ name: 'title', type: 'text' } as any} />);
expect(screen.getByText('-')).toBeInTheDocument();
});

it('should render dash for undefined', () => {
render(<TextCellRenderer value={undefined} field={{ name: 'title', type: 'text' } as any} />);
expect(screen.getByText('-')).toBeInTheDocument();
});

it('should render dash for empty string', () => {
render(<TextCellRenderer value="" field={{ name: 'title', type: 'text' } as any} />);
expect(screen.getByText('-')).toBeInTheDocument();
});

it('should render "0" for numeric zero (not dash)', () => {
render(<TextCellRenderer value={0} field={{ name: 'count', type: 'text' } as any} />);
expect(screen.getByText('0')).toBeInTheDocument();
});

it('should render "false" for boolean false (not dash)', () => {
render(<TextCellRenderer value={false} field={{ name: 'flag', type: 'text' } as any} />);
expect(screen.getByText('false')).toBeInTheDocument();
});
});

// =========================================================================
// 2. SelectCellRenderer
// 3. SelectCellRenderer
// =========================================================================
describe('SelectCellRenderer', () => {
it('should render badge with explicit color from options', () => {
Expand Down Expand Up @@ -477,3 +540,197 @@ describe('formatDate', () => {
expect(result).toContain('2026');
});
});

// =========================================================================
// 7. LookupCellRenderer
// =========================================================================
describe('LookupCellRenderer', () => {
it('should render dash for null value', () => {
render(
<LookupCellRenderer
value={null}
field={{ name: 'customer', type: 'lookup' } as any}
/>
);
expect(screen.getByText('-')).toBeInTheDocument();
});

it('should render dash for empty string', () => {
render(
<LookupCellRenderer
value=""
field={{ name: 'customer', type: 'lookup' } as any}
/>
);
expect(screen.getByText('-')).toBeInTheDocument();
});

it('should resolve primitive ID to label via field options', () => {
render(
<LookupCellRenderer
value={2}
field={{
name: 'customer',
type: 'lookup',
options: [
{ value: 1, label: 'Alice' },
{ value: 2, label: 'Bob' },
],
} as any}
/>
);
expect(screen.getByText('Bob')).toBeInTheDocument();
});

it('should resolve string ID to label via field options', () => {
render(
<LookupCellRenderer
value="contact_1"
field={{
name: 'customer',
type: 'lookup',
options: [{ value: 'contact_1', label: 'Alice Smith' }],
} as any}
/>
);
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
});

it('should render raw primitive when no options available', () => {
render(
<LookupCellRenderer
value={42}
field={{ name: 'customer', type: 'lookup' } as any}
/>
);
expect(screen.getByText('42')).toBeInTheDocument();
});

it('should render object name when value is an object', () => {
render(
<LookupCellRenderer
value={{ name: 'Acme Corp', _id: '123' }}
field={{ name: 'account', type: 'lookup' } as any}
/>
);
expect(screen.getByText('Acme Corp')).toBeInTheDocument();
});

it('should render object label when name is missing', () => {
render(
<LookupCellRenderer
value={{ label: 'Widget Co', _id: '456' }}
field={{ name: 'account', type: 'lookup' } as any}
/>
);
expect(screen.getByText('Widget Co')).toBeInTheDocument();
});

it('should render object _id as fallback', () => {
render(
<LookupCellRenderer
value={{ _id: '789' }}
field={{ name: 'account', type: 'lookup' } as any}
/>
);
expect(screen.getByText('789')).toBeInTheDocument();
});

it('should render tags for array of objects', () => {
render(
<LookupCellRenderer
value={[{ name: 'Alice' }, { name: 'Bob' }]}
field={{ name: 'contacts', type: 'lookup' } as any}
/>
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
});

it('should resolve array of primitive IDs via options', () => {
render(
<LookupCellRenderer
value={[1, 3]}
field={{
name: 'contacts',
type: 'lookup',
options: [
{ value: 1, label: 'Alice' },
{ value: 2, label: 'Bob' },
{ value: 3, label: 'Charlie' },
],
} as any}
/>
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
});
});

// =========================================================================
// 8. UserCellRenderer
// =========================================================================
describe('UserCellRenderer', () => {
it('should render dash for null value', () => {
render(
<UserCellRenderer
value={null}
field={{ name: 'owner', type: 'user' } as any}
/>
);
expect(screen.getByText('-')).toBeInTheDocument();
});

it('should render text for primitive user ID (number)', () => {
render(
<UserCellRenderer
value={5}
field={{ name: 'owner', type: 'user' } as any}
/>
);
expect(screen.getByText('5')).toBeInTheDocument();
});

it('should render text for primitive user ID (string)', () => {
render(
<UserCellRenderer
value="user_abc"
field={{ name: 'owner', type: 'user' } as any}
/>
);
expect(screen.getByText('user_abc')).toBeInTheDocument();
});

it('should render avatar and name for object value', () => {
render(
<UserCellRenderer
value={{ name: 'John Doe', username: 'jdoe' }}
field={{ name: 'owner', type: 'user' } as any}
/>
);
expect(screen.getByText('John Doe')).toBeInTheDocument();
});

it('should use username when name is missing', () => {
render(
<UserCellRenderer
value={{ username: 'jdoe' }}
field={{ name: 'owner', type: 'user' } as any}
/>
);
expect(screen.getByText('jdoe')).toBeInTheDocument();
});

it('should render multiple avatars for array of user objects', () => {
render(
<UserCellRenderer
value={[{ name: 'Alice' }, { name: 'Bob' }]}
field={{ name: 'assignees', type: 'user' } as any}
/>
);
// Avatar fallbacks contain initials
expect(screen.getByTitle('Alice')).toBeInTheDocument();
expect(screen.getByTitle('Bob')).toBeInTheDocument();
});
});
Loading