feat: add custom-fields
This commit is contained in:
205
frontend/src/components/custom-fields/CustomFieldsTable.tsx
Normal file
205
frontend/src/components/custom-fields/CustomFieldsTable.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
DataTable,
|
||||
type DataTableEmptyState,
|
||||
} from "@/components/tables/DataTable";
|
||||
import { dateCell } from "@/components/tables/cell-formatters";
|
||||
import type { TaskCustomFieldDefinitionRead } from "@/api/generated/model";
|
||||
|
||||
type CustomFieldsTableProps = {
|
||||
fields: TaskCustomFieldDefinitionRead[];
|
||||
isLoading?: boolean;
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: OnChangeFn<SortingState>;
|
||||
stickyHeader?: boolean;
|
||||
editHref?: (field: TaskCustomFieldDefinitionRead) => string;
|
||||
onDelete?: (field: TaskCustomFieldDefinitionRead) => void;
|
||||
emptyState?: Omit<DataTableEmptyState, "icon"> & {
|
||||
icon?: DataTableEmptyState["icon"];
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_EMPTY_ICON = (
|
||||
<svg
|
||||
className="h-16 w-16 text-slate-300"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M8 6h13" />
|
||||
<path d="M8 12h13" />
|
||||
<path d="M8 18h13" />
|
||||
<path d="M3 6h.01" />
|
||||
<path d="M3 12h.01" />
|
||||
<path d="M3 18h.01" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const formatDefaultValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
export function CustomFieldsTable({
|
||||
fields,
|
||||
isLoading = false,
|
||||
sorting,
|
||||
onSortingChange,
|
||||
stickyHeader = false,
|
||||
editHref,
|
||||
onDelete,
|
||||
emptyState,
|
||||
}: CustomFieldsTableProps) {
|
||||
const [internalSorting, setInternalSorting] = useState<SortingState>([
|
||||
{ id: "field_key", desc: false },
|
||||
]);
|
||||
const resolvedSorting = sorting ?? internalSorting;
|
||||
const handleSortingChange: OnChangeFn<SortingState> =
|
||||
onSortingChange ??
|
||||
((updater: Updater<SortingState>) => {
|
||||
setInternalSorting(updater);
|
||||
});
|
||||
|
||||
const columns = useMemo<ColumnDef<TaskCustomFieldDefinitionRead>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "field_key",
|
||||
header: "Field",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">
|
||||
{row.original.label || row.original.field_key}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs text-slate-500">
|
||||
key: {row.original.field_key}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{row.original.description || "No description"}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "required",
|
||||
header: "Required",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.required === true ? "Required" : "Optional"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "field_type",
|
||||
header: "Type",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.field_type}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "ui_visibility",
|
||||
header: "UI visible",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-slate-700">
|
||||
{row.original.ui_visibility}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "default_value",
|
||||
header: "Default value",
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<p className="font-mono text-xs break-all text-slate-700">
|
||||
{formatDefaultValue(row.original.default_value) || "—"}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated",
|
||||
cell: ({ row }) => dateCell(row.original.updated_at),
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const table = useReactTable({
|
||||
data: fields,
|
||||
columns,
|
||||
state: {
|
||||
sorting: resolvedSorting,
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
table={table}
|
||||
isLoading={isLoading}
|
||||
stickyHeader={stickyHeader}
|
||||
rowClassName="transition hover:bg-slate-50"
|
||||
cellClassName="px-6 py-4 align-top"
|
||||
rowActions={
|
||||
editHref || onDelete
|
||||
? {
|
||||
actions: [
|
||||
...(editHref
|
||||
? [
|
||||
{
|
||||
key: "edit",
|
||||
label: "Edit",
|
||||
href: editHref,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(onDelete
|
||||
? [
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
onClick: onDelete,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyState={
|
||||
emptyState
|
||||
? {
|
||||
icon: emptyState.icon ?? DEFAULT_EMPTY_ICON,
|
||||
title: emptyState.title,
|
||||
description: emptyState.description,
|
||||
actionHref: emptyState.actionHref,
|
||||
actionLabel: emptyState.actionLabel,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Building2,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
Settings,
|
||||
Tags,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -146,6 +147,20 @@ export function DashboardSidebar() {
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
Approvals
|
||||
</Link>
|
||||
{isAdmin ? (
|
||||
<Link
|
||||
href="/custom-fields"
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-slate-700 transition",
|
||||
pathname.startsWith("/custom-fields")
|
||||
? "bg-blue-100 text-blue-800 font-medium"
|
||||
: "hover:bg-slate-100",
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Custom fields
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user