diff --git a/web/app/dashboard/channels/page.tsx b/web/app/dashboard/channels/page.tsx index d377d20..3155dc1 100644 --- a/web/app/dashboard/channels/page.tsx +++ b/web/app/dashboard/channels/page.tsx @@ -5,11 +5,12 @@ import { MessagesSquareIcon, MessageSquareMoreIcon, } from "lucide-react" -import { toast } from "sonner" -import { DashboardCrudPage } from "@/components/dashboard/crud" +import { + createDashboardStatusColumn, + DashboardCrudPage, +} from "@/components/dashboard/crud" import { Badge } from "@/components/ui/badge" -import { Switch } from "@/components/ui/switch" import { createChannel, deleteChannel, @@ -147,49 +148,28 @@ export default function DashboardChannelsPage() { label: t("channel.columnAgent"), render: (item) => item.aiAgentName || "-", }, - { - key: "status", + createDashboardStatusColumn({ label: t("channel.columnStatus"), - render: (item, { actionLoading, reload, setActionLoadingId }) => ( -
- { - void (async () => { - setActionLoadingId(item.id) - try { - const nextStatus = - item.status === Status.Ok ? Status.Disabled : Status.Ok - await updateChannelStatus(item.id, nextStatus) - toast.success( - t( - nextStatus === Status.Ok - ? "channel.statusEnabled" - : "channel.statusDisabled", - { name: item.name } - ) - ) - await reload() - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("channel.statusUpdateFailed") - ) - } finally { - setActionLoadingId(null) - } - })() - }} - aria-label={t("channel.toggleStatus", { name: item.name })} - /> - - {getStatusLabel(item.status as Status, t)} - -
- ), - }, + getStatus: (item) => item.status as Status, + getLabel: (status) => getStatusLabel(status, t), + getBadgeVariant: (status) => (status === Status.Ok ? "default" : "outline"), + isEnabled: (status) => status === Status.Ok, + toggle: { + getNextStatus: (item) => + item.status === Status.Ok ? Status.Disabled : Status.Ok, + updateStatus: (item, nextStatus) => + updateChannelStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t( + nextStatus === Status.Ok + ? "channel.statusEnabled" + : "channel.statusDisabled", + { name: item.name } + ), + errorMessage: t("channel.statusUpdateFailed"), + ariaLabel: (item) => t("channel.toggleStatus", { name: item.name }), + }, + }), ]} fetchList={fetchChannels} getItemId={(item) => item.id} diff --git a/web/app/dashboard/companies/page.tsx b/web/app/dashboard/companies/page.tsx index 66e1cb7..8053c5c 100644 --- a/web/app/dashboard/companies/page.tsx +++ b/web/app/dashboard/companies/page.tsx @@ -1,11 +1,12 @@ "use client" import { BanIcon, CheckCircle2Icon } from "lucide-react" -import { toast } from "sonner" -import { DashboardCrudPage } from "@/components/dashboard/crud" -import { Badge } from "@/components/ui/badge" -import { DropdownMenuItem } from "@/components/ui/dropdown-menu" +import { + createDashboardStatusColumn, + createDashboardStatusToggleAction, + DashboardCrudPage, +} from "@/components/dashboard/crud" import { createCompany, deleteCompany, @@ -96,26 +97,19 @@ export default function DashboardCompaniesPage() { className: "w-28", render: (item) => item.customerCount, }, - { - key: "status", + createDashboardStatusColumn({ label: t("company.columnStatus"), className: "w-24", - render: (item) => ( - - {StatusLabels[item.status as Status] - ? getStatusLabel(item.status as Status, t) - : t("company.unknownStatus")} - - ), - }, + getStatus: (item) => item.status as Status, + getLabel: (status) => + StatusLabels[status] ? getStatusLabel(status, t) : t("company.unknownStatus"), + getBadgeVariant: (status) => + status === Status.Ok + ? "default" + : status === Status.Deleted + ? "outline" + : "secondary", + }), { key: "remark", label: t("company.columnRemark"), @@ -177,48 +171,23 @@ export default function DashboardCompaniesPage() { maxValue: () => t("company.nameRequired"), }, }} - renderRowActions={({ item, actionLoading, reload, setActionLoadingId }) => ( - { - void (async () => { - setActionLoadingId(item.id) - try { - const nextStatus = item.status === Status.Ok ? Status.Disabled : Status.Ok - await updateCompanyStatus(item.id, nextStatus) - toast.success( - t(nextStatus === Status.Ok ? "company.enabled" : "company.disabled", { - name: item.name, - }) - ) - await reload() - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("company.statusUpdateFailed") - ) - } finally { - setActionLoadingId(null) - } - })() - }} - > - {actionLoading ? ( - t("company.processing") - ) : item.status === Status.Ok ? ( - <> - - {t("company.disable")} - - ) : ( - <> - - {t("company.enable")} - - )} - - )} + rowActions={[ + createDashboardStatusToggleAction({ + disabled: (item) => item.status === Status.Deleted, + icon: (item) => + item.status === Status.Ok ? : , + label: (item) => + item.status === Status.Ok ? t("company.disable") : t("company.enable"), + getNextStatus: (item) => + item.status === Status.Ok ? Status.Disabled : Status.Ok, + updateStatus: (item, nextStatus) => updateCompanyStatus(item.id, nextStatus), + successMessage: (item, nextStatus) => + t(nextStatus === Status.Ok ? "company.enabled" : "company.disabled", { + name: item.name, + }), + errorMessage: t("company.statusUpdateFailed"), + }), + ]} labels={{ refresh: t("company.refresh"), create: t("company.new"), diff --git a/web/app/dashboard/quick-replies/page.tsx b/web/app/dashboard/quick-replies/page.tsx index 1930c22..072c97a 100644 --- a/web/app/dashboard/quick-replies/page.tsx +++ b/web/app/dashboard/quick-replies/page.tsx @@ -1,11 +1,13 @@ "use client" import { FileTextIcon, RefreshCwIcon } from "lucide-react" -import { toast } from "sonner" -import { DashboardCrudPage } from "@/components/dashboard/crud" +import { + createDashboardStatusColumn, + createDashboardStatusToggleAction, + DashboardCrudPage, +} from "@/components/dashboard/crud" import { Badge } from "@/components/ui/badge" -import { DropdownMenuItem } from "@/components/ui/dropdown-menu" import { createQuickReply, deleteQuickReply, @@ -93,15 +95,12 @@ export default function DashboardQuickRepliesPage() { label: t("quickReply.columnGroup"), render: (item) => {item.groupName}, }, - { - key: "status", + createDashboardStatusColumn({ label: t("quickReply.columnStatus"), - render: (item) => ( - - {getStatusLabel(item.status as Status, t)} - - ), - }, + getStatus: (item) => item.status as Status, + getLabel: (status) => getStatusLabel(status, t), + getBadgeVariant: (status) => (status === Status.Ok ? "default" : "outline"), + }), { key: "sortNo", label: t("quickReply.columnSort"), @@ -193,51 +192,34 @@ export default function DashboardQuickRepliesPage() { maxValue: () => t("quickReply.sortInvalid"), }, }} - renderRowActions={({ item, actionLoading, reload, setActionLoadingId }) => ( - { - void (async () => { - setActionLoadingId(item.id) - try { - const nextStatus = - item.status === Status.Ok ? Status.Disabled : Status.Ok - await updateQuickReply({ - id: item.id, - groupName: item.groupName, - title: item.title, - content: item.content, - sortNo: item.sortNo, - status: nextStatus, - }) - toast.success( - t( - nextStatus === Status.Ok - ? "quickReply.enabled" - : "quickReply.disabled", - { title: item.title } - ) - ) - await reload() - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : t("quickReply.statusUpdateFailed") - ) - } finally { - setActionLoadingId(null) - } - })() - }} - > - - {actionLoading - ? t("quickReply.processing") - : item.status === Status.Ok + rowActions={[ + createDashboardStatusToggleAction({ + icon: , + label: (item) => + item.status === Status.Ok ? t("quickReply.disable") - : t("quickReply.enable")} - - )} + : t("quickReply.enable"), + getNextStatus: (item) => + item.status === Status.Ok ? Status.Disabled : Status.Ok, + updateStatus: (item, nextStatus) => + updateQuickReply({ + id: item.id, + groupName: item.groupName, + title: item.title, + content: item.content, + sortNo: item.sortNo, + status: nextStatus, + }), + successMessage: (item, nextStatus) => + t( + nextStatus === Status.Ok + ? "quickReply.enabled" + : "quickReply.disabled", + { title: item.title } + ), + errorMessage: t("quickReply.statusUpdateFailed"), + }), + ]} labels={{ refresh: t("quickReply.refresh"), create: t("quickReply.new"), diff --git a/web/components/dashboard/crud/dashboard-crud-form-dialog.tsx b/web/components/dashboard/crud/dashboard-crud-form-dialog.tsx index 50a2690..3849534 100644 --- a/web/components/dashboard/crud/dashboard-crud-form-dialog.tsx +++ b/web/components/dashboard/crud/dashboard-crud-form-dialog.tsx @@ -12,6 +12,7 @@ import { buildDashboardCrudFormValues, normalizeDashboardCrudSubmitValues, type DashboardCrudFormField, + type DashboardCrudFormOption, } from "./dashboard-crud-utils" import { DashboardCrudFieldControl } from "./dashboard-crud-field-control" @@ -116,6 +117,9 @@ export function DashboardCrudFormDialog({ id: number item: TItem } | null>(null) + const [fieldOptions, setFieldOptions] = useState< + Record> + >({}) const form = useForm, undefined, Record>({ resolver, defaultValues: initialValues, @@ -157,6 +161,32 @@ export function DashboardCrudFormDialog({ } }, [fetchDetail, fields, initialValues, item, itemId, reset]) + useEffect(() => { + if (!open) return + + let cancelled = false + const loaders = fields + .filter((field) => field.type === "select" && field.loadOptions) + .map(async (field) => { + const options = await field.loadOptions!() + return [field.name, options] as const + }) + + if (loaders.length === 0) return + + void Promise.all(loaders).then((entries) => { + if (cancelled) return + setFieldOptions((current) => ({ + ...current, + ...Object.fromEntries(entries), + })) + }) + + return () => { + cancelled = true + } + }, [fields, open]) + async function submit(values: Record) { const normalizedValues = normalizeDashboardCrudSubmitValues(fields, values) const payload = transformSubmitValues @@ -205,7 +235,10 @@ export function DashboardCrudFormDialog({ {layoutFields.map((field) => ( = +export type DashboardCrudFilter = DashboardCrudQueryFilter & { label: string placeholder?: string @@ -56,14 +79,14 @@ type DashboardCrudFilter = options?: ReadonlyArray<{ value: string; label: string }> } -type DashboardCrudColumn = { +export type DashboardCrudColumn = { key: string label: ReactNode className?: string render: (item: TItem, context: DashboardCrudRowActionContext) => ReactNode } -type DashboardCrudDialogProps = { +export type DashboardCrudDialogProps = { open: boolean saving: boolean item: TItem | null @@ -72,15 +95,25 @@ type DashboardCrudDialogProps = { onSubmit: (payload: TPayload) => Promise } -type DashboardCrudRowActionContext = { +export type DashboardCrudRowActionContext = { item: TItem + itemId: number actionLoading: boolean actionLoadingId: number | null reload: () => Promise setActionLoadingId: (id: number | null) => void } -type DashboardCrudPageProps = { +export type DashboardCrudRowAction = DashboardCrudActionRule & { + key: string + label: ReactNode | ((item: TItem) => ReactNode) + icon?: ReactNode | ((item: TItem) => ReactNode) + variant?: "default" | "destructive" + confirm?: ConfirmOptions | ((item: TItem) => ConfirmOptions) + run: (context: DashboardCrudRowActionContext) => Promise | void +} + +export type DashboardCrudPageProps = { filters: DashboardCrudFilter[] columns: DashboardCrudColumn[] fetchList: ( @@ -113,7 +146,17 @@ type DashboardCrudPageProps = { updateItem: (item: TItem, payload: TPayload) => Promise deleteItem?: (item: TItem) => Promise canDelete?: (item: TItem) => boolean + deleteConfirm?: ConfirmOptions | ((item: TItem) => ConfirmOptions) + rowActions?: DashboardCrudRowAction[] renderRowActions?: (context: DashboardCrudRowActionContext) => ReactNode + sort?: { + enabled?: boolean + disabled?: boolean + onReorder: (items: TItem[]) => Promise + successMessage?: string + errorMessage: string + handleLabel: string + } pageSize?: number labels: { refresh: string @@ -146,19 +189,26 @@ export function DashboardCrudPage({ updateItem, deleteItem, canDelete, + deleteConfirm, + rowActions = [], renderRowActions, + sort, pageSize = 20, labels, }: DashboardCrudPageProps) { - const initialFilters = useMemo( - () => - Object.fromEntries( - filters.map((filter) => [filter.name, filter.defaultValue]) - ) as Record, - [filters] + const confirm = useConfirm() + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 6, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) ) - const [draftFilters, setDraftFilters] = useState(initialFilters) - const [appliedFilters, setAppliedFilters] = useState(initialFilters) + const { draftFilters, appliedFilters, setDraftFilter, applyFilters } = + useDashboardCrudFilters(filters) const [page, setPage] = useState(1) const [limit, setLimit] = useState(pageSize) const [loading, setLoading] = useState(true) @@ -171,11 +221,6 @@ export function DashboardCrudPage({ page: { page: 1, limit: pageSize, total: 0 }, }) - useEffect(() => { - setDraftFilters(initialFilters) - setAppliedFilters(initialFilters) - }, [initialFilters]) - const loadData = useCallback(async () => { setLoading(true) try { @@ -199,15 +244,15 @@ export function DashboardCrudPage({ void loadData() }, [loadData]) - function applyFilters() { - setAppliedFilters(draftFilters) + function handleApplyFilters() { + applyFilters() setPage(1) } function handleFilterKeyDown(event: React.KeyboardEvent) { if (event.key !== "Enter") return event.preventDefault() - applyFilters() + handleApplyFilters() } function openCreateDialog() { @@ -249,6 +294,12 @@ export function DashboardCrudPage({ async function handleDelete(item: TItem) { if (!deleteItem) return + if (deleteConfirm) { + const confirmed = await confirm( + typeof deleteConfirm === "function" ? deleteConfirm(item) : deleteConfirm + ) + if (!confirmed) return + } const id = getItemId(item) setActionLoadingId(id) try { @@ -262,7 +313,185 @@ export function DashboardCrudPage({ } } - const colSpan = columns.length + 1 + async function runRowAction( + action: DashboardCrudRowAction, + item: TItem, + actionLoading: boolean + ) { + if (actionLoading || isDashboardCrudActionDisabled(action, item)) return + + if (action.confirm) { + const confirmed = await confirm( + typeof action.confirm === "function" ? action.confirm(item) : action.confirm + ) + if (!confirmed) return + } + + const id = getItemId(item) + setActionLoadingId(id) + try { + await action.run({ + item, + itemId: id, + actionLoading: true, + actionLoadingId: id, + reload: loadData, + setActionLoadingId, + }) + } finally { + setActionLoadingId(null) + } + } + + async function handleSortEnd(event: DragEndEvent) { + if (!sort?.enabled || sort.disabled) return + + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = result.results.findIndex( + (item) => String(getItemId(item)) === String(active.id) + ) + const newIndex = result.results.findIndex( + (item) => String(getItemId(item)) === String(over.id) + ) + if (oldIndex < 0 || newIndex < 0) return + + const previousResults = result.results + const nextResults = arrayMove(result.results, oldIndex, newIndex) + setResult((current) => ({ ...current, results: nextResults })) + + try { + await sort.onReorder(nextResults) + if (sort.successMessage) { + toast.success(sort.successMessage) + } + } catch (error) { + setResult((current) => ({ ...current, results: previousResults })) + toast.error(error instanceof Error ? error.message : sort.errorMessage) + } + } + + const sortable = Boolean(sort?.enabled) + const colSpan = columns.length + 1 + (sortable ? 1 : 0) + + function renderTableRow( + item: TItem, + options: { sortable?: boolean; sortDisabled?: boolean } = {} + ) { + const id = getItemId(item) + const actionLoading = actionLoadingId === id + const rowCells = ( + <> + {columns.map((column) => ( + + {column.render(item, { + item, + itemId: id, + actionLoading, + actionLoadingId, + reload: loadData, + setActionLoadingId, + })} + + ))} + + + + {renderRowActions || rowActions.length > 0 || deleteItem ? ( + + } + aria-label={labels.moreActions(item)} + > + + + + {rowActions + .filter((action) => isDashboardCrudActionVisible(action, item)) + .map((action) => { + const icon = + typeof action.icon === "function" + ? action.icon(item) + : action.icon + const label = + typeof action.label === "function" + ? action.label(item) + : action.label + return ( + void runRowAction(action, item, actionLoading)} + > + {icon} + {actionLoading ? labels.processing : label} + + ) + })} + {renderRowActions?.({ + item, + itemId: id, + actionLoading, + actionLoadingId, + reload: loadData, + setActionLoadingId, + })} + {deleteItem ? ( + void handleDelete(item)} + disabled={canDelete ? !canDelete(item) : false} + className="text-destructive focus:text-destructive" + > + + {actionLoading ? labels.processing : labels.delete} + + ) : null} + + + ) : null} + + + + ) + + if (options.sortable) { + return ( + + {rowCells} + + ) + } + + return {rowCells} + } + + function renderStateRow() { + if (!loading && result.results.length > 0) return null + return ( + + ) + } return ( <> @@ -288,12 +517,7 @@ export function DashboardCrudPage({
- setDraftFilters((current) => ({ - ...current, - [filter.name]: nextValue, - })) - } + onChange={(nextValue) => setDraftFilter(filter.name, nextValue)} placeholder={filter.placeholder ?? filter.label} options={[...(filter.options ?? [])]} /> @@ -306,10 +530,7 @@ export function DashboardCrudPage({ - setDraftFilters((current) => ({ - ...current, - [filter.name]: event.target.value, - })) + setDraftFilter(filter.name, event.target.value) } onKeyDown={handleFilterKeyDown} placeholder={filter.placeholder ?? filter.label} @@ -317,7 +538,7 @@ export function DashboardCrudPage({
) })} - @@ -344,6 +565,7 @@ export function DashboardCrudPage({ + {sortable ? : null} {columns.map((column) => ( {column.label} @@ -354,75 +576,33 @@ export function DashboardCrudPage({ - - {result.results.map((item) => { - const id = getItemId(item) - const actionLoading = actionLoadingId === id - return ( - - {columns.map((column) => ( - - {column.render(item, { - item, - actionLoading, - actionLoadingId, - reload: loadData, - setActionLoadingId, - })} - - ))} - - - - {renderRowActions || deleteItem ? ( - - } - aria-label={labels.moreActions(item)} - > - - - - {renderRowActions?.({ - item, - actionLoading, - actionLoadingId, - reload: loadData, - setActionLoadingId, - })} - {deleteItem ? ( - void handleDelete(item)} - disabled={canDelete ? !canDelete(item) : false} - className="text-destructive focus:text-destructive" - > - - {actionLoading ? labels.processing : labels.delete} - - ) : null} - - - ) : null} - - - - ) - })} - {loading || result.results.length === 0 ? ( - - ) : null} - + {sortable ? ( + void handleSortEnd(event)} + > + String(getItemId(item)))} + strategy={verticalListSortingStrategy} + > + + {result.results.map((item) => + renderTableRow(item, { + sortable: true, + sortDisabled: sort?.disabled, + }) + )} + {renderStateRow()} + + + + ) : ( + + {result.results.map((item) => renderTableRow(item))} + {renderStateRow()} + + )}
@@ -452,3 +632,50 @@ export function DashboardCrudPage({ ) } + +function DashboardCrudSortableRow({ + id, + disabled, + handleLabel, + children, +}: { + id: string + disabled?: boolean + handleLabel: string + children: ReactNode +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id, disabled }) + + return ( + + + + + {children} + + ) +} diff --git a/web/components/dashboard/crud/dashboard-crud-presets.tsx b/web/components/dashboard/crud/dashboard-crud-presets.tsx new file mode 100644 index 0000000..d00624d --- /dev/null +++ b/web/components/dashboard/crud/dashboard-crud-presets.tsx @@ -0,0 +1,130 @@ +"use client" + +import type { ComponentProps, ReactNode } from "react" +import { toast } from "sonner" + +import { Badge } from "@/components/ui/badge" +import { Switch } from "@/components/ui/switch" +import type { + DashboardCrudColumn, + DashboardCrudRowAction, +} from "./dashboard-crud-page" + +type BadgeVariant = ComponentProps["variant"] + +export type DashboardCrudStatusColumnOptions = { + key?: string + label: ReactNode + className?: string + getStatus: (item: TItem) => TStatus + getLabel: (status: TStatus, item: TItem) => ReactNode + getBadgeVariant?: (status: TStatus, item: TItem) => BadgeVariant + isEnabled?: (status: TStatus, item: TItem) => boolean + toggle?: { + disabled?: (item: TItem) => boolean + getNextStatus: (item: TItem) => TStatus + updateStatus: (item: TItem, nextStatus: TStatus) => Promise + successMessage: (item: TItem, nextStatus: TStatus) => string + errorMessage: string + ariaLabel: (item: TItem) => string + } +} + +export type DashboardCrudStatusToggleActionOptions = { + key?: string + icon?: ReactNode | ((item: TItem) => ReactNode) + label: (item: TItem, nextStatus: TStatus) => ReactNode + visible?: (item: TItem) => boolean + disabled?: (item: TItem) => boolean + getNextStatus: (item: TItem) => TStatus + updateStatus: (item: TItem, nextStatus: TStatus) => Promise + successMessage: (item: TItem, nextStatus: TStatus) => string + errorMessage: string +} + +export function createDashboardStatusColumn({ + key = "status", + label, + className, + getStatus, + getLabel, + getBadgeVariant, + isEnabled, + toggle, +}: DashboardCrudStatusColumnOptions): DashboardCrudColumn { + return { + key, + label, + className, + render: (item, { itemId, actionLoading, reload, setActionLoadingId }) => { + const status = getStatus(item) + const badge = ( + + {getLabel(status, item)} + + ) + + if (!toggle) { + return badge + } + + return ( +
+ { + void (async () => { + setActionLoadingId(itemId) + try { + const nextStatus = toggle.getNextStatus(item) + await toggle.updateStatus(item, nextStatus) + toast.success(toggle.successMessage(item, nextStatus)) + await reload() + } catch (error) { + toast.error( + error instanceof Error ? error.message : toggle.errorMessage + ) + } finally { + setActionLoadingId(null) + } + })() + }} + aria-label={toggle.ariaLabel(item)} + /> + {badge} +
+ ) + }, + } +} + +export function createDashboardStatusToggleAction({ + key = "toggle-status", + icon, + label, + visible, + disabled, + getNextStatus, + updateStatus, + successMessage, + errorMessage, +}: DashboardCrudStatusToggleActionOptions): DashboardCrudRowAction { + return { + key, + icon, + visible, + disabled, + label: (item) => label(item, getNextStatus(item)), + run: async ({ item, reload }) => { + try { + const nextStatus = getNextStatus(item) + await updateStatus(item, nextStatus) + toast.success(successMessage(item, nextStatus)) + await reload() + } catch (error) { + toast.error(error instanceof Error ? error.message : errorMessage) + } + }, + } +} diff --git a/web/components/dashboard/crud/dashboard-crud-utils.test.mjs b/web/components/dashboard/crud/dashboard-crud-utils.test.mjs index 0163ebc..0072148 100644 --- a/web/components/dashboard/crud/dashboard-crud-utils.test.mjs +++ b/web/components/dashboard/crud/dashboard-crud-utils.test.mjs @@ -75,6 +75,27 @@ describe("buildDashboardCrudQuery", () => { }) }) +describe("buildDashboardCrudInitialFilters", () => { + it("creates stable filter state from defaults", async () => { + const { buildDashboardCrudInitialFilters } = await loadModule() + + assert.deepEqual( + plain( + buildDashboardCrudInitialFilters([ + { name: "keyword", defaultValue: "" }, + { name: "status", defaultValue: "all" }, + { name: "companyId", defaultValue: 0 }, + ]) + ), + { + keyword: "", + status: "all", + companyId: 0, + } + ) + }) +}) + describe("normalizeDashboardCrudPageResult", () => { it("returns a stable empty page when the API result is missing", async () => { const { normalizeDashboardCrudPageResult } = await loadModule() @@ -143,3 +164,32 @@ describe("normalizeDashboardCrudSubmitValues", () => { ) }) }) + +describe("dashboard CRUD action rules", () => { + it("defaults actions to visible and enabled", async () => { + const { + isDashboardCrudActionVisible, + isDashboardCrudActionDisabled, + } = await loadModule() + const action = {} + + assert.equal(isDashboardCrudActionVisible(action, { status: 0 }), true) + assert.equal(isDashboardCrudActionDisabled(action, { status: 0 }), false) + }) + + it("honors visible and disabled predicates", async () => { + const { + isDashboardCrudActionVisible, + isDashboardCrudActionDisabled, + } = await loadModule() + const action = { + visible: (item) => item.status !== 2, + disabled: (item) => item.status === 1, + } + + assert.equal(isDashboardCrudActionVisible(action, { status: 2 }), false) + assert.equal(isDashboardCrudActionVisible(action, { status: 0 }), true) + assert.equal(isDashboardCrudActionDisabled(action, { status: 1 }), true) + assert.equal(isDashboardCrudActionDisabled(action, { status: 0 }), false) + }) +}) diff --git a/web/components/dashboard/crud/dashboard-crud-utils.ts b/web/components/dashboard/crud/dashboard-crud-utils.ts index 27f3f98..da680cb 100644 --- a/web/components/dashboard/crud/dashboard-crud-utils.ts +++ b/web/components/dashboard/crud/dashboard-crud-utils.ts @@ -7,6 +7,13 @@ export type DashboardCrudQueryFilter = { valueType?: "string" | "number" } +export type DashboardCrudFilterStateConfig< + TValue extends string | number = string | number, +> = { + name: string + defaultValue: TValue +} + export type DashboardCrudPageResult = { results: T[] page: { @@ -39,11 +46,17 @@ export type DashboardCrudFormField = { pattern?: RegExp patternMessage?: string options?: ReadonlyArray + loadOptions?: () => Promise> colSpan?: 1 | 2 rows?: number valueFromItem?: (item: TItem) => DashboardCrudFormValue } +export type DashboardCrudActionRule = { + visible?: (item: TItem) => boolean + disabled?: (item: TItem) => boolean +} + export function buildDashboardCrudQuery({ values, filters, @@ -86,6 +99,14 @@ export function buildDashboardCrudQuery({ return query } +export function buildDashboardCrudInitialFilters( + filters: ReadonlyArray +): Record { + return Object.fromEntries( + filters.map((filter) => [filter.name, filter.defaultValue]) + ) as Record +} + export function normalizeDashboardCrudPageResult( result: Partial> | null | undefined, page: number, @@ -139,3 +160,17 @@ export function normalizeDashboardCrudSubmitValues( return output } + +export function isDashboardCrudActionVisible( + action: DashboardCrudActionRule, + item: TItem +) { + return action.visible ? action.visible(item) : true +} + +export function isDashboardCrudActionDisabled( + action: DashboardCrudActionRule, + item: TItem +) { + return action.disabled ? action.disabled(item) : false +} diff --git a/web/components/dashboard/crud/index.ts b/web/components/dashboard/crud/index.ts index b53b871..5c3924b 100644 --- a/web/components/dashboard/crud/index.ts +++ b/web/components/dashboard/crud/index.ts @@ -1,5 +1,18 @@ export { DashboardCrudPage } from "./dashboard-crud-page" export { DashboardCrudFormDialog } from "./dashboard-crud-form-dialog" +export { + createDashboardStatusColumn, + createDashboardStatusToggleAction, +} from "./dashboard-crud-presets" +export { useDashboardCrudFilters } from "./use-dashboard-crud-filters" +export type { + DashboardCrudColumn, + DashboardCrudDialogProps, + DashboardCrudFilter, + DashboardCrudPageProps, + DashboardCrudRowAction, + DashboardCrudRowActionContext, +} from "./dashboard-crud-page" export type { DashboardCrudFormField, DashboardCrudPageResult, diff --git a/web/components/dashboard/crud/use-dashboard-crud-filters.ts b/web/components/dashboard/crud/use-dashboard-crud-filters.ts new file mode 100644 index 0000000..b69556d --- /dev/null +++ b/web/components/dashboard/crud/use-dashboard-crud-filters.ts @@ -0,0 +1,43 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" + +import { + buildDashboardCrudInitialFilters, + type DashboardCrudFilterStateConfig, +} from "./dashboard-crud-utils" + +export function useDashboardCrudFilters( + filters: ReadonlyArray +) { + const initialFilters = useMemo( + () => buildDashboardCrudInitialFilters(filters), + [filters] + ) + const [draftFilters, setDraftFilters] = useState(initialFilters) + const [appliedFilters, setAppliedFilters] = useState(initialFilters) + + useEffect(() => { + setDraftFilters(initialFilters) + setAppliedFilters(initialFilters) + }, [initialFilters]) + + function setDraftFilter(name: string, value: string | number | undefined) { + setDraftFilters((current) => ({ + ...current, + [name]: value, + })) + } + + function applyFilters() { + setAppliedFilters(draftFilters) + } + + return { + draftFilters, + appliedFilters, + setDraftFilter, + setDraftFilters, + applyFilters, + } +}