Skip to content

Commit 8ed7944

Browse files
authored
Merge pull request #222 from objectstack-ai/copilot/add-objectui-integration
2 parents 8809a5e + c35692b commit 8ed7944

26 files changed

Lines changed: 3002 additions & 11 deletions
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { ActivityTimeline } from '@/components/workflow/ActivityTimeline';
4+
import type { ActivityEntry } from '@/types/workflow';
5+
6+
const sampleActivities: ActivityEntry[] = [
7+
{
8+
id: 'act-1',
9+
type: 'record_created',
10+
timestamp: '2025-01-01T10:00:00Z',
11+
user: 'Alice',
12+
summary: 'Created record',
13+
},
14+
{
15+
id: 'act-2',
16+
type: 'field_changed',
17+
timestamp: '2025-01-02T14:00:00Z',
18+
user: 'Bob',
19+
summary: 'Updated status',
20+
changes: [{ field: 'status', fieldLabel: 'Status', oldValue: 'draft', newValue: 'sent' }],
21+
},
22+
{
23+
id: 'act-3',
24+
type: 'comment',
25+
timestamp: '2025-01-03T09:00:00Z',
26+
user: 'Carol',
27+
summary: 'Added a comment',
28+
comment: 'This looks good!',
29+
},
30+
];
31+
32+
describe('ActivityTimeline', () => {
33+
it('renders empty state when no activities', () => {
34+
render(<ActivityTimeline activities={[]} />);
35+
expect(screen.getByText('No activity yet.')).toBeDefined();
36+
});
37+
38+
it('renders activity entries', () => {
39+
render(<ActivityTimeline activities={sampleActivities} />);
40+
expect(screen.getByText('Alice')).toBeDefined();
41+
expect(screen.getByText('Bob')).toBeDefined();
42+
expect(screen.getByText('Carol')).toBeDefined();
43+
});
44+
45+
it('renders field changes with old/new values', () => {
46+
render(<ActivityTimeline activities={sampleActivities} />);
47+
expect(screen.getByText('Status:')).toBeDefined();
48+
expect(screen.getByText('draft')).toBeDefined();
49+
expect(screen.getByText('sent')).toBeDefined();
50+
});
51+
52+
it('renders comment body', () => {
53+
render(<ActivityTimeline activities={sampleActivities} />);
54+
expect(screen.getByText('This looks good!')).toBeDefined();
55+
});
56+
57+
it('respects maxItems prop', () => {
58+
render(<ActivityTimeline activities={sampleActivities} maxItems={1} />);
59+
expect(screen.getByText('Alice')).toBeDefined();
60+
// Bob and Carol should not be visible
61+
expect(screen.queryByText('Bob')).toBeNull();
62+
expect(screen.getByText('+2 more activities')).toBeDefined();
63+
});
64+
65+
it('has data-testid attribute', () => {
66+
render(<ActivityTimeline activities={sampleActivities} />);
67+
expect(screen.getByTestId('activity-timeline')).toBeDefined();
68+
});
69+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { ApprovalActions } from '@/components/workflow/ApprovalActions';
4+
import type { WorkflowStatus, WorkflowTransition } from '@/types/workflow';
5+
6+
function makeStatus(transitions: WorkflowTransition[]): WorkflowStatus {
7+
return {
8+
workflowName: 'test_flow',
9+
currentState: 'pending',
10+
currentStateLabel: 'Pending',
11+
color: 'yellow',
12+
availableTransitions: transitions,
13+
};
14+
}
15+
16+
describe('ApprovalActions', () => {
17+
it('renders nothing when no transitions available', () => {
18+
const { container } = render(
19+
<ApprovalActions status={makeStatus([])} onTransition={() => {}} />,
20+
);
21+
expect(container.innerHTML).toBe('');
22+
});
23+
24+
it('renders approve button', () => {
25+
const transitions: WorkflowTransition[] = [
26+
{ name: 'approve', label: 'Approve', from: 'pending', to: 'approved' },
27+
];
28+
render(<ApprovalActions status={makeStatus(transitions)} onTransition={() => {}} />);
29+
expect(screen.getByText('Approve')).toBeDefined();
30+
});
31+
32+
it('renders reject button with destructive variant', () => {
33+
const transitions: WorkflowTransition[] = [
34+
{ name: 'reject', label: 'Reject', from: 'pending', to: 'rejected' },
35+
];
36+
render(<ApprovalActions status={makeStatus(transitions)} onTransition={() => {}} />);
37+
expect(screen.getByText('Reject')).toBeDefined();
38+
});
39+
40+
it('calls onTransition when button clicked', () => {
41+
const onTransition = vi.fn();
42+
const transitions: WorkflowTransition[] = [
43+
{ name: 'approve', label: 'Approve', from: 'pending', to: 'approved' },
44+
];
45+
render(<ApprovalActions status={makeStatus(transitions)} onTransition={onTransition} />);
46+
fireEvent.click(screen.getByText('Approve'));
47+
expect(onTransition).toHaveBeenCalledWith(transitions[0]);
48+
});
49+
50+
it('disables buttons when isExecuting is true', () => {
51+
const transitions: WorkflowTransition[] = [
52+
{ name: 'approve', label: 'Approve', from: 'pending', to: 'approved' },
53+
];
54+
render(
55+
<ApprovalActions
56+
status={makeStatus(transitions)}
57+
onTransition={() => {}}
58+
isExecuting
59+
/>,
60+
);
61+
const button = screen.getByText('Approve').closest('button');
62+
expect(button?.disabled).toBe(true);
63+
});
64+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { ChartWidget } from '@/components/objectui/ChartWidget';
4+
import type { ChartConfig } from '@/types/workflow';
5+
6+
describe('ChartWidget', () => {
7+
it('renders number chart', () => {
8+
const config: ChartConfig = {
9+
type: 'number',
10+
title: 'Total Revenue',
11+
data: [{ label: 'Revenue', value: 150000 }],
12+
};
13+
render(<ChartWidget config={config} />);
14+
expect(screen.getByText('Total Revenue')).toBeDefined();
15+
expect(screen.getByText('150,000')).toBeDefined();
16+
});
17+
18+
it('renders bar chart with labels', () => {
19+
const config: ChartConfig = {
20+
type: 'bar',
21+
title: 'Leads by Status',
22+
data: [
23+
{ label: 'New', value: 10 },
24+
{ label: 'Qualified', value: 5 },
25+
],
26+
};
27+
render(<ChartWidget config={config} />);
28+
expect(screen.getByText('Leads by Status')).toBeDefined();
29+
expect(screen.getByText('New')).toBeDefined();
30+
expect(screen.getByText('Qualified')).toBeDefined();
31+
});
32+
33+
it('renders pie chart with legend', () => {
34+
const config: ChartConfig = {
35+
type: 'pie',
36+
title: 'Distribution',
37+
data: [
38+
{ label: 'A', value: 30 },
39+
{ label: 'B', value: 70 },
40+
],
41+
};
42+
render(<ChartWidget config={config} />);
43+
expect(screen.getByText('Distribution')).toBeDefined();
44+
expect(screen.getByText('A')).toBeDefined();
45+
expect(screen.getByText('B')).toBeDefined();
46+
});
47+
48+
it('renders description when provided', () => {
49+
const config: ChartConfig = {
50+
type: 'number',
51+
title: 'Title',
52+
description: 'Some description',
53+
data: [{ label: 'X', value: 1 }],
54+
};
55+
render(<ChartWidget config={config} />);
56+
expect(screen.getByText('Some description')).toBeDefined();
57+
});
58+
59+
it('has data-testid attribute', () => {
60+
const config: ChartConfig = {
61+
type: 'number',
62+
title: 'Test',
63+
data: [{ label: 'X', value: 1 }],
64+
};
65+
render(<ChartWidget config={config} />);
66+
expect(screen.getByTestId('chart-widget')).toBeDefined();
67+
});
68+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { ViewSwitcher, findKanbanField } from '@/components/objectui/ViewSwitcher';
4+
import type { ObjectDefinition } from '@/types/metadata';
5+
import { vi } from 'vitest';
6+
7+
const taskObjectDef: ObjectDefinition = {
8+
name: 'task',
9+
label: 'Task',
10+
fields: {
11+
id: { type: 'text', label: 'ID', readonly: true },
12+
title: { type: 'text', label: 'Title' },
13+
status: {
14+
type: 'select',
15+
label: 'Status',
16+
options: [
17+
{ label: 'To Do', value: 'todo' },
18+
{ label: 'In Progress', value: 'in_progress' },
19+
{ label: 'Done', value: 'done' },
20+
],
21+
},
22+
due_date: { type: 'datetime', label: 'Due Date' },
23+
},
24+
};
25+
26+
const noSelectObjectDef: ObjectDefinition = {
27+
name: 'note',
28+
label: 'Note',
29+
fields: {
30+
id: { type: 'text', label: 'ID', readonly: true },
31+
content: { type: 'textarea', label: 'Content' },
32+
},
33+
};
34+
35+
describe('findKanbanField', () => {
36+
it('returns status field for object with select fields', () => {
37+
expect(findKanbanField(taskObjectDef)).toBe('status');
38+
});
39+
40+
it('returns undefined for object without select fields', () => {
41+
expect(findKanbanField(noSelectObjectDef)).toBeUndefined();
42+
});
43+
44+
it('prefers status/stage/state/priority names', () => {
45+
const objectDef: ObjectDefinition = {
46+
name: 'test',
47+
label: 'Test',
48+
fields: {
49+
category: {
50+
type: 'select',
51+
label: 'Category',
52+
options: [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }],
53+
},
54+
priority: {
55+
type: 'select',
56+
label: 'Priority',
57+
options: [{ label: 'Low', value: 'low' }, { label: 'High', value: 'high' }],
58+
},
59+
},
60+
};
61+
expect(findKanbanField(objectDef)).toBe('priority');
62+
});
63+
});
64+
65+
describe('ViewSwitcher', () => {
66+
it('renders all three view buttons', () => {
67+
render(<ViewSwitcher currentView="table" onViewChange={() => {}} />);
68+
// Three buttons should exist
69+
const buttons = screen.getAllByRole('button');
70+
expect(buttons.length).toBe(3);
71+
});
72+
73+
it('calls onViewChange when a button is clicked', () => {
74+
const onViewChange = vi.fn();
75+
render(
76+
<ViewSwitcher
77+
currentView="table"
78+
onViewChange={onViewChange}
79+
objectDef={taskObjectDef}
80+
/>,
81+
);
82+
// Click the kanban button (second button)
83+
const buttons = screen.getAllByRole('button');
84+
fireEvent.click(buttons[1]); // Kanban
85+
expect(onViewChange).toHaveBeenCalledWith('kanban');
86+
});
87+
88+
it('disables kanban when no select field available', () => {
89+
render(
90+
<ViewSwitcher
91+
currentView="table"
92+
onViewChange={() => {}}
93+
objectDef={noSelectObjectDef}
94+
/>,
95+
);
96+
const buttons = screen.getAllByRole('button');
97+
// Kanban button should be disabled
98+
expect(buttons[1].hasAttribute('disabled')).toBe(true);
99+
});
100+
101+
it('enables kanban when select field is available', () => {
102+
render(
103+
<ViewSwitcher
104+
currentView="table"
105+
onViewChange={() => {}}
106+
objectDef={taskObjectDef}
107+
/>,
108+
);
109+
const buttons = screen.getAllByRole('button');
110+
// Kanban button should NOT be disabled
111+
expect(buttons[1].hasAttribute('disabled')).toBe(false);
112+
});
113+
});
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import { WorkflowStatusBadge } from '@/components/workflow/WorkflowStatusBadge';
4+
import type { WorkflowStatus } from '@/types/workflow';
5+
6+
function makeStatus(overrides?: Partial<WorkflowStatus>): WorkflowStatus {
7+
return {
8+
workflowName: 'test_flow',
9+
currentState: 'pending',
10+
currentStateLabel: 'Pending',
11+
color: 'yellow',
12+
availableTransitions: [],
13+
...overrides,
14+
};
15+
}
16+
17+
describe('WorkflowStatusBadge', () => {
18+
it('renders the current state label', () => {
19+
render(<WorkflowStatusBadge status={makeStatus()} />);
20+
expect(screen.getByText('Pending')).toBeDefined();
21+
});
22+
23+
it('renders with green color for approved state', () => {
24+
render(
25+
<WorkflowStatusBadge
26+
status={makeStatus({ currentStateLabel: 'Approved', color: 'green' })}
27+
/>,
28+
);
29+
expect(screen.getByText('Approved')).toBeDefined();
30+
});
31+
32+
it('shows workflow name when showWorkflowName is true', () => {
33+
render(
34+
<WorkflowStatusBadge
35+
status={makeStatus({ workflowName: 'leave_flow' })}
36+
showWorkflowName
37+
/>,
38+
);
39+
expect(screen.getByText('leave_flow:')).toBeDefined();
40+
});
41+
42+
it('hides workflow name by default', () => {
43+
const { container } = render(
44+
<WorkflowStatusBadge status={makeStatus({ workflowName: 'leave_flow' })} />,
45+
);
46+
expect(container.textContent).not.toContain('leave_flow:');
47+
});
48+
49+
it('has data-testid attribute', () => {
50+
render(<WorkflowStatusBadge status={makeStatus()} />);
51+
expect(screen.getByTestId('workflow-status-badge')).toBeDefined();
52+
});
53+
});

0 commit comments

Comments
 (0)