316 lines
9.4 KiB
TypeScript
316 lines
9.4 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, useRef } from "react";
|
|
|
|
import { useEntityTable } from "../hooks/use-entity-table";
|
|
import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
|
|
import { entityListLabels } from "../lib/entity-list";
|
|
import type { EntityListState } from "../lib/entity-list-state";
|
|
import { entityTableAvailability } from "../lib/entity-list-state";
|
|
import type { EntityTableNavigation } from "../lib/entity-table-navigation";
|
|
|
|
import { EntityListPagination } from "./entity-list-controls";
|
|
import { EntityListStatus } from "./entity-list-status";
|
|
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,
|
|
icon,
|
|
actions,
|
|
locale = "es",
|
|
}: {
|
|
title: string;
|
|
description?: string;
|
|
icon?: ReactNode;
|
|
actions?: ReactNode;
|
|
locale?: string;
|
|
}) {
|
|
return (
|
|
<header className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="min-w-0">
|
|
<h2 className="flex items-center gap-2 text-2xl font-semibold">
|
|
{icon && (
|
|
<span
|
|
aria-hidden="true"
|
|
className="inline-flex size-6 shrink-0 items-center justify-center [&>svg]:size-6"
|
|
>
|
|
{icon}
|
|
</span>
|
|
)}
|
|
{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> = EntityColumnMetadata & {
|
|
id: string;
|
|
label: string;
|
|
render: (item: T) => ReactNode;
|
|
value?: (item: T) => string | number | boolean | null | undefined;
|
|
defaultSort?: "asc" | "desc";
|
|
hideable?: boolean;
|
|
defaultVisible?: boolean;
|
|
};
|
|
|
|
export function EntityTable<T extends { id: string }>({
|
|
items,
|
|
columns,
|
|
title,
|
|
entityLabel = title,
|
|
locale = "es",
|
|
search,
|
|
onSearchChange,
|
|
searchPlaceholder,
|
|
filters,
|
|
filtersApplied = false,
|
|
onClearFilters,
|
|
filterKey = "",
|
|
columnVisibilityStorageKey,
|
|
columnGroups,
|
|
state,
|
|
navigation,
|
|
onRetry,
|
|
columnsDisabled,
|
|
loading = false,
|
|
unavailable = false,
|
|
emptyMessage,
|
|
rowClassName,
|
|
onRowClick,
|
|
layout = "content",
|
|
}: {
|
|
items: readonly T[];
|
|
columns: readonly EntityColumn<T>[];
|
|
title: string;
|
|
entityLabel?: string | ((count: number) => string);
|
|
locale?: string;
|
|
search?: string;
|
|
onSearchChange?: (value: string) => void;
|
|
searchPlaceholder?: string;
|
|
filters?: ReactNode;
|
|
filtersApplied?: boolean;
|
|
onClearFilters?: () => void;
|
|
filterKey?: string;
|
|
columnVisibilityStorageKey?: string | undefined;
|
|
columnGroups?: readonly EntityColumnGroup[];
|
|
state?: EntityListState | undefined;
|
|
navigation?: EntityTableNavigation | undefined;
|
|
onRetry?: (() => Promise<unknown>) | undefined;
|
|
columnsDisabled?: boolean | undefined;
|
|
loading?: boolean;
|
|
unavailable?: boolean;
|
|
emptyMessage?: string;
|
|
rowClassName?: (item: T) => string;
|
|
onRowClick?: ((item: T) => void) | undefined;
|
|
layout?: "content" | "fill";
|
|
}) {
|
|
const id = useId();
|
|
const searchRef = useRef<HTMLInputElement>(null);
|
|
const labels = entityListLabels(locale);
|
|
const availability = entityTableAvailability(state, loading, unavailable);
|
|
const pending = availability.loading;
|
|
const missing = availability.unavailable;
|
|
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
|
|
useEntityTable({
|
|
items,
|
|
columns,
|
|
locale,
|
|
search,
|
|
onSearchChange,
|
|
filterKey,
|
|
columnVisibilityStorageKey,
|
|
navigation,
|
|
});
|
|
const info = entityTableInfo(
|
|
pending,
|
|
missing,
|
|
labels.loading,
|
|
labels.range(
|
|
range.from,
|
|
range.to,
|
|
total,
|
|
entityTableLabel(entityLabel, total, locale),
|
|
layout === "fill" ? "compact" : "full"
|
|
)
|
|
);
|
|
const message = entityTableMessage(
|
|
pending,
|
|
missing,
|
|
Boolean(currentSearch.trim() || filtersApplied),
|
|
labels,
|
|
emptyMessage
|
|
);
|
|
|
|
const clear = () => {
|
|
if (navigation?.onClearFilters) {
|
|
navigation.onClearFilters();
|
|
} else {
|
|
(onSearchChange ?? setLocalSearch)("");
|
|
onClearFilters?.();
|
|
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
|
}
|
|
searchRef.current?.focus({ preventScroll: true });
|
|
};
|
|
|
|
function resetSortPage() {
|
|
if (!navigation) {
|
|
setPagination((previous) => ({ ...previous, pageIndex: 0 }));
|
|
}
|
|
}
|
|
|
|
const configuration = (
|
|
<EntityTableConfiguration
|
|
columnGroups={columnGroups}
|
|
columnMetadata={columns}
|
|
columnsDisabled={columnsDisabled}
|
|
locale={locale}
|
|
onResetColumns={() => table.setColumnVisibility({})}
|
|
onSort={resetSortPage}
|
|
presentation={layout === "fill" ? "picker" : "full"}
|
|
table={table}
|
|
/>
|
|
);
|
|
|
|
return (
|
|
<section
|
|
aria-busy={availability.sectionBusy}
|
|
aria-label={title}
|
|
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "@container/entity-table flex min-h-72 flex-1 flex-col overflow-hidden [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:overscroll-contain [&>[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" : ""}`}
|
|
data-query-state={state?.status}
|
|
data-slot="entity-table"
|
|
>
|
|
<div
|
|
className={`flex shrink-0 flex-wrap justify-between border-b ${layout === "fill" ? "items-center gap-2 p-2" : "items-start gap-3 p-4"}`}
|
|
>
|
|
<div
|
|
className={
|
|
layout === "fill"
|
|
? "min-w-0 flex-1 @min-[32rem]/entity-table:max-w-sm"
|
|
: "w-full sm:max-w-sm"
|
|
}
|
|
>
|
|
<label className="sr-only" htmlFor={`${id}-search`}>
|
|
{searchPlaceholder ?? labels.search}
|
|
</label>
|
|
<Input
|
|
data-quick-search=""
|
|
id={`${id}-search`}
|
|
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
|
|
placeholder={searchPlaceholder ?? labels.placeholder}
|
|
ref={searchRef}
|
|
value={currentSearch}
|
|
/>
|
|
</div>
|
|
{layout === "fill" && configuration}
|
|
<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>
|
|
{layout !== "fill" && (
|
|
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<EntityListStatus
|
|
onRetry={onRetry}
|
|
retryFocus={() => searchRef.current}
|
|
retryLabel={labels.retry}
|
|
state={availability.summaryState}
|
|
>
|
|
{info}
|
|
</EntityListStatus>
|
|
</div>
|
|
{configuration}
|
|
</div>
|
|
)}
|
|
|
|
<EntityTableContent
|
|
columns={columns}
|
|
loading={pending}
|
|
message={message}
|
|
onRowClick={onRowClick}
|
|
onSort={resetSortPage}
|
|
rowClassName={rowClassName}
|
|
state={state}
|
|
table={table}
|
|
title={title}
|
|
unavailable={missing}
|
|
/>
|
|
|
|
<footer
|
|
className={`flex shrink-0 flex-wrap items-center justify-between border-t ${layout === "fill" ? "gap-2 px-2 py-2" : "gap-3 px-4 py-3"}`}
|
|
>
|
|
<p
|
|
aria-live={layout === "fill" ? availability.legacyAnnouncement : undefined}
|
|
className="text-sm text-muted-foreground"
|
|
>
|
|
{info}
|
|
</p>
|
|
<EntityListPagination
|
|
disabled={availability.paginationDisabled}
|
|
id={id}
|
|
locale={locale}
|
|
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
|
|
onPageSize={(pageSize) => setPagination({ pageIndex: 0, pageSize })}
|
|
page={range.page}
|
|
pageSize={pagination.pageSize}
|
|
pages={range.pages}
|
|
presentation={layout === "fill" ? "picker" : "full"}
|
|
/>
|
|
</footer>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function entityTableInfo(
|
|
loading: boolean,
|
|
unavailable: boolean,
|
|
loadingLabel: string,
|
|
info: string
|
|
) {
|
|
if (loading) {
|
|
return loadingLabel;
|
|
}
|
|
|
|
return unavailable ? "—" : info;
|
|
}
|
|
|
|
function entityTableLabel(
|
|
label: string | ((count: number) => string),
|
|
total: number,
|
|
locale: string
|
|
) {
|
|
return (typeof label === "function" ? label(total) : label).toLocaleLowerCase(locale);
|
|
}
|
|
|
|
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);
|
|
}
|