From 5074a0274ac1bc0cbe50916035192ee6ac8ed47e Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 23:00:32 +0000 Subject: [PATCH] feat: add viewer filter and detail components --- .../misc/viewers/audit-log/Filters.tsx | 188 ++++++++++++++++++ .../misc/viewers/audit-log/LogTable.tsx | 124 ++++++++++++ .../misc/viewers/model-list/DetailsDrawer.tsx | 73 +++++++ .../misc/viewers/model-list/ModelFilters.tsx | 82 ++++++++ .../misc/viewers/model-list/ModelTable.tsx | 148 ++++++++++++++ 5 files changed, 615 insertions(+) create mode 100644 frontends/nextjs/src/components/misc/viewers/audit-log/Filters.tsx create mode 100644 frontends/nextjs/src/components/misc/viewers/audit-log/LogTable.tsx create mode 100644 frontends/nextjs/src/components/misc/viewers/model-list/DetailsDrawer.tsx create mode 100644 frontends/nextjs/src/components/misc/viewers/model-list/ModelFilters.tsx create mode 100644 frontends/nextjs/src/components/misc/viewers/model-list/ModelTable.tsx diff --git a/frontends/nextjs/src/components/misc/viewers/audit-log/Filters.tsx b/frontends/nextjs/src/components/misc/viewers/audit-log/Filters.tsx new file mode 100644 index 000000000..7dc47373d --- /dev/null +++ b/frontends/nextjs/src/components/misc/viewers/audit-log/Filters.tsx @@ -0,0 +1,188 @@ +import { useMemo } from 'react' +import { Badge, Button, Input, Label, Switch, ToggleGroup, ToggleGroupItem } from '@/components/ui' +import type { OperationType, ResourceType } from '@/lib/security/secure-db/types' +import { FunnelSimple, MagnifyingGlass, X } from '@phosphor-icons/react' + +interface AuditLogFiltersProps { + searchTerm: string + onSearchChange: (value: string) => void + selectedOperations: OperationType[] + onOperationsChange: (operations: OperationType[]) => void + selectedResources: ResourceType[] + onResourcesChange: (resources: ResourceType[]) => void + showFailuresOnly: boolean + onShowFailuresChange: (value: boolean) => void + availableOperations?: OperationType[] + availableResources?: ResourceType[] + onReset?: () => void +} + +const DEFAULT_OPERATIONS: OperationType[] = ['CREATE', 'READ', 'UPDATE', 'DELETE'] +const DEFAULT_RESOURCES: ResourceType[] = [ + 'user', + 'workflow', + 'luaScript', + 'pageConfig', + 'modelSchema', + 'comment', + 'componentNode', + 'componentConfig', + 'cssCategory', + 'dropdownConfig', + 'tenant', + 'powerTransfer', + 'smtpConfig', + 'credential' +] + +export function AuditLogFilters({ + searchTerm, + onSearchChange, + selectedOperations, + onOperationsChange, + selectedResources, + onResourcesChange, + showFailuresOnly, + onShowFailuresChange, + availableOperations, + availableResources, + onReset +}: AuditLogFiltersProps) { + const operationOptions = availableOperations || DEFAULT_OPERATIONS + const resourceOptions = useMemo( + () => availableResources || DEFAULT_RESOURCES, + [availableResources] + ) + + return ( +
+
+ + Filters +
+ +
+
+ +
+ + onSearchChange(event.target.value)} + className="pl-9" + /> +
+
+ +
+
+ +

Show only unsuccessful operations

+
+ +
+
+ +
+
+ + onOperationsChange(value as OperationType[])} + className="flex flex-wrap gap-2" + > + {operationOptions.map((operation) => ( + + {operation} + + ))} + +
+ +
+
+ + Select one or more +
+
+ {resourceOptions.map((resource) => { + const isSelected = selectedResources.includes(resource) + return ( + + ) + })} +
+
+
+ +
+
+ {selectedOperations.length > 0 && ( + + Operations: + {selectedOperations.join(', ')} + + )} + {selectedResources.length > 0 && ( + + Resources: + {selectedResources.join(', ')} + + )} + {showFailuresOnly && ( + + + Failures + + )} + {selectedOperations.length === 0 && + selectedResources.length === 0 && + !showFailuresOnly && ( + No filters applied + )} +
+ + {onReset && ( + + )} +
+
+ ) +} + +function WarningIcon() { + return +} diff --git a/frontends/nextjs/src/components/misc/viewers/audit-log/LogTable.tsx b/frontends/nextjs/src/components/misc/viewers/audit-log/LogTable.tsx new file mode 100644 index 000000000..21cbbdae7 --- /dev/null +++ b/frontends/nextjs/src/components/misc/viewers/audit-log/LogTable.tsx @@ -0,0 +1,124 @@ +import { Badge, Card, CardContent, CardHeader, CardTitle, ScrollArea, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui' +import type { AuditLog, OperationType, ResourceType } from '@/lib/security/secure-db/types' +import { ArrowDown, ArrowUp, ShieldCheck, User as UserIcon, WarningCircle } from '@phosphor-icons/react' + +interface LogTableProps { + logs: AuditLog[] + sortField?: keyof AuditLog | null + sortDirection?: 'asc' | 'desc' + onSortChange?: (field: keyof AuditLog) => void +} + +const OPERATION_COLORS: Record = { + CREATE: 'bg-green-100 text-green-800', + READ: 'bg-blue-100 text-blue-800', + UPDATE: 'bg-yellow-100 text-yellow-800', + DELETE: 'bg-red-100 text-red-800' +} + +const RESOURCE_ICONS: Partial> = { + user: , + credential: +} + +export function LogTable({ logs, sortField, sortDirection = 'asc', onSortChange }: LogTableProps) { + const handleSort = (field: keyof AuditLog) => { + onSortChange?.(field) + } + + return ( + + + Audit Log + + + + + + + + User + Operation + Resource + Status + Details + + + + {logs.length === 0 ? ( + + + No audit events to display + + + ) : ( + logs.map((log) => ( + + + {new Date(log.timestamp).toLocaleString()} + + {log.username} + + {log.operation} + + + {RESOURCE_ICONS[log.resource] || } + {log.resource} + + + {log.success ? ( + + + Success + + ) : ( + + + Failed + + )} + + + {log.errorMessage || '—'} + + + )) + )} + +
+
+
+
+ ) +} + +interface SortableHeaderProps { + field: keyof AuditLog + label: string + sortField?: keyof AuditLog | null + sortDirection?: 'asc' | 'desc' + onSort?: (field: keyof AuditLog) => void +} + +function SortableHeader({ field, label, sortField, sortDirection = 'asc', onSort }: SortableHeaderProps) { + const isActive = sortField === field + const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown + + return ( + onSort?.(field)} + > +
+ {label} + {isActive && } +
+
+ ) +} diff --git a/frontends/nextjs/src/components/misc/viewers/model-list/DetailsDrawer.tsx b/frontends/nextjs/src/components/misc/viewers/model-list/DetailsDrawer.tsx new file mode 100644 index 000000000..07bc37865 --- /dev/null +++ b/frontends/nextjs/src/components/misc/viewers/model-list/DetailsDrawer.tsx @@ -0,0 +1,73 @@ +import { Badge, Button, ScrollArea, Separator, Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui' +import type { ModelSchema } from '@/lib/schema-types' +import { getFieldLabel } from '@/lib/schema-utils' + +interface DetailsDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + record: any | null + model: ModelSchema +} + +export function DetailsDrawer({ open, onOpenChange, record, model }: DetailsDrawerProps) { + const renderValue = (fieldName: string) => { + const field = model.fields.find((item) => item.name === fieldName) + if (!field) return null + const value = record?.[fieldName] + + if (value === null || value === undefined || value === '') { + return Not provided + } + + switch (field.type) { + case 'boolean': + return value ? ( + + Yes + + ) : ( + No + ) + case 'date': + case 'datetime': + return new Date(value).toLocaleString() + case 'json': + return
{JSON.stringify(value, null, 2)}
+ default: + return {String(value)} + } + } + + return ( + + + + {model.label || model.name} details + Review the full record and its attributes. + + + + + +
+ {model.fields.map((field) => ( +
+

{getFieldLabel(field)}

+
{renderValue(field.name)}
+ {field.helpText && ( +

{field.helpText}

+ )} +
+ ))} +
+
+ + + + + + +
+
+ ) +} diff --git a/frontends/nextjs/src/components/misc/viewers/model-list/ModelFilters.tsx b/frontends/nextjs/src/components/misc/viewers/model-list/ModelFilters.tsx new file mode 100644 index 000000000..465f5605d --- /dev/null +++ b/frontends/nextjs/src/components/misc/viewers/model-list/ModelFilters.tsx @@ -0,0 +1,82 @@ +import { Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui' +import type { FieldSchema, ModelSchema } from '@/lib/schema-types' +import { MagnifyingGlass } from '@phosphor-icons/react' + +interface ModelFiltersProps { + model: ModelSchema + filters: Record + searchTerm: string + onSearchChange: (value: string) => void + onFilterChange: (field: string, value: any) => void +} + +function getFilterableFields(model: ModelSchema): FieldSchema[] { + if (model.listFilter) { + return model.fields.filter((field) => model.listFilter?.includes(field.name)) + } + return model.fields.filter((field) => field.type === 'select' || field.type === 'boolean') +} + +export function ModelFilters({ model, filters, searchTerm, onSearchChange, onFilterChange }: ModelFiltersProps) { + const filterFields = getFilterableFields(model) + + return ( +
+
+ +
+ + onSearchChange(event.target.value)} + className="pl-9" + /> +
+
+ + {filterFields.length > 0 && ( +
+ {filterFields.map((field) => ( +
+ + {field.type === 'select' ? ( + + ) : ( + + )} +
+ ))} +
+ )} +
+ ) +} diff --git a/frontends/nextjs/src/components/misc/viewers/model-list/ModelTable.tsx b/frontends/nextjs/src/components/misc/viewers/model-list/ModelTable.tsx new file mode 100644 index 000000000..8ecf94a36 --- /dev/null +++ b/frontends/nextjs/src/components/misc/viewers/model-list/ModelTable.tsx @@ -0,0 +1,148 @@ +import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui' +import type { FieldSchema, ModelSchema } from '@/lib/schema-types' +import { getFieldLabel } from '@/lib/schema-utils' +import { ArrowDown, ArrowUp, Pencil, Trash } from '@phosphor-icons/react' +import { ReactNode } from 'react' + +interface ModelTableProps { + model: ModelSchema + records: any[] + displayFields: string[] + sortField?: string | null + sortDirection?: 'asc' | 'desc' + onSortChange?: (field: string) => void + onEdit?: (record: any) => void + onDelete?: (id: string) => void + onRowClick?: (record: any) => void + renderRelationValue?: (value: string, field: FieldSchema) => ReactNode +} + +export function ModelTable({ + model, + records, + displayFields, + sortField, + sortDirection = 'asc', + onSortChange, + onEdit, + onDelete, + onRowClick, + renderRelationValue +}: ModelTableProps) { + const actionColumns = onEdit || onDelete ? 1 : 0 + + const renderCellValue = (record: any, fieldName: string) => { + const field = model.fields.find((item) => item.name === fieldName) + if (!field) return null + + const value = record[fieldName] + + if (value === null || value === undefined) { + return + } + + if (field.type === 'relation' && typeof value === 'string' && renderRelationValue) { + return renderRelationValue(value, field) + } + + switch (field.type) { + case 'boolean': + return value ? Yes : No + case 'date': + case 'datetime': + return new Date(value).toLocaleString() + case 'json': + return {JSON.stringify(value)} + default: + return typeof value === 'string' && value.length > 60 ? `${value.slice(0, 60)}…` : String(value) + } + } + + return ( +
+ + + + {displayFields.map((fieldName) => { + const field = model.fields.find((item) => item.name === fieldName) + if (!field) return null + const isSortable = field.sortable !== false + const isActive = sortField === fieldName + const Icon = sortDirection === 'asc' ? ArrowUp : ArrowDown + + return ( + isSortable && onSortChange?.(fieldName)} + > +
+ + {getFieldLabel(field)} + + {isSortable && isActive && } +
+
+ ) + })} + {(onEdit || onDelete) && Actions} +
+
+ + {records.length === 0 ? ( + + + No records to display + + + ) : ( + records.map((record) => ( + onRowClick?.(record)} + > + {displayFields.map((fieldName) => ( + + {renderCellValue(record, fieldName)} + + ))} + {(onEdit || onDelete) && ( + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+ )} +
+ )) + )} +
+
+
+ ) +}