Skip to content

Commit 635923c

Browse files
Copilothotlong
andcommitted
feat: enhance field renderers — email copy, phone call icon + copy, boolean warning badge
- EmailCellRenderer: add hover copy-to-clipboard button alongside mailto link - PhoneCellRenderer: add phone icon and hover copy-to-clipboard button - BooleanCellRenderer: render destructive warning Badge for active/enabled/verified=false - Update existing tests for new boolean behavior, add 12 new field renderer tests - Update ROADMAP.md with field renderer enhancements Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent a3a3cc3 commit 635923c

4 files changed

Lines changed: 194 additions & 24 deletions

File tree

ROADMAP.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1297,7 +1297,12 @@ All 313 `@object-ui/fields` tests pass.
12971297
- [x] Remove `columns: 2` hardcode — let `autoLayout` infer optimal columns
12981298
- [x] Auto-detect `primaryField` from object fields (name/title)
12991299

1300-
**Tests:** 94 tests passing (11 new) covering hideEmpty filtering, empty section hiding, primaryField/summaryFields rendering, responsive breakpoints, collapseWhenEmpty, autoLayout undefined-columns regression.
1300+
**Field Renderers (`@object-ui/fields`):**
1301+
- [x] `EmailCellRenderer`: mailto link + hover copy-to-clipboard button
1302+
- [x] `PhoneCellRenderer`: tel link with call icon + hover copy-to-clipboard button
1303+
- [x] `BooleanCellRenderer`: warning Badge for active/enabled/verified fields when false (e.g. "Active — Off")
1304+
1305+
**Tests:** 94 plugin-detail tests passing (11 new), 100 field renderer tests passing (12 new) covering hideEmpty filtering, empty section hiding, primaryField/summaryFields rendering, responsive breakpoints, collapseWhenEmpty, autoLayout undefined-columns regression, email copy, phone copy+icon, boolean warning badge.
13011306

13021307
**Storybook:** Added `PrimaryFieldWithBadges` and `HideEmptyFields` stories.
13031308

packages/fields/src/__tests__/boolean-checkbox.test.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ describe('BooleanCellRenderer', () => {
2323
expect(checkbox).toHaveAttribute('data-state', 'checked');
2424
});
2525

26-
it('should render an unchecked checkbox for false values', () => {
26+
it('should render an unchecked checkbox for false values (non-status field)', () => {
2727
render(
2828
<BooleanCellRenderer
2929
value={false}
30-
field={{ name: 'active', type: 'boolean' } as any}
30+
field={{ name: 'flagged', type: 'boolean' } as any}
3131
/>
3232
);
3333

@@ -36,6 +36,19 @@ describe('BooleanCellRenderer', () => {
3636
expect(checkbox).toHaveAttribute('data-state', 'unchecked');
3737
});
3838

39+
it('should render warning badge for active=false', () => {
40+
const { container } = render(
41+
<BooleanCellRenderer
42+
value={false}
43+
field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
44+
/>
45+
);
46+
47+
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
48+
expect(badge).toBeInTheDocument();
49+
expect(badge?.textContent).toContain('Off');
50+
});
51+
3952
it('should render dash for null/undefined values', () => {
4053
render(
4154
<BooleanCellRenderer

packages/fields/src/__tests__/cell-renderers.test.tsx

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
TextCellRenderer,
1717
DateCellRenderer,
1818
BooleanCellRenderer,
19+
EmailCellRenderer,
20+
PhoneCellRenderer,
1921
PercentCellRenderer,
2022
humanizeLabel,
2123
formatDate,
@@ -365,7 +367,7 @@ describe('BooleanCellRenderer', () => {
365367
render(
366368
<BooleanCellRenderer
367369
value={false}
368-
field={{ name: 'active', type: 'boolean' } as any}
370+
field={{ name: 'flagged', type: 'boolean' } as any}
369371
/>
370372
);
371373
const checkbox = screen.getByRole('checkbox');
@@ -440,6 +442,91 @@ describe('BooleanCellRenderer', () => {
440442
});
441443
});
442444

445+
// =========================================================================
446+
// 4b. BooleanCellRenderer — Warning badge for status fields
447+
// =========================================================================
448+
describe('BooleanCellRenderer warning badge', () => {
449+
it('should render warning badge for active=false', () => {
450+
const { container } = render(
451+
<BooleanCellRenderer
452+
value={false}
453+
field={{ name: 'active', type: 'boolean', label: 'Active' } as any}
454+
/>
455+
);
456+
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
457+
expect(badge).toBeInTheDocument();
458+
expect(badge?.textContent).toContain('Off');
459+
});
460+
461+
it('should render warning badge for is_enabled=false', () => {
462+
const { container } = render(
463+
<BooleanCellRenderer
464+
value={false}
465+
field={{ name: 'is_enabled', type: 'boolean', label: 'Enabled' } as any}
466+
/>
467+
);
468+
const badge = container.querySelector('[data-testid="boolean-warning-badge"]');
469+
expect(badge).toBeInTheDocument();
470+
});
471+
472+
it('should render normal checkbox for active=true', () => {
473+
render(
474+
<BooleanCellRenderer
475+
value={true}
476+
field={{ name: 'active', type: 'boolean' } as any}
477+
/>
478+
);
479+
const checkbox = screen.getByRole('checkbox');
480+
expect(checkbox).toHaveAttribute('data-state', 'checked');
481+
});
482+
});
483+
484+
// =========================================================================
485+
// 4c. EmailCellRenderer — mailto + copy button
486+
// =========================================================================
487+
describe('EmailCellRenderer', () => {
488+
it('should render mailto link', () => {
489+
render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
490+
const link = screen.getByRole('link');
491+
expect(link).toHaveAttribute('href', 'mailto:test@example.com');
492+
expect(screen.getByText('test@example.com')).toBeInTheDocument();
493+
});
494+
495+
it('should render copy button', () => {
496+
render(<EmailCellRenderer value="test@example.com" field={{ name: 'email', type: 'email' } as any} />);
497+
const copyBtn = screen.getByLabelText('Copy email');
498+
expect(copyBtn).toBeInTheDocument();
499+
});
500+
501+
it('should render dash for empty value', () => {
502+
render(<EmailCellRenderer value={null} field={{ name: 'email', type: 'email' } as any} />);
503+
expect(screen.getByText('-')).toBeInTheDocument();
504+
});
505+
});
506+
507+
// =========================================================================
508+
// 4d. PhoneCellRenderer — tel link + call icon + copy
509+
// =========================================================================
510+
describe('PhoneCellRenderer', () => {
511+
it('should render tel link with phone icon', () => {
512+
render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
513+
const link = screen.getByRole('link');
514+
expect(link).toHaveAttribute('href', 'tel:+1-555-1234');
515+
expect(screen.getByText('+1-555-1234')).toBeInTheDocument();
516+
});
517+
518+
it('should render copy button', () => {
519+
render(<PhoneCellRenderer value="+1-555-1234" field={{ name: 'phone', type: 'phone' } as any} />);
520+
const copyBtn = screen.getByLabelText('Copy phone number');
521+
expect(copyBtn).toBeInTheDocument();
522+
});
523+
524+
it('should render dash for empty value', () => {
525+
render(<PhoneCellRenderer value={null} field={{ name: 'phone', type: 'phone' } as any} />);
526+
expect(screen.getByText('-')).toBeInTheDocument();
527+
});
528+
});
529+
443530
// =========================================================================
444531
// 5. formatRelativeDate edge cases
445532
// =========================================================================

packages/fields/src/index.tsx

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import React from 'react';
1010
import type { FieldMetadata, SelectOptionMetadata } from '@object-ui/types';
1111
import { ComponentRegistry } from '@object-ui/core';
1212
import { Badge, Avatar, AvatarFallback, Button, Checkbox } from '@object-ui/components';
13-
import { Check, X } from 'lucide-react';
13+
import { Check, X, Copy, Phone as PhoneIcon } from 'lucide-react';
1414

1515
import { TextField } from './widgets/TextField';
1616
import { NumberField } from './widgets/NumberField';
@@ -265,7 +265,8 @@ export function PercentCellRenderer({ value, field }: CellRendererProps): React.
265265

266266
/**
267267
* Boolean field cell renderer (Airtable-style checkbox)
268-
* Supports semantic rendering for completion fields (green indicator).
268+
* Supports semantic rendering for completion fields (green indicator)
269+
* and warning badge for active/enabled fields when false.
269270
*/
270271
export function BooleanCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
271272
if (value == null) {
@@ -292,6 +293,19 @@ export function BooleanCellRenderer({ value, field }: CellRendererProps): React.
292293
);
293294
}
294295

296+
// Warning badge for active/enabled fields when false
297+
const isStatusField = fieldName === 'active' || fieldName === 'is_active'
298+
|| fieldName === 'enabled' || fieldName === 'is_enabled'
299+
|| fieldName === 'verified' || fieldName === 'is_verified';
300+
301+
if (isStatusField && !value) {
302+
return (
303+
<Badge variant="destructive" className="text-xs" data-testid="boolean-warning-badge">
304+
{field?.label || humanizeLabel(field?.name || 'Inactive')} — Off
305+
</Badge>
306+
);
307+
}
308+
295309
return (
296310
<div className="flex items-center justify-center">
297311
<Checkbox checked={!!value} disabled className="pointer-events-none" />
@@ -436,19 +450,44 @@ export function SelectCellRenderer({ value, field }: CellRendererProps): React.R
436450
export function EmailCellRenderer({ value }: CellRendererProps): React.ReactElement {
437451
if (!value) return <span>-</span>;
438452

453+
const [copied, setCopied] = React.useState(false);
454+
455+
const handleCopy = (e: React.MouseEvent) => {
456+
e.stopPropagation();
457+
e.preventDefault();
458+
navigator.clipboard.writeText(String(value)).then(() => {
459+
setCopied(true);
460+
setTimeout(() => setCopied(false), 2000);
461+
});
462+
};
463+
439464
return (
440-
<Button
441-
variant="link"
442-
className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
443-
asChild
444-
>
445-
<a
446-
href={`mailto:${value}`}
447-
onClick={(e) => e.stopPropagation()}
465+
<span className="inline-flex items-center gap-1 group/email">
466+
<Button
467+
variant="link"
468+
className="p-0 h-auto font-normal text-blue-600 hover:text-blue-800"
469+
asChild
448470
>
449-
{value}
450-
</a>
451-
</Button>
471+
<a
472+
href={`mailto:${value}`}
473+
onClick={(e) => e.stopPropagation()}
474+
>
475+
{value}
476+
</a>
477+
</Button>
478+
<button
479+
type="button"
480+
className="opacity-0 group-hover/email:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
481+
onClick={handleCopy}
482+
aria-label="Copy email"
483+
>
484+
{copied ? (
485+
<Check className="h-3 w-3 text-green-600" />
486+
) : (
487+
<Copy className="h-3 w-3 text-muted-foreground" />
488+
)}
489+
</button>
490+
</span>
452491
);
453492
}
454493

@@ -482,14 +521,40 @@ export function UrlCellRenderer({ value }: CellRendererProps): React.ReactElemen
482521
export function PhoneCellRenderer({ value }: CellRendererProps): React.ReactElement {
483522
if (!value) return <span>-</span>;
484523

524+
const [copied, setCopied] = React.useState(false);
525+
526+
const handleCopy = (e: React.MouseEvent) => {
527+
e.stopPropagation();
528+
e.preventDefault();
529+
navigator.clipboard.writeText(String(value)).then(() => {
530+
setCopied(true);
531+
setTimeout(() => setCopied(false), 2000);
532+
});
533+
};
534+
485535
return (
486-
<a
487-
href={`tel:${value}`}
488-
className="text-blue-600 hover:text-blue-800"
489-
onClick={(e) => e.stopPropagation()}
490-
>
491-
{value}
492-
</a>
536+
<span className="inline-flex items-center gap-1 group/phone">
537+
<a
538+
href={`tel:${value}`}
539+
className="inline-flex items-center gap-1 text-blue-600 hover:text-blue-800"
540+
onClick={(e) => e.stopPropagation()}
541+
>
542+
<PhoneIcon className="h-3 w-3" />
543+
{value}
544+
</a>
545+
<button
546+
type="button"
547+
className="opacity-0 group-hover/phone:opacity-100 transition-opacity p-0.5 rounded hover:bg-muted"
548+
onClick={handleCopy}
549+
aria-label="Copy phone number"
550+
>
551+
{copied ? (
552+
<Check className="h-3 w-3 text-green-600" />
553+
) : (
554+
<Copy className="h-3 w-3 text-muted-foreground" />
555+
)}
556+
</button>
557+
</span>
493558
);
494559
}
495560

0 commit comments

Comments
 (0)