All checks were successful
Trigger Cloudarix Deploy / call-webhook (push) Successful in 2s
✨ New Features - Advanced data table with TanStack Table v8 + Server Actions - Server-side pagination (10/25/50/100 rows per page) - Multi-column sorting with visual indicators - Column management (show/hide, resize) persisted to localStorage - URL state synchronization for shareable filtered views - Sticky header with compact/comfortable density modes 📦 Components Added - PolicyTableV2.tsx - Main table with TanStack integration - PolicyTableColumns.tsx - 7 column definitions with sorting - PolicyTablePagination.tsx - Pagination controls - PolicyTableToolbar.tsx - Density toggle + column visibility menu - ColumnVisibilityMenu.tsx - Show/hide columns dropdown 🔧 Hooks Added - usePolicyTable.ts - TanStack Table initialization - useURLState.ts - URL query param sync with nuqs - useTablePreferences.ts - localStorage persistence 🎨 Server Actions Updated - getPolicySettingsV2 - Pagination + sorting + filtering + Zod validation - exportPolicySettingsCSV - Server-side CSV generation (max 5000 rows) 📚 Documentation Added - Intune Migration Guide (1400+ lines) - Reverse engineering strategy - Intune Reference Version tracking - Tasks completed: 22/62 (Phase 1-3) ✅ Zero TypeScript compilation errors ✅ All MVP success criteria met (pagination, sorting, column management) ✅ Ready for Phase 4-7 (filtering, export, detail view, polish) Refs: specs/004-policy-explorer-v2/tasks.md
217 lines
6.4 KiB
TypeScript
217 lines
6.4 KiB
TypeScript
/**
|
|
* PolicyTableColumns
|
|
*
|
|
* Column definitions for the Policy Explorer V2 data table.
|
|
* Uses TanStack Table column definition API.
|
|
*
|
|
* Columns:
|
|
* - settingName: The setting key/identifier
|
|
* - settingValue: The setting value (truncated with tooltip)
|
|
* - policyName: Name of the policy containing this setting
|
|
* - policyType: Type badge (deviceConfiguration, compliancePolicy, etc.)
|
|
* - lastSyncedAt: Timestamp of last sync from Intune
|
|
* - graphPolicyId: Microsoft Graph Policy ID (truncated)
|
|
*/
|
|
|
|
import type { ColumnDef } from '@tanstack/react-table';
|
|
import type { PolicySettingRow } from '@/lib/types/policy-table';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
export const policyTableColumns: ColumnDef<PolicySettingRow>[] = [
|
|
{
|
|
id: 'select',
|
|
header: ({ table }) => (
|
|
<div className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={table.getIsAllPageRowsSelected()}
|
|
ref={(input) => {
|
|
if (input) {
|
|
input.indeterminate = table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected();
|
|
}
|
|
}}
|
|
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
|
aria-label="Select all rows on this page"
|
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
/>
|
|
</div>
|
|
),
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={row.getIsSelected()}
|
|
disabled={!row.getCanSelect()}
|
|
onChange={row.getToggleSelectedHandler()}
|
|
aria-label={`Select row ${row.id}`}
|
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
/>
|
|
</div>
|
|
),
|
|
enableSorting: false,
|
|
enableResizing: false,
|
|
},
|
|
{
|
|
accessorKey: 'settingName',
|
|
header: ({ column }) => {
|
|
const isSorted = column.getIsSorted();
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => column.toggleSorting()}
|
|
className="h-8 px-2 lg:px-3"
|
|
>
|
|
Setting Name
|
|
{isSorted === 'asc' ? (
|
|
<ArrowUp className="ml-2 h-4 w-4" />
|
|
) : isSorted === 'desc' ? (
|
|
<ArrowDown className="ml-2 h-4 w-4" />
|
|
) : (
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const settingName = row.getValue('settingName') as string;
|
|
return (
|
|
<div className="max-w-[300px] truncate font-medium" title={settingName}>
|
|
{settingName}
|
|
</div>
|
|
);
|
|
},
|
|
enableSorting: true,
|
|
enableResizing: true,
|
|
},
|
|
{
|
|
accessorKey: 'settingValue',
|
|
header: 'Setting Value',
|
|
cell: ({ row }) => {
|
|
const settingValue = row.getValue('settingValue') as string;
|
|
const truncated = settingValue.length > 100 ? settingValue.slice(0, 100) + '...' : settingValue;
|
|
return (
|
|
<div className="max-w-[400px] truncate" title={settingValue}>
|
|
{truncated}
|
|
</div>
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
enableResizing: true,
|
|
},
|
|
{
|
|
accessorKey: 'policyName',
|
|
header: ({ column }) => {
|
|
const isSorted = column.getIsSorted();
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => column.toggleSorting()}
|
|
className="h-8 px-2 lg:px-3"
|
|
>
|
|
Policy Name
|
|
{isSorted === 'asc' ? (
|
|
<ArrowUp className="ml-2 h-4 w-4" />
|
|
) : isSorted === 'desc' ? (
|
|
<ArrowDown className="ml-2 h-4 w-4" />
|
|
) : (
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const policyName = row.getValue('policyName') as string;
|
|
return (
|
|
<div className="max-w-[300px] truncate" title={policyName}>
|
|
{policyName}
|
|
</div>
|
|
);
|
|
},
|
|
enableSorting: true,
|
|
enableResizing: true,
|
|
},
|
|
{
|
|
accessorKey: 'policyType',
|
|
header: ({ column }) => {
|
|
const isSorted = column.getIsSorted();
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => column.toggleSorting()}
|
|
className="h-8 px-2 lg:px-3"
|
|
>
|
|
Policy Type
|
|
{isSorted === 'asc' ? (
|
|
<ArrowUp className="ml-2 h-4 w-4" />
|
|
) : isSorted === 'desc' ? (
|
|
<ArrowDown className="ml-2 h-4 w-4" />
|
|
) : (
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const policyType = row.getValue('policyType') as string;
|
|
return (
|
|
<Badge variant="outline" className="whitespace-nowrap">
|
|
{policyType}
|
|
</Badge>
|
|
);
|
|
},
|
|
enableSorting: true,
|
|
enableResizing: true,
|
|
},
|
|
{
|
|
accessorKey: 'lastSyncedAt',
|
|
header: ({ column }) => {
|
|
const isSorted = column.getIsSorted();
|
|
return (
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => column.toggleSorting()}
|
|
className="h-8 px-2 lg:px-3"
|
|
>
|
|
Last Synced
|
|
{isSorted === 'asc' ? (
|
|
<ArrowUp className="ml-2 h-4 w-4" />
|
|
) : isSorted === 'desc' ? (
|
|
<ArrowDown className="ml-2 h-4 w-4" />
|
|
) : (
|
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
);
|
|
},
|
|
cell: ({ row }) => {
|
|
const lastSyncedAt = row.getValue('lastSyncedAt') as Date;
|
|
const formattedDate = formatDistanceToNow(new Date(lastSyncedAt), { addSuffix: true });
|
|
return (
|
|
<div className="whitespace-nowrap text-sm text-muted-foreground" title={new Date(lastSyncedAt).toLocaleString()}>
|
|
{formattedDate}
|
|
</div>
|
|
);
|
|
},
|
|
enableSorting: true,
|
|
enableResizing: true,
|
|
},
|
|
{
|
|
accessorKey: 'graphPolicyId',
|
|
header: 'Graph Policy ID',
|
|
cell: ({ row }) => {
|
|
const graphPolicyId = row.getValue('graphPolicyId') as string;
|
|
const truncated = graphPolicyId.length > 40 ? graphPolicyId.slice(0, 40) + '...' : graphPolicyId;
|
|
return (
|
|
<div className="max-w-[200px] truncate font-mono text-xs" title={graphPolicyId}>
|
|
{truncated}
|
|
</div>
|
|
);
|
|
},
|
|
enableSorting: false,
|
|
enableResizing: true,
|
|
},
|
|
];
|