metis-desk/packages/rdx-ui/src/components/entity-list.tsx
David ccb52fb534 feat: add catalog field picker dialog and list components
- Introduced `CatalogFieldPickerDialog` and `CatalogFieldPickerList` for selecting catalog fields.
- Implemented `appendTemplateFields` helper function for managing template fields.
- Added tests for catalog field selection and template field management.
- Created reusable components for field picking and reordering in the UI library.
- Enhanced drag-and-drop functionality for reordering items in lists.
2026-10-15 15:58:01 +02:00

207 lines
6.2 KiB
TypeScript

import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import type { ReactNode } from "react";
import { useId } from "react";
import { useEntityTable } from "../hooks/use-entity-table";
import { entityListLabels } from "../lib/entity-list";
import { EntityListPagination } from "./entity-list-controls";
import { EntityTableConfiguration } from "./entity-table-configuration";
import { EntityTableContent } from "./entity-table-content";
export { EntityAction, EntityRowActions, EntityStatus } from "./entity-list-controls";
export function EntityListHeader({
title,
description,
actions,
locale = "es",
}: {
title: string;
description?: string;
actions?: ReactNode;
locale?: string;
}) {
return (
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-2xl font-semibold">{title}</h2>
<p className="mt-1 text-base font-medium text-muted-foreground">
{description ??
`${entityListLabels(locale).description} ${title.toLocaleLowerCase(locale)}.`}
</p>
</div>
{actions && <div className="flex flex-wrap items-center justify-end gap-2">{actions}</div>}
</header>
);
}
export type EntityColumn<T> = {
id: string;
label: string;
render: (item: T) => ReactNode;
value?: (item: T) => string | number | boolean | null | undefined;
defaultSort?: "asc" | "desc";
hideable?: boolean;
};
export function EntityTable<T extends { id: string }>({
items,
columns,
title,
entityLabel = title,
locale = "es",
search,
onSearchChange,
searchPlaceholder,
filters,
filtersApplied = false,
onClearFilters,
filterKey = "",
loading = false,
unavailable = false,
emptyMessage,
rowClassName,
onRowClick,
layout = "content",
}: {
items: readonly T[];
columns: readonly EntityColumn<T>[];
title: string;
entityLabel?: string;
locale?: string;
search?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
filters?: ReactNode;
filtersApplied?: boolean;
onClearFilters?: () => void;
filterKey?: string;
loading?: boolean;
unavailable?: boolean;
emptyMessage?: string;
rowClassName?: (item: T) => string;
onRowClick?: ((item: T) => void) | undefined;
layout?: "content" | "fill";
}) {
const id = useId();
const labels = entityListLabels(locale);
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
useEntityTable({ items, columns, search, onSearchChange, filterKey });
const info = entityTableInfo(
loading,
unavailable,
labels.loading,
labels.range(range.from, range.to, total, entityLabel.toLocaleLowerCase(locale))
);
const message = entityTableMessage(
loading,
unavailable,
Boolean(currentSearch || filtersApplied),
labels,
emptyMessage
);
const clear = () => {
(onSearchChange ?? setLocalSearch)("");
onClearFilters?.();
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
};
return (
<section
aria-busy={loading}
aria-label={title}
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "flex min-h-96 flex-1 flex-col overflow-hidden sm:min-h-80 [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:[scrollbar-gutter:stable] [&_[data-slot=table-header]]:sticky [&_[data-slot=table-header]]:top-0 [&_[data-slot=table-header]]:z-20 [&_[data-slot=table-header]]:bg-background" : ""}`}
>
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3 border-b p-4">
<div className="w-full sm:max-w-sm">
<label className="sr-only" htmlFor={`${id}-search`}>
{searchPlaceholder ?? labels.search}
</label>
<Input
id={`${id}-search`}
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
placeholder={searchPlaceholder ?? labels.placeholder}
value={currentSearch}
/>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 [&>fieldset]:min-w-40">
{filters}
{(currentSearch || filtersApplied) && (
<Button onClick={clear} size="sm" variant="outline">
{labels.clear}
</Button>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<p aria-live="polite" className="text-sm text-muted-foreground">
{info}
</p>
<EntityTableConfiguration
locale={locale}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
table={table}
/>
</div>
<EntityTableContent
columns={columns}
loading={loading}
message={message}
onRowClick={onRowClick}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
rowClassName={rowClassName}
table={table}
title={title}
unavailable={unavailable}
/>
<footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
<p className="text-sm text-muted-foreground">{info}</p>
<EntityListPagination
id={id}
locale={locale}
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
onPageSize={(pageSize) => setPagination({ pageIndex: 0, pageSize })}
page={range.page}
pageSize={pagination.pageSize}
pages={range.pages}
/>
</footer>
</section>
);
}
function entityTableInfo(
loading: boolean,
unavailable: boolean,
loadingLabel: string,
info: string
) {
if (loading) {
return loadingLabel;
}
return unavailable ? "—" : info;
}
function entityTableMessage(
loading: boolean,
unavailable: boolean,
filtered: boolean,
labels: ReturnType<typeof entityListLabels>,
emptyMessage: string | undefined
) {
if (loading) {
return labels.loading;
}
if (unavailable) {
return labels.unavailable;
}
return filtered ? labels.noResults : (emptyMessage ?? labels.empty);
}