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
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId
rowId={row.original.id}
columnId="description"
onUpdate={updateCell}
expandable
expandTitle="Edit Control Description"
/>
),
}),
Expand Down
123 changes: 123 additions & 0 deletions apps/framework-editor/app/components/table/EditableCell.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { EditableCell } from './EditableCell';

// The ui package ships untranspiled JSX in dist; stub the bits the cell uses.
vi.mock('@trycompai/ui', () => ({
Button: ({
children,
variant: _v,
...props
}: { variant?: string } & React.ComponentProps<'button'>) => (
<button {...props}>{children}</button>
),
Textarea: (props: React.ComponentProps<'textarea'>) => <textarea {...props} />,
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? <div role="dialog">{children}</div> : null,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
DialogFooter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));

function setup(props: Partial<Parameters<typeof EditableCell>[0]> = {}) {
const onUpdate = vi.fn();
render(
<EditableCell
value="The organization shall assign account managers."
rowId="row-1"
columnId="description"
onUpdate={onUpdate}
{...props}
/>,
);
return { onUpdate };
}

describe('EditableCell — non-expandable (default)', () => {
beforeEach(() => vi.clearAllMocks());

it('renders no expand affordance and no dialog', () => {
setup();
expect(screen.queryByRole('button', { name: /large editor/i })).toBeNull();
expect(screen.queryByRole('dialog')).toBeNull();
});

it('click switches to single-line input and commits on Enter', () => {
const { onUpdate } = setup();
fireEvent.click(screen.getByText(/assign account managers/i));
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input.tagName).toBe('INPUT');
fireEvent.change(input, { target: { value: 'New value' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onUpdate).toHaveBeenCalledWith('row-1', 'description', 'New value');
});
});

describe('EditableCell — expandable', () => {
beforeEach(() => vi.clearAllMocks());

it('shows an expand affordance', () => {
setup({ expandable: true });
expect(screen.getByRole('button', { name: /large editor/i })).toBeTruthy();
});

it('right-click opens the multi-line editor with the current value', () => {
setup({ expandable: true, expandTitle: 'Edit Control Description' });
fireEvent.contextMenu(screen.getByText(/assign account managers/i));
expect(screen.getByRole('dialog')).toBeTruthy();
expect(screen.getByText('Edit Control Description')).toBeTruthy();
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
expect(textarea.tagName).toBe('TEXTAREA');
expect(textarea.value).toBe('The organization shall assign account managers.');
});

it('clicking the expand icon opens the editor without entering inline edit', () => {
setup({ expandable: true });
fireEvent.click(screen.getByRole('button', { name: /large editor/i }));
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
expect(textarea.tagName).toBe('TEXTAREA');
});

it('Save commits the edited multi-line value', () => {
const { onUpdate } = setup({ expandable: true });
fireEvent.contextMenu(screen.getByText(/assign account managers/i));
const textarea = screen.getByRole('textbox');
fireEvent.change(textarea, { target: { value: 'Line 1\nLine 2\nLine 3' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(onUpdate).toHaveBeenCalledWith('row-1', 'description', 'Line 1\nLine 2\nLine 3');
});

it('Save is disabled until the value changes', () => {
setup({ expandable: true });
fireEvent.contextMenu(screen.getByText(/assign account managers/i));
const save = screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement;
expect(save.disabled).toBe(true);
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'changed' } });
expect(save.disabled).toBe(false);
});

it('Cancel closes without committing', () => {
const { onUpdate } = setup({ expandable: true });
fireEvent.contextMenu(screen.getByText(/assign account managers/i));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'discarded' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onUpdate).not.toHaveBeenCalled();
expect(screen.queryByRole('dialog')).toBeNull();
});

it('still supports the quick single-line edit on normal click', () => {
const { onUpdate } = setup({ expandable: true });
fireEvent.click(screen.getByText(/assign account managers/i));
const input = screen.getByRole('textbox') as HTMLInputElement;
expect(input.tagName).toBe('INPUT');
fireEvent.change(input, { target: { value: 'quick edit' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(onUpdate).toHaveBeenCalledWith('row-1', 'description', 'quick edit');
});

it('does nothing expandable when disabled', () => {
setup({ expandable: true, disabled: true });
expect(screen.queryByRole('button', { name: /large editor/i })).toBeNull();
});
});
101 changes: 94 additions & 7 deletions apps/framework-editor/app/components/table/EditableCell.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
'use client';

import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Textarea,
} from '@trycompai/ui';
import { Maximize2 } from 'lucide-react';
import { useState } from 'react';

interface EditableCellProps {
Expand All @@ -9,6 +19,11 @@ interface EditableCellProps {
onUpdate: (rowId: string, columnId: string, value: string) => void;
disabled?: boolean;
placeholder?: string;
// When set, the cell keeps the quick single-line edit on click but also
// offers a large multi-line editor (hover icon or right-click) for long
// values like control descriptions.
expandable?: boolean;
expandTitle?: string;
}

export function EditableCell({
Expand All @@ -18,9 +33,13 @@ export function EditableCell({
onUpdate,
disabled = false,
placeholder = 'Click to edit',
expandable = false,
expandTitle = 'Edit',
}: EditableCellProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value ?? '');
const [isExpanded, setIsExpanded] = useState(false);
const [expandValue, setExpandValue] = useState(value ?? '');

const handleBlur = () => {
setIsEditing(false);
Expand All @@ -39,6 +58,24 @@ export function EditableCell({
}
};

const handleStartEditing = () => {
setEditValue(value ?? '');
setIsEditing(true);
};

const handleOpenExpanded = () => {
if (disabled) return;
setExpandValue(value ?? '');
setIsExpanded(true);
};

const handleExpandSave = () => {
if (expandValue !== (value ?? '')) {
onUpdate(rowId, columnId, expandValue);
}
setIsExpanded(false);
};

if (disabled) {
return (
<span className="text-muted-foreground block truncate px-2 py-1.5 text-sm">
Expand All @@ -61,15 +98,65 @@ export function EditableCell({
);
}

if (!expandable) {
return (
<span
className="hover:bg-muted/50 block cursor-text truncate px-2 py-1.5 text-sm"
onClick={handleStartEditing}
>
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
</span>
);
}

return (
<span
className="hover:bg-muted/50 block cursor-text truncate px-2 py-1.5 text-sm"
onClick={() => {
setEditValue(value ?? '');
setIsEditing(true);
<div
className="group relative"
onContextMenu={(e) => {
e.preventDefault();
handleOpenExpanded();
}}
>
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
</span>
<span
className="hover:bg-muted/50 block cursor-text truncate px-2 py-1.5 pr-7 text-sm"
onClick={handleStartEditing}
>
{value || <span className="text-muted-foreground italic">{placeholder}</span>}
</span>
<button
type="button"
aria-label="Open large editor"
title="Open large editor (or right-click)"
className="text-muted-foreground hover:text-foreground absolute right-1 top-1.5 rounded-xs p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
handleOpenExpanded();
}}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>

<Dialog open={isExpanded} onOpenChange={setIsExpanded}>
<DialogContent className="sm:max-w-[760px]">
<DialogHeader>
<DialogTitle>{expandTitle}</DialogTitle>
</DialogHeader>
<Textarea
value={expandValue}
onChange={(e) => setExpandValue(e.target.value)}
autoFocus
className="min-h-[260px] font-mono text-sm"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setIsExpanded(false)}>
Cancel
</Button>
<Button onClick={handleExpandSave} disabled={expandValue === (value ?? '')}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
Loading