Skip to content

Commit 49a3100

Browse files
authored
Merge pull request #606 from trycompai/claudio/comp-editor-add-editable-table
[dev] [claudfuen] claudio/comp-editor-add-editable-table
2 parents 0a9a63f + 960fd3c commit 49a3100

File tree

18 files changed

+1567
-68
lines changed

18 files changed

+1567
-68
lines changed

apps/framework-editor/app/(pages)/controls/ControlsClientPage.tsx

Lines changed: 310 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,327 @@
11
'use client';
22

3-
import { useState } from 'react';
43
import PageLayout from "@/app/components/PageLayout";
5-
import { DataTable } from "@/app/components/DataTable";
6-
import { columns } from "./components/columns";
4+
import { useMemo, useState } from 'react';
5+
import { TableToolbar } from '../../components/TableToolbar';
6+
import { useTableSearchSort } from '../../hooks/useTableSearchSort';
7+
import type { SortConfig } from '../../types/common';
8+
import { relationalColumn, type ItemWithName } from './components/RelationalCell';
79
import { CreateControlDialog } from './components/CreateControlDialog';
8-
import type { FrameworkEditorControlTemplate } from '@prisma/client';
9-
import { useRouter } from 'next/navigation';
10+
import { simpleUUID, useChangeTracking } from './hooks/useChangeTracking';
11+
import type {
12+
ControlsPageGridData,
13+
ControlsPageSortableColumnKey,
14+
FrameworkEditorControlTemplateWithRelatedData,
15+
SortableColumnOption
16+
} from './types';
17+
import {
18+
getAllPolicyTemplates, getAllRequirements, getAllTaskTemplates,
19+
linkPolicyTemplateToControl, unlinkPolicyTemplateFromControl,
20+
linkRequirementToControl, unlinkRequirementFromControl,
21+
linkTaskTemplateToControl, unlinkTaskTemplateFromControl
22+
} from './actions';
23+
import { toast } from 'sonner';
24+
25+
import {
26+
Column,
27+
DataSheetGrid,
28+
dateColumn,
29+
keyColumn,
30+
textColumn,
31+
type CellProps,
32+
} from 'react-datasheet-grid';
33+
import 'react-datasheet-grid/dist/style.css';
1034

1135
interface ControlsClientPageProps {
12-
initialControls: FrameworkEditorControlTemplate[];
36+
initialControls: FrameworkEditorControlTemplateWithRelatedData[];
1337
}
1438

39+
const sortableColumnsOptions: SortableColumnOption[] = [
40+
{ value: 'name', label: 'Name' },
41+
{ value: 'description', label: 'Description' },
42+
{ value: 'createdAt', label: 'Created At' },
43+
{ value: 'updatedAt', label: 'Updated At' },
44+
];
45+
46+
const controlsSearchableKeys: ControlsPageSortableColumnKey[] = ['name', 'description'];
47+
const controlsSortConfig: SortConfig<ControlsPageSortableColumnKey> = {
48+
name: 'string',
49+
description: 'string',
50+
policyTemplatesLength: 'number',
51+
requirementsLength: 'number',
52+
taskTemplatesLength: 'number',
53+
createdAt: 'number',
54+
updatedAt: 'number',
55+
};
56+
57+
// Helper function to format dates in a friendly way
58+
const formatFriendlyDate = (date: Date | string | number | null | undefined): string => {
59+
if (date === null || date === undefined) return '';
60+
const d = new Date(date);
61+
if (isNaN(d.getTime())) return 'Invalid Date'; // Handle invalid date objects
62+
return new Intl.DateTimeFormat(undefined, { // 'undefined' uses the browser's default locale
63+
year: 'numeric',
64+
month: 'long',
65+
day: 'numeric',
66+
hour: 'numeric',
67+
minute: '2-digit',
68+
hour12: true,
69+
}).format(d);
70+
};
71+
72+
// Custom base column configuration for displaying friendly dates
73+
const friendlyDateColumnBase: Partial<Column<Date | null, any, string>> = {
74+
component: ({ rowData }: CellProps<Date | null, any>) => (
75+
<div
76+
style={{
77+
padding: '5px',
78+
whiteSpace: 'nowrap',
79+
overflow: 'hidden',
80+
textOverflow: 'ellipsis',
81+
width: '100%',
82+
height: '100%',
83+
display: 'flex',
84+
alignItems: 'center'
85+
}}
86+
title={formatFriendlyDate(rowData)}
87+
>
88+
{formatFriendlyDate(rowData)}
89+
</div>
90+
),
91+
// copyValue can be added if needed: ({ rowData }) => formatFriendlyDate(rowData),
92+
};
93+
1594
export function ControlsClientPage({ initialControls }: ControlsClientPageProps) {
16-
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
17-
const router = useRouter(); // Uncomment if needed
95+
const initialGridData: ControlsPageGridData[] = useMemo(() => {
96+
return initialControls.map(control => {
97+
let cDate: Date | null = null;
98+
if (control.createdAt) {
99+
// Handles if control.createdAt is Date obj or valid date string
100+
const parsedCDate = new Date(control.createdAt);
101+
if (!isNaN(parsedCDate.getTime())) { // Check if it's a valid date
102+
cDate = parsedCDate;
103+
}
104+
}
105+
106+
let uDate: Date | null = null;
107+
if (control.updatedAt) {
108+
// Handles if control.updatedAt is Date obj or valid date string
109+
const parsedUDate = new Date(control.updatedAt);
110+
if (!isNaN(parsedUDate.getTime())) { // Check if it's a valid date
111+
uDate = parsedUDate;
112+
}
113+
}
114+
115+
return {
116+
id: control.id || simpleUUID(),
117+
name: control.name ?? null,
118+
description: control.description ?? null,
119+
policyTemplates: control.policyTemplates?.map(pt => ({ id: pt.id, name: pt.name })) ?? [],
120+
requirements: control.requirements?.map(r => ({ id: r.id, name: r.name })) ?? [],
121+
taskTemplates: control.taskTemplates?.map(tt => ({ id: tt.id, name: tt.name })) ?? [],
122+
policyTemplatesLength: control.policyTemplates?.length ?? 0,
123+
requirementsLength: control.requirements?.length ?? 0,
124+
taskTemplatesLength: control.taskTemplates?.length ?? 0,
125+
createdAt: cDate,
126+
updatedAt: uDate,
127+
};
128+
});
129+
}, [initialControls]);
130+
131+
const {
132+
dataForGrid,
133+
handleGridChange,
134+
getRowClassName,
135+
handleCommit,
136+
handleCancel,
137+
isDirty,
138+
createdRowIds,
139+
updatedRowIds,
140+
deletedRowIds,
141+
changesSummaryString
142+
} = useChangeTracking(initialGridData);
143+
144+
const {
145+
searchTerm,
146+
setSearchTerm,
147+
sortColumnKey,
148+
setSortColumnKey,
149+
sortDirection,
150+
toggleSortDirection,
151+
processedData: sortedDataWithPotentialTimestamps,
152+
} = useTableSearchSort<ControlsPageGridData, ControlsPageSortableColumnKey>(
153+
dataForGrid,
154+
controlsSearchableKeys,
155+
controlsSortConfig,
156+
'createdAt',
157+
'asc'
158+
);
18159

19-
const handleRowClick = (control: FrameworkEditorControlTemplate) => {
20-
router.push(`/controls/${control.id}`);
21-
};
160+
// Convert timestamps back to Date objects if useTableSearchSort changed them
161+
const dataForDisplay = useMemo(() => {
162+
return sortedDataWithPotentialTimestamps.map(row => {
163+
const newRow = { ...row };
164+
// If createdAt/updatedAt became a number (timestamp) after sorting, convert back to Date
165+
if (typeof newRow.createdAt === 'number') {
166+
newRow.createdAt = new Date(newRow.createdAt);
167+
}
168+
if (typeof newRow.updatedAt === 'number') {
169+
newRow.updatedAt = new Date(newRow.updatedAt);
170+
}
171+
return newRow;
172+
});
173+
}, [sortedDataWithPotentialTimestamps]);
22174

175+
const columns: Column<ControlsPageGridData>[] = [
176+
{ ...keyColumn('name', textColumn), title: 'Name', minWidth: 300 },
177+
{ ...keyColumn('description', textColumn), title: 'Description', minWidth: 420, grow: 1 },
178+
{
179+
...(relationalColumn<ControlsPageGridData, 'policyTemplates'> ({
180+
itemsKey: 'policyTemplates',
181+
getAllSearchableItems: getAllPolicyTemplates,
182+
linkItemAction: async (controlId, policyTemplateId) => {
183+
try {
184+
await linkPolicyTemplateToControl(controlId, policyTemplateId);
185+
toast.success("Policy template linked successfully.");
186+
} catch (error) {
187+
toast.error(`Failed to link policy template: ${error instanceof Error ? error.message : String(error)}`);
188+
// Do not re-throw, error is handled with a toast
189+
}
190+
},
191+
unlinkItemAction: async (controlId, policyTemplateId) => {
192+
try {
193+
await unlinkPolicyTemplateFromControl(controlId, policyTemplateId);
194+
toast.success("Policy template unlinked successfully.");
195+
} catch (error) {
196+
toast.error(`Failed to unlink policy template: ${error instanceof Error ? error.message : String(error)}`);
197+
}
198+
},
199+
itemTypeLabel: 'Policy',
200+
createdRowIds: createdRowIds,
201+
})),
202+
id: 'policyTemplates',
203+
title: 'Linked Policies',
204+
minWidth: 200
205+
},
206+
{
207+
id: 'requirements',
208+
title: 'Linked Requirements',
209+
minWidth: 200,
210+
...(relationalColumn<ControlsPageGridData, 'requirements'> ({
211+
itemsKey: 'requirements',
212+
getAllSearchableItems: getAllRequirements,
213+
linkItemAction: async (controlId, requirementId) => {
214+
try {
215+
await linkRequirementToControl(controlId, requirementId);
216+
toast.success("Requirement linked successfully.");
217+
} catch (error) {
218+
toast.error(`Failed to link requirement: ${error instanceof Error ? error.message : String(error)}`);
219+
}
220+
},
221+
unlinkItemAction: async (controlId, requirementId) => {
222+
try {
223+
await unlinkRequirementFromControl(controlId, requirementId);
224+
toast.success("Requirement unlinked successfully.");
225+
} catch (error) {
226+
toast.error(`Failed to unlink requirement: ${error instanceof Error ? error.message : String(error)}`);
227+
}
228+
},
229+
itemTypeLabel: 'Requirement',
230+
createdRowIds: createdRowIds,
231+
})),
232+
},
233+
{
234+
id: 'taskTemplates',
235+
title: 'Linked Tasks',
236+
minWidth: 200,
237+
...(relationalColumn<ControlsPageGridData, 'taskTemplates'> ({
238+
itemsKey: 'taskTemplates',
239+
getAllSearchableItems: getAllTaskTemplates,
240+
linkItemAction: async (controlId, taskTemplateId) => {
241+
try {
242+
await linkTaskTemplateToControl(controlId, taskTemplateId);
243+
toast.success("Task template linked successfully.");
244+
} catch (error) {
245+
toast.error(`Failed to link task template: ${error instanceof Error ? error.message : String(error)}`);
246+
}
247+
},
248+
unlinkItemAction: async (controlId, taskTemplateId) => {
249+
try {
250+
await unlinkTaskTemplateFromControl(controlId, taskTemplateId);
251+
toast.success("Task template unlinked successfully.");
252+
} catch (error) {
253+
toast.error(`Failed to unlink task template: ${error instanceof Error ? error.message : String(error)}`);
254+
}
255+
},
256+
itemTypeLabel: 'Task Template',
257+
createdRowIds: createdRowIds,
258+
})),
259+
},
260+
{
261+
...keyColumn('createdAt', friendlyDateColumnBase),
262+
title: 'Created At',
263+
minWidth: 220, // Adjusted minWidth
264+
disabled: true
265+
},
266+
{
267+
...keyColumn('updatedAt', friendlyDateColumnBase),
268+
title: 'Updated At',
269+
minWidth: 220, // Adjusted minWidth
270+
disabled: true
271+
},
272+
{
273+
...keyColumn(
274+
'id',
275+
textColumn as Partial<Column<string, any, string>>
276+
),
277+
title: 'ID',
278+
minWidth: 300,
279+
disabled: true
280+
},
281+
];
282+
23283
return (
24284
<PageLayout breadcrumbs={[{ label: "Controls", href: "/controls" }]}>
25-
<DataTable
26-
data={initialControls}
27-
columns={columns}
28-
searchQueryParamName="controls-search"
29-
searchPlaceholder="Search controls..."
30-
onCreateClick={() => setIsCreateDialogOpen(true)}
31-
createButtonLabel="Create New Control"
32-
onRowClick={handleRowClick}
285+
<TableToolbar
286+
searchTerm={searchTerm}
287+
onSearchTermChange={setSearchTerm}
288+
sortColumnKey={sortColumnKey}
289+
onSortColumnKeyChange={(key) => setSortColumnKey(key as ControlsPageSortableColumnKey | null)}
290+
sortDirection={sortDirection}
291+
onSortDirectionChange={toggleSortDirection}
292+
sortableColumnOptions={sortableColumnsOptions}
293+
showCommitCancel={true}
294+
isDirty={isDirty}
295+
onCommit={handleCommit}
296+
onCancel={handleCancel}
297+
commitButtonDetailText={changesSummaryString}
33298
/>
34-
<CreateControlDialog
35-
isOpen={isCreateDialogOpen}
36-
onOpenChange={setIsCreateDialogOpen}
37-
onControlCreated={() => {
38-
setIsCreateDialogOpen(false);
39-
}}
299+
300+
<DataSheetGrid
301+
value={dataForDisplay}
302+
height={600}
303+
onChange={handleGridChange}
304+
columns={columns}
305+
rowClassName={getRowClassName}
306+
createRow={() => ({
307+
id: simpleUUID(),
308+
name: 'Control Name',
309+
description: 'Control Description',
310+
policyTemplates: [],
311+
requirements: [],
312+
taskTemplates: [],
313+
policyTemplatesLength: 0,
314+
requirementsLength: 0,
315+
taskTemplatesLength: 0,
316+
createdAt: new Date(),
317+
updatedAt: new Date(),
318+
})}
319+
duplicateRow={({rowData}) => ({
320+
...rowData,
321+
id: simpleUUID(),
322+
createdAt: new Date(),
323+
updatedAt: new Date(),
324+
})}
40325
/>
41326
</PageLayout>
42327
);

apps/framework-editor/app/(pages)/controls/[controlId]/components/ManageLinksDialog.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
'use client'
22

3+
import { SearchAndLinkList, type SearchableItemForLinking } from '@/app/components/SearchAndLinkList';
34
import {
4-
Dialog,
5-
DialogContent,
6-
DialogHeader,
7-
DialogTitle,
5+
Dialog,
6+
DialogContent,
87
DialogDescription,
9-
DialogFooter // If we need a close button, though onOpenChange handles it
8+
DialogHeader,
9+
DialogTitle
1010
} from "@comp/ui/dialog";
11-
import { Button } from "@comp/ui/button";
12-
import { SearchAndLinkList, type SearchableItemForLinking } from '@/app/components/SearchAndLinkList';
1311

1412
interface ManageLinksDialogProps {
1513
isOpen: boolean;

0 commit comments

Comments
 (0)