diff --git a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx
index 9de5e4cee5..3e5af15073 100644
--- a/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx
+++ b/apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx
@@ -253,6 +253,8 @@ export function ControlsClientPage({ initialControls, emptyMessage, frameworkId
rowId={row.original.id}
columnId="description"
onUpdate={updateCell}
+ expandable
+ expandTitle="Edit Control Description"
/>
),
}),
diff --git a/apps/framework-editor/app/components/table/EditableCell.test.tsx b/apps/framework-editor/app/components/table/EditableCell.test.tsx
new file mode 100644
index 0000000000..518d0a1861
--- /dev/null
+++ b/apps/framework-editor/app/components/table/EditableCell.test.tsx
@@ -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'>) => (
+
+ ),
+ Textarea: (props: React.ComponentProps<'textarea'>) => ,
+ Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
+ open ?
{children}
: null,
+ DialogContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogHeader: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogTitle: ({ children }: { children: React.ReactNode }) => {children}
,
+ DialogFooter: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+function setup(props: Partial[0]> = {}) {
+ const onUpdate = vi.fn();
+ render(
+ ,
+ );
+ 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();
+ });
+});
diff --git a/apps/framework-editor/app/components/table/EditableCell.tsx b/apps/framework-editor/app/components/table/EditableCell.tsx
index 92d8e5937a..54e1298732 100644
--- a/apps/framework-editor/app/components/table/EditableCell.tsx
+++ b/apps/framework-editor/app/components/table/EditableCell.tsx
@@ -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 {
@@ -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({
@@ -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);
@@ -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 (
@@ -61,15 +98,65 @@ export function EditableCell({
);
}
+ if (!expandable) {
+ return (
+
+ {value || {placeholder}}
+
+ );
+ }
+
return (
- {
- setEditValue(value ?? '');
- setIsEditing(true);
+ {
+ e.preventDefault();
+ handleOpenExpanded();
}}
>
- {value || {placeholder}}
-
+
+ {value || {placeholder}}
+
+
+
+
+
);
}