|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { useState } from 'react'; |
4 | 3 | 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'; |
7 | 9 | 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'; |
10 | 34 |
|
11 | 35 | interface ControlsClientPageProps { |
12 | | - initialControls: FrameworkEditorControlTemplate[]; |
| 36 | + initialControls: FrameworkEditorControlTemplateWithRelatedData[]; |
13 | 37 | } |
14 | 38 |
|
| 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 | + |
15 | 94 | 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 | + ); |
18 | 159 |
|
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]); |
22 | 174 |
|
| 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 | + |
23 | 283 | return ( |
24 | 284 | <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} |
33 | 298 | /> |
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 | + })} |
40 | 325 | /> |
41 | 326 | </PageLayout> |
42 | 327 | ); |
|
0 commit comments