diff --git a/frontend/src/features/alerting-rules/components/table.tsx b/frontend/src/features/alerting-rules/components/table.tsx index 15b55f95c..b0926d134 100644 --- a/frontend/src/features/alerting-rules/components/table.tsx +++ b/frontend/src/features/alerting-rules/components/table.tsx @@ -1,6 +1,8 @@ import type { ReactNode } from 'react' import type { TFunction } from 'i18next' import { Crosshair, Lock } from 'lucide-react' +import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { cn } from '@/shared/lib/utils' import type { CorrelationRule } from '../services/alerting-rules-http.service' import { impactKey } from '../lib/impact-key' @@ -11,30 +13,44 @@ import { Toggle } from './toggle' const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium' const TD = 'whitespace-nowrap px-3 py-2.5 align-middle' +const ALERTING_RULES_TABLE_COLS = [48, 360, 220, 160, 180, 160, 90, 90] const IMPACT_TONE: Record = { high: 'text-red-500', medium: 'text-amber-500', low: 'text-sky-500', none: 'text-muted-foreground' } export function Table({ rules, selected, onToggleSelected, onSelectAll, onOpen, onToggle, t, footer }: { rules: CorrelationRule[]; selected: Set; onToggleSelected: (relPath: string) => void; onSelectAll: (checked: boolean) => void; onOpen: (r: CorrelationRule) => void; onToggle: (r: CorrelationRule, next: boolean) => void; t: TFunction; footer?: ReactNode }) { const allChecked = rules.length > 0 && rules.every((r) => selected.has(r.relPath)) const someChecked = !allChecked && rules.some((r) => selected.has(r.relPath)) + const alertingRulesLabelMins = colMins([ + t('alertingRules.table.name'), + t('alertingRules.table.dataTypes'), + t('alertingRules.table.category'), + t('alertingRules.table.technique'), + t('alertingRules.table.adversary'), + t('alertingRules.table.impact'), + t('alertingRules.table.active'), + ]) + const { widths, startDrag } = useResizableColumns(ALERTING_RULES_TABLE_COLS, { + min: [48, ...alertingRulesLabelMins], + storageKey: 'alerting-rules-table-columns', + }) return (
- - - - - - - - - - - - - +
-
- -
-
{t('alertingRules.table.name')}{t('alertingRules.table.dataTypes')}{t('alertingRules.table.category')}{t('alertingRules.table.technique')}{t('alertingRules.table.adversary')}{t('alertingRules.table.impact')}{t('alertingRules.table.active')}
+ , className: `${TH} text-center` }, + { content: t('alertingRules.table.name'), className: TH }, + { content: t('alertingRules.table.dataTypes'), className: TH }, + { content: t('alertingRules.table.category'), className: TH }, + { content: t('alertingRules.table.technique'), className: TH }, + { content: t('alertingRules.table.adversary'), className: TH }, + { content: t('alertingRules.table.impact'), className: `${TH} text-center` }, + { content: t('alertingRules.table.active'), className: `${TH} text-center` }, + ]} + widths={widths} + startDrag={startDrag} + className="sticky top-0 z-10 bg-muted/90 text-[10px] uppercase tracking-wider text-muted-foreground" + rowClassName="border-b border-border" + /> {rules.map((r) => { const dts = (r.dataTypes ?? []).filter((d) => d.included).map((d) => d.dataType) diff --git a/frontend/src/features/alerts/components/alert-row.tsx b/frontend/src/features/alerts/components/alert-row.tsx index b8031247f..1024a0ee3 100644 --- a/frontend/src/features/alerts/components/alert-row.tsx +++ b/frontend/src/features/alerts/components/alert-row.tsx @@ -130,14 +130,16 @@ export function AlertRow({ onCreateRule={() => onCreateRule(a)} /> - - - - - - - - - - - - - - + {allChecked && }, className: `${TH} w-px` }, + { content: t('alerts.table.actions'), className: `${TH} text-center` }, + { content: null, className: `${TH} text-center` }, + { content: t('alerts.table.alert'), className: TH }, + { content: t('alerts.table.status'), className: TH }, + { content: t('alerts.table.technique'), className: TH }, + { content: t('alerts.table.source'), className: TH }, + { content: t('alerts.table.adversary'), className: TH }, + { content: t('alerts.table.severity'), className: `${TH} text-center` }, + { content: t('alerts.table.echoes'), className: `${TH} text-center` }, + { content: t('alerts.table.time'), className: `${TH} text-center` }, + ]} + widths={widths} + startDrag={startDrag} + className="sticky top-0 z-10 bg-muted/90 text-[10px] uppercase tracking-wider text-muted-foreground" + rowClassName="border-b border-border" + /> ) } diff --git a/frontend/src/features/alerts/components/tagging-rules-table-row.tsx b/frontend/src/features/alerts/components/tagging-rules-table-row.tsx index 08405f517..3d4dfcd2a 100644 --- a/frontend/src/features/alerts/components/tagging-rules-table-row.tsx +++ b/frontend/src/features/alerts/components/tagging-rules-table-row.tsx @@ -1,20 +1,21 @@ import { Tag as TagIcon } from 'lucide-react' import type { TaggingRule } from '../types/tagging-rule.types' -import { TAGGING_RULES_TABLE_COLS } from './tagging-rules-table' export function TaggingRulesTableRow({ rule, + tableCols, onOpen, }: { rule: TaggingRule + tableCols: string onOpen: (rule: TaggingRule) => void }) { const tags = rule.tags ?? [] return (
onOpen(rule)} - className="grid cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-3 text-sm last:border-b-0 hover:bg-muted/30" - style={{ gridTemplateColumns: TAGGING_RULES_TABLE_COLS }} + className="grid w-max min-w-full cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-3 text-sm last:border-b-0 hover:bg-muted/30" + style={{ gridTemplateColumns: tableCols }} >
{rule.name}
diff --git a/frontend/src/features/alerts/components/tagging-rules-table.tsx b/frontend/src/features/alerts/components/tagging-rules-table.tsx index 658132493..89d9a742a 100644 --- a/frontend/src/features/alerts/components/tagging-rules-table.tsx +++ b/frontend/src/features/alerts/components/tagging-rules-table.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import type { TaggingRule } from '../types/tagging-rule.types' import { TaggingRulesTableRow } from './tagging-rules-table-row' -export const TAGGING_RULES_TABLE_COLS = '1.6fr 0.9fr 90px' +export const TAGGING_RULES_TABLE_COLS = ['1.6fr', '0.9fr', 90] export function TaggingRulesTable({ rules, @@ -12,18 +14,22 @@ export function TaggingRulesTable({ onOpen: (rule: TaggingRule) => void }) { const { t } = useTranslation() + const taggingHeaders = [t('taggingRules.table.rule'), t('taggingRules.table.tags'), t('taggingRules.table.conditions')] + const { template: tableCols, startDrag } = useResizableColumns(TAGGING_RULES_TABLE_COLS, { + min: colMins(taggingHeaders), + storageKey: 'tagging-rules-table-columns', + }) return ( -
-
-
{t('taggingRules.table.rule')}
-
{t('taggingRules.table.tags')}
-
{t('taggingRules.table.conditions')}
-
+
+ {rules.map((rule) => ( - + ))}
) diff --git a/frontend/src/features/alerts/pages/AlertsPage.tsx b/frontend/src/features/alerts/pages/AlertsPage.tsx index 470630fd9..d4eab2373 100644 --- a/frontend/src/features/alerts/pages/AlertsPage.tsx +++ b/frontend/src/features/alerts/pages/AlertsPage.tsx @@ -6,6 +6,7 @@ import { Button } from '@/shared/components/ui/button' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' import { ConfirmDialog } from '@/shared/components/ui/confirm-dialog' import { presetRange, type TimeRange, resolveRange } from '@/shared/components/ui/time-range-picker' +import { useResizableColumns } from '@/shared/hooks/useResizableColumns' import { FILTER_OPS, TS } from '../lib/alert-meta' import { alertToRuleConditions } from '../lib/tagging-rule-meta' import { @@ -30,7 +31,7 @@ import { AlertsStatusTabs } from '../components/alerts-status-tabs' import { AlertsVolumeCard } from '../components/alerts-volume-card' import { AlertsBreakdownCard } from '../components/alerts-breakdown-card' import { AlertsBulkBar } from '../components/alerts-bulk-bar' -import { AlertsTableHeader, ALERTS_TABLE_COLUMN_COUNT } from '../components/alerts-table-header' +import { AlertsTableHeader, ALERTS_TABLE_COLS, ALERTS_TABLE_COLUMN_COUNT, ALERTS_TABLE_MINS } from '../components/alerts-table-header' import { AlertRow } from '../components/alert-row' import { EchoesTimeline } from '../components/echoes-timeline' import { AlertDrawer } from '../components/alert-drawer' @@ -58,6 +59,14 @@ export function AlertsPage() { const [selected, setSelected] = useState>(new Set()) const [expandedEchoes, setExpandedEchoes] = useState>(new Set()) const [openAlert, setOpenAlert] = useState(null) + const { widths: alertTableWidths, startDrag: startAlertTableDrag } = useResizableColumns(ALERTS_TABLE_COLS, { + min: ALERTS_TABLE_MINS, + storageKey: 'alerts-table-columns', + }) + const alertTableWidth = alertTableWidths.reduce( + (total, width) => total + (typeof width === 'number' ? width : 0), + 0, + ) const [incidentTargets, setIncidentTargets] = useState(null) // Tagging-rule drawer is rendered here so the tag editor / rule button don't // have to bounce through the tagging-rules page. @@ -333,8 +342,18 @@ export function AlertsPage() { }} >
-
- {a.technique || '—'} + + {a.technique || '—'} - +
+ +
void }) { +export function AlertsTableHeader({ + allChecked, + widths, + startDrag, + onTogglePage, +}: { + allChecked: boolean + widths: ColSize[] + startDrag: ReturnType['startDrag'] + onTogglePage: () => void +}) { const { t } = useTranslation() return ( -
- - - {t('alerts.table.actions')}{t('alerts.table.alert')}{t('alerts.table.status')}{t('alerts.table.technique')}{t('alerts.table.source')}{t('alerts.table.adversary')}{t('alerts.table.severity')}{t('alerts.table.echoes')}{t('alerts.table.time')}
- +
+ + {alertTableWidths.map((width, index) => ( + + ))} + + {loading && alerts.length === 0 ? ( diff --git a/frontend/src/features/api-keys/components/KeyRow.tsx b/frontend/src/features/api-keys/components/KeyRow.tsx index f00ab2443..49d82e296 100644 --- a/frontend/src/features/api-keys/components/KeyRow.tsx +++ b/frontend/src/features/api-keys/components/KeyRow.tsx @@ -4,7 +4,7 @@ import type { ApiKey } from '../types/api-key.types' import { IconAction } from './IconAction' import { StatusBadge } from './StatusBadge' -export const COLS = '1.4fr 1.2fr 110px 110px 110px 100px 110px' +export const API_KEY_TABLE_COLS = ['1.4fr', '1.2fr', 110, 110, 110, 100, 110] function formatDate(iso: string): string { const d = new Date(iso) @@ -14,11 +14,13 @@ function formatDate(iso: string): string { export function KeyRow({ apiKey: k, + tableCols, onEdit, onRotate, onDelete, }: { apiKey: ApiKey + tableCols: string onEdit: () => void onRotate: () => void onDelete: () => void @@ -26,8 +28,8 @@ export function KeyRow({ const { t } = useTranslation() return (
diff --git a/frontend/src/features/api-keys/pages/ApiKeysPage.tsx b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx index d4563efae..85c2adc8a 100644 --- a/frontend/src/features/api-keys/pages/ApiKeysPage.tsx +++ b/frontend/src/features/api-keys/pages/ApiKeysPage.tsx @@ -12,11 +12,13 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { ColumnResizeHandle } from '@/shared/components/ui/column-resize-handle' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { useBilling } from '@/features/billing' import { EnterpriseGate } from '@/shared/components/EnterpriseGate' import { apiKeysHttpService } from '../services/api-keys-http.service' import type { ApiKey, ApiKeyPageInfo } from '../types/api-key.types' -import { COLS, KeyRow } from '../components/KeyRow' +import { API_KEY_TABLE_COLS, KeyRow } from '../components/KeyRow' import { UpsertDialog } from '../components/UpsertDialog' import { ConfirmDialog } from '../components/ConfirmDialog' import { RevealModal } from '../components/RevealModal' @@ -39,6 +41,19 @@ export function ApiKeysPage() { const [dialog, setDialog] = useState(null) const [confirm, setConfirm] = useState(null) const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null) + const apiKeyHeaders = [ + t('apiKeys.col.name'), + t('apiKeys.col.allowedIps'), + t('apiKeys.col.created'), + t('apiKeys.col.lastRotated'), + t('apiKeys.col.expires'), + t('apiKeys.col.status'), + t('apiKeys.col.actions'), + ] + const { template: tableCols, startDrag } = useResizableColumns(API_KEY_TABLE_COLS, { + min: colMins(apiKeyHeaders), + storageKey: 'api-keys-table-columns', + }) const load = useCallback(async () => { setLoading(true) @@ -129,18 +144,17 @@ export function ApiKeysPage() {
-
+
-
{t('apiKeys.col.name')}
-
{t('apiKeys.col.allowedIps')}
-
{t('apiKeys.col.created')}
-
{t('apiKeys.col.lastRotated')}
-
{t('apiKeys.col.expires')}
-
{t('apiKeys.col.status')}
-
{t('apiKeys.col.actions')}
+ {apiKeyHeaders.map((header, index, headers) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
{loading && (!keys || keys.length === 0) && ( @@ -173,6 +187,7 @@ export function ApiKeysPage() { setDialog({ mode: 'edit', key: k })} onRotate={() => setConfirm({ kind: 'rotate', key: k })} onDelete={() => setConfirm({ kind: 'delete', key: k })} diff --git a/frontend/src/features/audit/pages/AuditPage.tsx b/frontend/src/features/audit/pages/AuditPage.tsx index f5a716fc8..cad1eda3a 100644 --- a/frontend/src/features/audit/pages/AuditPage.tsx +++ b/frontend/src/features/audit/pages/AuditPage.tsx @@ -17,11 +17,14 @@ import { useDateFormat } from '@/shared/lib/datetime' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { ColumnResizeHandle } from '@/shared/components/ui/column-resize-handle' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { auditHttpService } from '../services/audit-http.service' import { humanizeAction } from '../lib' import type { AuditListQuery, AuditLog } from '../types/audit.types' const DEFAULT_PAGE_SIZE = 50 +const AUDIT_TABLE_COLS = [180, 140, '1fr', 180, 90, 140, 60] /* ─── Page ─────────────────────────────────────────────────────────────── */ @@ -433,16 +436,31 @@ function TableCard({ }) { const { t } = useTranslation() const { formatDateTime } = useDateFormat() + const headers = [ + t('audit.table.timestamp'), + t('audit.table.actor'), + t('audit.table.action'), + t('audit.table.resource'), + t('audit.table.status'), + t('audit.table.ip'), + '', + ] + const { template, startDrag } = useResizableColumns(AUDIT_TABLE_COLS, { + min: colMins(headers), + storageKey: 'audit-log-table-columns', + }) return ( -
-
-
{t('audit.table.timestamp')}
-
{t('audit.table.actor')}
-
{t('audit.table.action')}
-
{t('audit.table.resource')}
-
{t('audit.table.status')}
-
{t('audit.table.ip')}
-
+
+
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
{loading && data.length === 0 ? (
@@ -459,7 +477,8 @@ function TableCard({
- - - {columns.map((c) => ( - - ))} - - + ({ content: c, className: 'px-3 py-2 font-medium' }))} + widths={widths} + startDrag={startDrag} + className="sticky top-0 bg-card" + rowClassName="border-b border-border text-left text-xs uppercase tracking-wide text-muted-foreground" + /> {rows.map((row, i) => ( diff --git a/frontend/src/features/datasources/pages/DataSourcesPage.tsx b/frontend/src/features/datasources/pages/DataSourcesPage.tsx index 572b672f3..242ed7663 100644 --- a/frontend/src/features/datasources/pages/DataSourcesPage.tsx +++ b/frontend/src/features/datasources/pages/DataSourcesPage.tsx @@ -31,6 +31,8 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { ColumnResizeHandle } from '@/shared/components/ui/column-resize-handle' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { TimeRangePicker, presetRange, type TimeRange } from '@/shared/components/ui/time-range-picker' import { datasourcesHttpService as svc, @@ -135,6 +137,17 @@ export function DataSourcesPage() { const [range, setRange] = useState(() => presetRange('24h')) const [counts, setCounts] = useState | null>(null) const [openId, setOpenId] = useState(null) + const datasourcesLabelMins = colMins([ + t('datasources.cols.source'), + t('datasources.cols.type'), + t('datasources.cols.status'), + t('datasources.cols.events24h'), + t('datasources.cols.lastSeen'), + ]) + const { template: listCols, startDrag } = useResizableColumns(LIST_COLS, { + min: [36, ...datasourcesLabelMins], + storageKey: 'datasources-table-columns', + }) useEffect(() => { const h = setTimeout(() => { @@ -287,10 +300,10 @@ export function DataSourcesPage() { ) : sources.length === 0 ? ( {t('datasources.none')} ) : layout === 'list' ? ( -
- +
+ {sources.map((s) => ( - setOpenId(s.id)} onLabelClick={filterByLabel} /> + setOpenId(s.id)} onLabelClick={filterByLabel} /> ))}
) : ( @@ -536,32 +549,42 @@ function Toolbar({ /* ─── List ─────────────────────────────────────────────────────────────── */ -const LIST_COLS = '36px 1fr 150px 110px 120px 120px' +const LIST_COLS = [36, '1fr', 150, 110, 120, 120] -function ListHeader() { +function ListHeader({ tableCols, startDrag }: { tableCols: string; startDrag: ReturnType['startDrag'] }) { const { t } = useTranslation() + const headers = [ + '', + t('datasources.cols.source'), + t('datasources.cols.type'), + t('datasources.cols.status'), + t('datasources.cols.events24h'), + t('datasources.cols.lastSeen'), + ] return (
-
-
{t('datasources.cols.source')}
-
{t('datasources.cols.type')}
-
{t('datasources.cols.status')}
-
{t('datasources.cols.events24h')}
-
{t('datasources.cols.lastSeen')}
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
) } function SourceListRow({ source: s, + tableCols, events24h, onOpen, onLabelClick, }: { source: Datasource + tableCols: string events24h: number onOpen: () => void onLabelClick: (label: string) => void @@ -573,8 +596,8 @@ function SourceListRow({ return (
diff --git a/frontend/src/features/incidents/components/create-incident-step-alerts.tsx b/frontend/src/features/incidents/components/create-incident-step-alerts.tsx index e4681a394..75df73558 100644 --- a/frontend/src/features/incidents/components/create-incident-step-alerts.tsx +++ b/frontend/src/features/incidents/components/create-incident-step-alerts.tsx @@ -58,7 +58,7 @@ export function CreateIncidentStepAlerts({
-
- {c} -
+
onToggleAll(alerts)} /> {loading && alerts.length === 0 ? ( diff --git a/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx b/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx index a16a76faa..e18e98870 100644 --- a/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx +++ b/frontend/src/features/incidents/components/incident-alerts-picker-header.tsx @@ -1,6 +1,9 @@ import { useTranslation } from 'react-i18next' +import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium' +const INCIDENT_ALERTS_TABLE_COLS = [6, 36, 360, 90, 160] export function IncidentAlertsPickerHeader({ allChecked, @@ -10,19 +13,28 @@ export function IncidentAlertsPickerHeader({ onTogglePage: () => void }) { const { t } = useTranslation() + const pickerLabelMins = colMins([ + t('alerts.table.alert'), + t('alerts.table.severity'), + t('alerts.table.time'), + ]) + const { widths, startDrag } = useResizableColumns(INCIDENT_ALERTS_TABLE_COLS, { + min: [6, 36, ...pickerLabelMins], + storageKey: 'incident-alerts-picker-table-columns', + }) return ( - - - - - - - - + {allChecked && }, className: `${TH} w-px` }, + { content: t('alerts.table.alert'), className: TH }, + { content: t('alerts.table.severity'), className: `${TH} text-center` }, + { content: t('alerts.table.time'), className: `${TH} text-center` }, + ]} + widths={widths} + startDrag={startDrag} + className="sticky top-0 z-10 bg-muted/90 text-[10px] uppercase tracking-wider text-muted-foreground" + rowClassName="border-b border-border" + /> ) } diff --git a/frontend/src/features/incidents/components/incidents-table.tsx b/frontend/src/features/incidents/components/incidents-table.tsx index 8b32b75d5..c08ee30a6 100644 --- a/frontend/src/features/incidents/components/incidents-table.tsx +++ b/frontend/src/features/incidents/components/incidents-table.tsx @@ -1,4 +1,6 @@ import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { cn } from '@/shared/lib/utils' import { useDateFormat } from '@/shared/lib/datetime' import { SEV_TONE, TABLE_COLS, sevKey } from '../lib/incident-meta' @@ -9,25 +11,33 @@ import { IncidentAssignee } from './incident-assignee' export function IncidentsTable({ incidents, onOpen }: { incidents: Incident[]; onOpen: (i: Incident) => void }) { const { t } = useTranslation() const df = useDateFormat() + const incidentsHeaders = [ + t('incidents.table.name'), + t('incidents.table.status'), + t('incidents.table.severity'), + t('incidents.table.assignee'), + t('incidents.table.alerts'), + t('incidents.table.created'), + ] + const { template: tableCols, startDrag } = useResizableColumns(TABLE_COLS, { + min: colMins(incidentsHeaders), + storageKey: 'incidents-table-columns', + }) return ( -
-
-
{t('incidents.table.name')}
-
{t('incidents.table.status')}
-
{t('incidents.table.severity')}
-
{t('incidents.table.assignee')}
-
{t('incidents.table.alerts')}
-
{t('incidents.table.created')}
-
+
+ {incidents.map((i) => ( )} + {i < columns.length - 1 && resizeHandle(i + 3)}
)) )} @@ -121,6 +140,7 @@ function ResultRowImpl({ doc, columns, autoColumns = [], + tableCols, expanded, onToggle, onAdd, @@ -130,6 +150,7 @@ function ResultRowImpl({ doc: LogDocument columns: string[] autoColumns?: string[] + tableCols?: string expanded: boolean onToggle: (index: number) => void onAdd?: (f: FilterType) => void @@ -148,10 +169,10 @@ function ResultRowImpl({
onToggle(index)} className={cn( - 'grid cursor-pointer items-center gap-3 border-b border-border/40 px-4 py-1 text-xs leading-tight transition-colors last:border-b-0', + 'grid w-max min-w-full cursor-pointer items-center gap-3 border-b border-border/40 px-4 py-1 text-xs leading-tight transition-colors last:border-b-0', expanded ? 'bg-muted/30' : 'hover:bg-muted/20' )} - style={{ gridTemplateColumns: gridTemplate(columns, autoColumns) }} + style={{ gridTemplateColumns: tableCols ?? gridTemplate(columns, autoColumns) }} > diff --git a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx index 0559db204..87eaa3b9f 100644 --- a/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx +++ b/frontend/src/features/parsing-filters/pages/ParsingFiltersPage.tsx @@ -7,6 +7,8 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { ResizableTableHeader } from '@/shared/components/ui/resizable-table-header' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { pipelinesHttpService } from '@/features/data-processing/services/data-processing-http.service' import type { Pipeline } from '@/features/data-processing/types/data-processing.types' import { TestPlaygroundModal } from '@/features/playground/components/TestPlaygroundModal' @@ -19,6 +21,7 @@ const TABS: Tab[] = ['all', 'active', 'inactive', 'system', 'user'] const TH = 'whitespace-nowrap px-3 py-2.5 text-left align-middle font-medium' const TD = 'whitespace-nowrap px-3 py-2.5 align-middle' +const PARSING_FILTERS_TABLE_COLS = [320, 220, 120, 90, 48, 48] // The name the engine matches on: the file's base name without its extension. function pipelineIdentity(relPath: string): string { @@ -44,6 +47,18 @@ export function ParsingFiltersPage() { const [editing, setEditing] = useState<{ filter: Pipeline; creating: boolean } | null>(null) const [preparingNew, setPreparingNew] = useState(false) const [showTestModal, setShowTestModal] = useState(false) + const parsingFiltersHeaders = [ + t('parsingFilters.cols.filter'), + t('parsingFilters.cols.dataTypes'), + t('parsingFilters.cols.type'), + t('parsingFilters.cols.active'), + 48, + 48, + ] + const { widths, startDrag } = useResizableColumns(PARSING_FILTERS_TABLE_COLS, { + min: colMins(parsingFiltersHeaders), + storageKey: 'parsing-filters-table-columns', + }) // Deep-link: ?dataType= pre-filters the list to that data type // (e.g. opened from an integration's "Filters" button). @@ -237,17 +252,21 @@ export function ParsingFiltersPage() {
-
- - - {t('alerts.table.alert')}{t('alerts.table.severity')}{t('alerts.table.time')}
- - - - - - - - +
{t('parsingFilters.cols.filter')}{t('parsingFilters.cols.dataTypes')}{t('parsingFilters.cols.type')}{t('parsingFilters.cols.active')} - -
+ {loading && items.length === 0 ? (
diff --git a/frontend/src/features/soar/components/ExecutionsView.tsx b/frontend/src/features/soar/components/ExecutionsView.tsx index b4cb124a6..ce7eba8a1 100644 --- a/frontend/src/features/soar/components/ExecutionsView.tsx +++ b/frontend/src/features/soar/components/ExecutionsView.tsx @@ -13,6 +13,8 @@ import { cn } from "@/shared/lib/utils"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; import { InfiniteScrollSentinel } from "@/shared/components/ui/infinite-scroll"; +import { ResizableGridHeader } from "@/shared/components/ui/resizable-grid-header"; +import { colMins, useResizableColumns } from "@/shared/hooks/useResizableColumns"; import { presetRange, resolveRange, @@ -48,7 +50,7 @@ const STATUSES: (ExecutionStatus | "all")[] = [ ]; const ORIGINS: (ExecutionOrigin | "all")[] = ["all", "FLOW", "MANUAL"]; const COLS = - "90px 100px minmax(160px,1.2fr) minmax(180px,1.6fr) 120px 150px 60px"; + [90, 100, "minmax(160px,1.2fr)", "minmax(180px,1.6fr)", 120, 150, 60]; const STATUS_META: Record< ExecutionStatus, @@ -78,6 +80,19 @@ export function ExecutionsView() { // each node's position in the flow's DAG (its ancestor chain) in the Node // column — the flow itself carries no per-run state, only its shape. const [runFlows, setRunFlows] = useState>({}); + const executionsHeaders = [ + t("soar.executions.cols.status"), + t("soar.executions.cols.node"), + t("soar.executions.cols.flow"), + t("soar.executions.cols.command"), + t("soar.executions.cols.agent"), + t("soar.executions.cols.date"), + t("soar.executions.cols.retries"), + ]; + const { template: tableCols, startDrag } = useResizableColumns(COLS, { + min: colMins(executionsHeaders), + storageKey: "soar-executions-table-columns", + }); const [page, setPage] = useState(0); const [pageSize] = useState(50); const [loading, setLoading] = useState(true); @@ -264,19 +279,14 @@ export function ExecutionsView() {
-
-
{t("soar.executions.cols.status")}
-
{t("soar.executions.cols.node")}
-
{t("soar.executions.cols.flow")}
-
{t("soar.executions.cols.command")}
-
{t("soar.executions.cols.agent")}
-
{t("soar.executions.cols.date")}
-
{t("soar.executions.cols.retries")}
-
-
+
+ {loading && items.length === 0 ? (
{" "} @@ -306,6 +316,7 @@ export function ExecutionsView() { key={e.id} e={e} flow={e.rulePath ? runFlows[e.rulePath] : undefined} + tableCols={tableCols} df={df} t={t} /> @@ -327,11 +338,13 @@ export function ExecutionsView() { function ExecutionRow({ e, flow, + tableCols, df, t, }: { e: Execution; flow?: Flow; + tableCols: string; df: ReturnType; t: ReturnType["t"]; }) { @@ -357,8 +370,8 @@ function ExecutionRow({ return (
>({}) + const flowsHeaders = [ + t('soar.cols.flow'), + t('soar.cols.platform'), + t('soar.cols.conditions'), + t('soar.cols.commands'), + t('soar.cols.lastRun'), + t('soar.cols.active'), + '', + ] + const { widths, startDrag } = useResizableColumns(FLOWS_TABLE_COLS, { + min: [...colMins(flowsHeaders.slice(0, -1)), 60], + storageKey: 'soar-flows-table-columns', + }) + const flowsTableWidth = widths.reduce( + (total, width) => total + (typeof width === 'number' ? width : 0), + 0, + ) const openStartFrom = () => { setStarting(true) @@ -190,18 +210,27 @@ export function FlowsPage() {
- - - - - - - - - - - +
{t('soar.cols.flow')}{t('soar.cols.platform')}{t('soar.cols.conditions')}{t('soar.cols.commands')}{t('soar.cols.lastRun')}{t('soar.cols.active')} -
+ + {widths.map((width, index) => ( + + ))} + + {loading && items.length === 0 ? ( diff --git a/frontend/src/features/team/pages/TeamPage.tsx b/frontend/src/features/team/pages/TeamPage.tsx index b3e4fe200..81726620a 100644 --- a/frontend/src/features/team/pages/TeamPage.tsx +++ b/frontend/src/features/team/pages/TeamPage.tsx @@ -26,6 +26,8 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { ColumnResizeHandle } from '@/shared/components/ui/column-resize-handle' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { SUPPORTED_LANGUAGES } from '@/shared/i18n' import { rolesHttpService, TeamHttpError, usersHttpService } from '../services/team-http.service' import type { @@ -40,6 +42,7 @@ import type { } from '../types/team.types' const PAGE_SIZE = 20 +const MEMBER_COLS = ['1.7fr', '1fr', 90, 130, 40] /* Roles & permissions arrive from the backend as English DB strings. Translate the * stable identifiers (role name, permission resource/action) with a fallback to the @@ -160,6 +163,18 @@ function MembersView({ roles }: { roles: Role[] }) { const [openId, setOpenId] = useState(null) const [inviteOpen, setInviteOpen] = useState(false) + const memberHeaders = [ + t('team.members.colUser'), + t('team.members.colRoles'), + t('team.members.col2fa'), + t('team.members.colStatus'), + '', + ] + const memberMins = [...colMins(memberHeaders.slice(0, -1)), 40] + const { template: memberCols, startDrag } = useResizableColumns(MEMBER_COLS, { + min: memberMins, + storageKey: 'team-members-table-columns', + }) // Debounce the search box, and reset to page 1 when the query changes. useEffect(() => { @@ -207,16 +222,17 @@ function MembersView({ roles }: { roles: Role[] }) { -
+
-
{t('team.members.colUser')}
-
{t('team.members.colRoles')}
-
{t('team.members.col2fa')}
-
{t('team.members.colStatus')}
-
+ {memberHeaders.map((header, index, headers) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
{loading && (!users || users.length === 0) && ( @@ -245,7 +261,7 @@ function MembersView({ roles }: { roles: Role[] }) { )} {users && users.length > 0 && - users.map((u) => setOpenId(u.id)} />)} + users.map((u) => setOpenId(u.id)} />)}
{users && users.length > 0 && ( @@ -280,18 +296,16 @@ function MembersView({ roles }: { roles: Role[] }) { ) } -const MEMBER_COLS = '1.7fr 1fr 90px 130px 40px' - -function MemberRow({ user: u, onOpen }: { user: UserListItem; onOpen: () => void }) { +function MemberRow({ user: u, tableCols, onOpen }: { user: UserListItem; tableCols: string; onOpen: () => void }) { const { t } = useTranslation() return (
diff --git a/frontend/src/features/threat-intel/components/FeedRow.tsx b/frontend/src/features/threat-intel/components/FeedRow.tsx index 5fa8f2a1f..faf965426 100644 --- a/frontend/src/features/threat-intel/components/FeedRow.tsx +++ b/frontend/src/features/threat-intel/components/FeedRow.tsx @@ -5,18 +5,17 @@ import { feedAccuracyMeta, feedTypeTone } from './utils/severity-style' interface FeedRowProps { feed: ThreatFeed + tableCols: string } -const FEED_COLS = '12px 1fr 160px 140px' - -export function FeedRow({ feed }: FeedRowProps) { +export function FeedRow({ feed, tableCols }: FeedRowProps) { const { t } = useTranslation() const acc = feedAccuracyMeta(feed.accuracy) return (
{feed.name}
diff --git a/frontend/src/features/threat-intel/components/FeedsHeader.tsx b/frontend/src/features/threat-intel/components/FeedsHeader.tsx index 00da0697a..d3382d951 100644 --- a/frontend/src/features/threat-intel/components/FeedsHeader.tsx +++ b/frontend/src/features/threat-intel/components/FeedsHeader.tsx @@ -1,18 +1,14 @@ import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import type { useResizableColumns } from '@/shared/hooks/useResizableColumns' -const FEED_COLS = '12px 1fr 160px 140px' - -export function FeedsHeader() { +export function FeedsHeader({ tableCols, startDrag }: { tableCols: string; startDrag: ReturnType['startDrag'] }) { const { t } = useTranslation() return ( -
-
-
{t('threatIntel.feeds.table.name')}
-
{t('threatIntel.feeds.table.type')}
-
{t('threatIntel.feeds.table.accuracy')}
-
+ ) } diff --git a/frontend/src/features/threat-intel/components/FeedsList.tsx b/frontend/src/features/threat-intel/components/FeedsList.tsx index ec8d8159a..9c7cd96e4 100644 --- a/frontend/src/features/threat-intel/components/FeedsList.tsx +++ b/frontend/src/features/threat-intel/components/FeedsList.tsx @@ -1,18 +1,30 @@ import { useTranslation } from 'react-i18next' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { useTiFeeds } from '../hooks/use-ti-feeds' import { FeedRow } from './FeedRow' import { FeedsHeader } from './FeedsHeader' +const FEED_COLS = [12, '1fr', 160, 140] + export function FeedsList() { const { t } = useTranslation() const { data, isLoading } = useTiFeeds() + const feedLabelMins = colMins([ + t('threatIntel.feeds.table.name'), + t('threatIntel.feeds.table.type'), + t('threatIntel.feeds.table.accuracy'), + ]) + const { template: tableCols, startDrag } = useResizableColumns(FEED_COLS, { + min: [12, ...feedLabelMins], + storageKey: 'threat-intel-feed-table-columns', + }) if (data?.kind === 'not-configured') return null if (isLoading) { return ( -
- +
+ {Array.from({ length: 5 }).map((_, i) => (
- +
+ {feeds.map((feed) => ( - + ))}
) diff --git a/frontend/src/features/threat-intel/components/IocRow.tsx b/frontend/src/features/threat-intel/components/IocRow.tsx index e3cfc4108..b8daad2cb 100644 --- a/frontend/src/features/threat-intel/components/IocRow.tsx +++ b/frontend/src/features/threat-intel/components/IocRow.tsx @@ -7,12 +7,11 @@ import { absTimestamp } from './utils/time-format' interface IocRowProps { ioc: EntitySummary + tableCols: string onOpen: (id: string) => void } -const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px' - -export function IocRow({ ioc, onOpen }: IocRowProps) { +export function IocRow({ ioc, tableCols, onOpen }: IocRowProps) { const { t } = useTranslation() const tone = reputationTone(ioc.reputation) const rep = REPUTATION_STYLE[tone] @@ -22,8 +21,8 @@ export function IocRow({ ioc, onOpen }: IocRowProps) { return (
onOpen(ioc.id)} - className="group grid cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs hover:bg-muted/40 last:border-b-0" - style={{ gridTemplateColumns: IOC_COLS }} + className="group grid w-max min-w-full cursor-pointer items-center gap-3 border-b border-border/60 px-4 py-2.5 text-xs hover:bg-muted/40 last:border-b-0" + style={{ gridTemplateColumns: tableCols }} >
diff --git a/frontend/src/features/threat-intel/components/IocTable.tsx b/frontend/src/features/threat-intel/components/IocTable.tsx index 2b565c21b..ffa054fc4 100644 --- a/frontend/src/features/threat-intel/components/IocTable.tsx +++ b/frontend/src/features/threat-intel/components/IocTable.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' +import { ResizableGridHeader } from '@/shared/components/ui/resizable-grid-header' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import type { EntitySummary } from '../domain/threat-intel.types' import { Pagination } from '@/shared/components/ui/pagination' import { IocRow } from './IocRow' @@ -17,7 +19,7 @@ interface IocTableProps { onLoadMore?: () => void } -const IOC_COLS = '4px 90px 1fr 130px 1fr 110px 36px' +const IOC_COLS = [4, 90, '1fr', 130, '1fr', 110, 36] export function IocTable({ iocs, @@ -34,6 +36,20 @@ export function IocTable({ const { t } = useTranslation() const scrollRef = useRef(null) const sentinelRef = useRef(null) + const iocHeaders = [ + '', + t('threatIntel.iocs.table.type'), + t('threatIntel.iocs.table.indicator'), + t('threatIntel.iocs.table.reputation'), + t('threatIntel.iocs.table.tags'), + t('threatIntel.iocs.table.lastSeen'), + '', + ] + const iocLabelMins = colMins(iocHeaders.slice(1, -1)) + const { template: tableCols, startDrag } = useResizableColumns(IOC_COLS, { + min: [4, ...iocLabelMins, 36], + storageKey: 'threat-intel-ioc-table-columns', + }) useEffect(() => { const node = sentinelRef.current @@ -52,21 +68,16 @@ export function IocTable({ return ( <>
-
-
-
{t('threatIntel.iocs.table.type')}
-
{t('threatIntel.iocs.table.indicator')}
-
{t('threatIntel.iocs.table.reputation')}
-
{t('threatIntel.iocs.table.tags')}
-
{t('threatIntel.iocs.table.lastSeen')}
-
-
-
+
+ {iocs.map((ioc) => ( - onOpen(ioc.id)} /> + onOpen(ioc.id)} /> ))} {!isLoading && iocs.length === 0 && (
{t('threatIntel.iocs.empty')}
diff --git a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx index f54a97a1a..e30ef051b 100644 --- a/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx +++ b/frontend/src/features/user-auditor/pages/UserAuditorPage.tsx @@ -27,6 +27,8 @@ import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/components/ui/button' import { Input } from '@/shared/components/ui/input' import { InfiniteScrollSentinel } from '@/shared/components/ui/infinite-scroll' +import { ColumnResizeHandle } from '@/shared/components/ui/column-resize-handle' +import { colMins, useResizableColumns } from '@/shared/hooks/useResizableColumns' import { CustomFilterBar } from '@/shared/components/filters/CustomFilterBar' import type { CustomFilter, @@ -38,6 +40,7 @@ import type { ADUser, ADUserSource, ADUserStats, ADUserStatus } from '../types/a const SIZE = 50 const STALE_MS = 30 * 86_400_000 +const LIST_COLS = [32, '1fr', '1.3fr', 110, 110, 110, 90, 36] type ViewId = 'all' | ADUserSource const VIEW_IDS: ViewId[] = ['all', 'windows', 'linux'] @@ -105,6 +108,18 @@ export function UserAuditorPage() { const [error, setError] = useState(false) const [stats, setStats] = useState(null) const [openUser, setOpenUser] = useState(null) + const listLabelMins = colMins([ + t('userAuditor.list.account'), + t('userAuditor.list.identity'), + t('userAuditor.list.status'), + t('userAuditor.list.lastLogon'), + t('userAuditor.list.lastSeen'), + t('userAuditor.list.tenant'), + ]) + const { template: listCols, startDrag } = useResizableColumns(LIST_COLS, { + min: [32, ...listLabelMins, 36], + storageKey: 'user-auditor-table-columns', + }) const filterFields: FilterFieldDef[] = [ { field: 'status', label: t('userAuditor.filterFields.status') }, @@ -238,12 +253,12 @@ export function UserAuditorPage() {
) : layout === 'list' ? ( -
- +
+ {loading && users.length === 0 ? ( ) : ( - users.map((u) => setOpenUser(u)} t={t} />) + users.map((u) => setOpenUser(u)} t={t} />) )} {!loading && users.length === 0 && (
@@ -483,22 +498,28 @@ function Toolbar({ /* ─── List ─────────────────────────────────────────────────────────────── */ -const LIST_COLS = '32px 1fr 1.3fr 110px 110px 110px 90px 36px' - -function ListHeader({ t }: { t: TFunction }) { +function ListHeader({ t, tableCols, startDrag }: { t: TFunction; tableCols: string; startDrag: ReturnType['startDrag'] }) { + const headers = [ + '', + t('userAuditor.list.account'), + t('userAuditor.list.identity'), + t('userAuditor.list.status'), + t('userAuditor.list.lastLogon'), + t('userAuditor.list.lastSeen'), + t('userAuditor.list.tenant'), + '', + ] return (
-
-
{t('userAuditor.list.account')}
-
{t('userAuditor.list.identity')}
-
{t('userAuditor.list.status')}
-
{t('userAuditor.list.lastLogon')}
-
{t('userAuditor.list.lastSeen')}
-
{t('userAuditor.list.tenant')}
-
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))}
) } @@ -511,14 +532,14 @@ function LoadingRows() { ) } -function UserListRow({ user, onOpen, t }: { user: ADUser; onOpen: () => void; t: TFunction }) { +function UserListRow({ user, tableCols, onOpen, t }: { user: ADUser; tableCols: string; onOpen: () => void; t: TFunction }) { const status = statusOf(user) const identity = accountIdentity(user) return (
diff --git a/frontend/src/shared/components/ui/column-resize-handle.tsx b/frontend/src/shared/components/ui/column-resize-handle.tsx new file mode 100644 index 000000000..2f327f474 --- /dev/null +++ b/frontend/src/shared/components/ui/column-resize-handle.tsx @@ -0,0 +1,28 @@ +import type { MouseEvent } from 'react' +import { GripVertical } from 'lucide-react' +import { cn } from '@/shared/lib/utils' + +interface Props { + onMouseDown: (e: MouseEvent) => void + className?: string +} + +export function ColumnResizeHandle({ onMouseDown, className }: Props) { + return ( + e.stopPropagation()} + className={cn( + 'group absolute right-0 top-0 z-20 flex h-full w-3 cursor-col-resize select-none items-center justify-center touch-none', + 'bg-transparent hover:bg-primary/20 active:bg-primary/30', + 'transition-colors', + className, + )} + aria-label="Resize column" + > + + + ) +} diff --git a/frontend/src/shared/components/ui/resizable-grid-header.tsx b/frontend/src/shared/components/ui/resizable-grid-header.tsx new file mode 100644 index 000000000..42b8ee7cc --- /dev/null +++ b/frontend/src/shared/components/ui/resizable-grid-header.tsx @@ -0,0 +1,37 @@ +import type { ReactNode } from 'react' +import type { useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { cn } from '@/shared/lib/utils' +import { ColumnResizeHandle } from './column-resize-handle' + +interface ResizableGridHeaderProps { + headers: ReactNode[] + tableCols: string + startDrag: ReturnType['startDrag'] + className?: string + cellClassName?: string +} + +export function ResizableGridHeader({ + headers, + tableCols, + startDrag, + className, + cellClassName, +}: ResizableGridHeaderProps) { + return ( +
+ {headers.map((header, index) => ( +
+ {header} + {index < headers.length - 1 && } +
+ ))} +
+ ) +} diff --git a/frontend/src/shared/components/ui/resizable-table-header.tsx b/frontend/src/shared/components/ui/resizable-table-header.tsx new file mode 100644 index 000000000..5df916545 --- /dev/null +++ b/frontend/src/shared/components/ui/resizable-table-header.tsx @@ -0,0 +1,50 @@ +import type { CSSProperties, ReactNode } from 'react' +import type { ColSize, useResizableColumns } from '@/shared/hooks/useResizableColumns' +import { cn } from '@/shared/lib/utils' +import { ColumnResizeHandle } from './column-resize-handle' + +export interface ResizableTableHeaderCell { + content: ReactNode + className?: string +} + +interface ResizableTableHeaderProps { + cells: ResizableTableHeaderCell[] + widths: ColSize[] + startDrag: ReturnType['startDrag'] + className?: string + rowClassName?: string + cellClassName?: string +} + +const widthStyle = (width: ColSize | undefined): CSSProperties | undefined => { + if (width == null) return undefined + return { width: typeof width === 'number' ? `${width}px` : width } +} + +export function ResizableTableHeader({ + cells, + widths, + startDrag, + className, + rowClassName, + cellClassName, +}: ResizableTableHeaderProps) { + return ( +
+ + {cells.map((cell, index) => ( + + ))} + + + ) +} diff --git a/frontend/src/shared/hooks/useResizableColumns.test.ts b/frontend/src/shared/hooks/useResizableColumns.test.ts new file mode 100644 index 000000000..e9d67756b --- /dev/null +++ b/frontend/src/shared/hooks/useResizableColumns.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from 'vitest' +import { act, renderHook } from '@testing-library/react' +import { useResizableColumns } from './useResizableColumns' + +describe('useResizableColumns', () => { + test('initial template keeps px tracks fixed and floors flex tracks with default min', () => { + const { result } = renderHook(() => useResizableColumns([32, '1fr', 120])) + expect(result.current.template).toBe('32px minmax(40px, 1fr) 120px') + }) + + test('drag updates the column width and rebuilds the template', () => { + const { result } = renderHook(() => useResizableColumns([100, '1fr', 60])) + act(() => { + result.current.setWidths((prev) => { + const next = prev.slice() + next[0] = 180 + return next + }) + }) + expect(result.current.widths[0]).toBe(180) + expect(result.current.template).toBe('180px minmax(40px, 1fr) 60px') + }) + + test('min clamp is enforced when consumers write below it via drag', () => { + // The clamp lives in the drag handler; sanity-check the default min is 40. + const { result } = renderHook(() => useResizableColumns([100])) + act(() => { + result.current.setWidths([10]) + }) + // setWidths trusts the caller; the drag handler applies min. This asserts + // that the hook doesn't silently rewrite direct sets. + expect(result.current.widths[0]).toBe(10) + expect(result.current.template).toBe('10px') + }) + + test('per-column min array is used to floor flex tracks in the template', () => { + const { result } = renderHook(() => + useResizableColumns([32, '1fr', '1fr'], { min: [32, 120, 80] }), + ) + expect(result.current.template).toBe('32px minmax(120px, 1fr) minmax(80px, 1fr)') + expect(result.current.mins).toEqual([32, 120, 80]) + }) +}) diff --git a/frontend/src/shared/hooks/useResizableColumns.ts b/frontend/src/shared/hooks/useResizableColumns.ts new file mode 100644 index 000000000..4e22f7b56 --- /dev/null +++ b/frontend/src/shared/hooks/useResizableColumns.ts @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { MouseEvent as ReactMouseEvent } from 'react' + +export type ColSize = string | number + +interface Opts { + min?: number | number[] + storageKey?: string +} + +const DEFAULT_MIN = 40 + +const minAt = (min: number | number[] | undefined, index: number): number => { + if (min == null) return DEFAULT_MIN + if (typeof min === 'number') return min + return min[index] ?? DEFAULT_MIN +} + +// Per-column min widths derived from the header label so a drag/resize can't +// crop the label. Pass a number in the labels array for icon/checkbox columns +// where no label exists — that number is used verbatim as the min. +// ponytail: 15px/char + 30px padding is a rough uppercase-heading approximation; +// tune the charPx/padding options if a specific font stack diverges. +export function colMins( + labels: Array, + opts: { floor?: number; charPx?: number; padding?: number } = {}, +): number[] { + const { floor = 60, charPx = 15, padding = 30 } = opts + return labels.map((l) => + typeof l === 'number' ? l : Math.max(floor, Math.round(l.length * charPx) + padding), + ) +} + +export function useResizableColumns(initial: ColSize[], opts: Opts = {}) { + const min = opts.min + const storageKey = opts.storageKey + const [widths, setWidths] = useState(initial) + const dragRef = useRef<{ index: number; startX: number; startW: number; measured: number[] } | null>(null) + + useEffect(() => { + setWidths(initial) + }, [initial.length, storageKey]) + + useEffect(() => { + const onMove = (e: globalThis.MouseEvent) => { + const d = dragRef.current + if (!d) return + e.preventDefault() + const w = Math.max(minAt(min, d.index), d.startW + (e.clientX - d.startX)) + setWidths((prev) => { + const next = d.measured.length === prev.length ? d.measured.slice() : prev.slice() + next[d.index] = w + return next + }) + } + const onUp = () => { + if (!dragRef.current) return + dragRef.current = null + document.body.style.cursor = '' + document.body.style.userSelect = '' + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + return () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + }, [min]) + + const startDrag = useCallback( + (index: number) => (e: ReactMouseEvent) => { + e.preventDefault() + e.stopPropagation() + const handle = e.currentTarget as HTMLElement + const cell = handle.closest('[data-resizable-col]') as HTMLElement | null ?? handle.parentElement + const row = cell?.parentElement + const measured = row + ? Array.from(row.children) + .filter((child): child is HTMLElement => child instanceof HTMLElement && child.hasAttribute('data-resizable-col')) + .map((child, i) => Math.max(minAt(min, i), child.getBoundingClientRect().width)) + : [] + const startW = measured[index] ?? cell?.getBoundingClientRect().width ?? minAt(min, index) + if (measured.length > 0) setWidths(measured) + dragRef.current = { index, startX: e.clientX, startW, measured } + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + }, + [min], + ) + + const mins = widths.map((_, i) => minAt(min, i)) + // ponytail: wrap simple fr tracks in minmax so a narrow viewport can't + // collapse labels below their per-column min. Fixed px tracks and strings + // that already declare their own function (e.g. `minmax(120px, 1fr)`) are + // passed through unchanged. + const template = widths + .map((w, i) => { + if (typeof w === 'number') return `${w}px` + return /^[\d.]+fr$/.test(w.trim()) ? `minmax(${mins[i]}px, ${w})` : w + }) + .join(' ') + + return { widths, template, setWidths, startDrag, mins } +}
{t('soar.loading')}
+ {cell.content} + {index < cells.length - 1 && } +