383 lines
15 KiB
TypeScript
383 lines
15 KiB
TypeScript
import clsx from "clsx";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useNavigate, useSearchParams } from "react-router";
|
|
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
|
import { RowHeaderDisplayDto } from "~/application/dtos/data/RowHeaderDisplayDto";
|
|
import RowDisplayValueHelper from "~/utils/helpers/RowDisplayValueHelper";
|
|
import ButtonTertiary from "../buttons/ButtonTertiary";
|
|
import TablePagination from "./TablePagination";
|
|
import { ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { RowHeaderActionDto } from "~/application/dtos/data/RowHeaderActionDto";
|
|
import DownArrow from "../icons/DownArrow";
|
|
import UpArrow from "../icons/UpArrow";
|
|
import ConfirmModal, { RefConfirmModal } from "../modals/ConfirmModal";
|
|
import { Checkbox } from "../checkbox";
|
|
import { translateLabel } from "~/utils/i18n/translateLabel";
|
|
|
|
interface Props<T> {
|
|
headers: RowHeaderDisplayDto<T>[];
|
|
items: T[];
|
|
actions?: RowHeaderActionDto<T>[];
|
|
pagination?: PaginationDto;
|
|
onClickRoute?: (idx: number, item: T) => string | undefined;
|
|
selectedRows?: T[];
|
|
onSelected?: (item: T[]) => void;
|
|
className?: (idx: number, item: T) => string;
|
|
padding?: string;
|
|
noRecords?: ReactNode;
|
|
emptyState?: { title: string; description: string; icon?: ReactNode };
|
|
darkMode?: boolean;
|
|
}
|
|
|
|
export default function TableSimple<T>(props: Props<T>) {
|
|
const [showChild, setShowChild] = useState(false);
|
|
|
|
// Wait until after client-side hydration to show
|
|
useEffect(() => {
|
|
setShowChild(true);
|
|
}, []);
|
|
|
|
if (!showChild) {
|
|
// You can show some kind of placeholder UI here
|
|
return null;
|
|
}
|
|
|
|
return <Table {...props} />;
|
|
}
|
|
|
|
function Table<T>({
|
|
headers,
|
|
items,
|
|
actions = [],
|
|
pagination,
|
|
onClickRoute,
|
|
selectedRows,
|
|
onSelected,
|
|
className,
|
|
// Subframe Cell padding: px-4 py-1 (was px-2 py-2).
|
|
padding = "px-4 py-1",
|
|
noRecords,
|
|
emptyState,
|
|
darkMode,
|
|
}: Props<T>) {
|
|
const { t, i18n } = useTranslation();
|
|
const navigate = useNavigate();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [sortBy, setSortBy] = useState<{ by: string; order: "asc" | "desc" }[]>();
|
|
const visibleHeaders = useMemo(() => headers.filter((header) => !header.hidden), [headers]);
|
|
const firstColumnActions = useMemo(() => actions.filter((action) => action.firstColumn), [actions]);
|
|
const trailingActions = useMemo(() => actions.filter((action) => !action.firstColumn), [actions]);
|
|
const selectedRowsSet = useMemo(() => new Set(selectedRows ?? []), [selectedRows]);
|
|
|
|
useEffect(() => {
|
|
let sort = searchParams.get("sort");
|
|
const sortArray = sort?.split(",") ?? [];
|
|
const sortObject = sortArray.map((s) => {
|
|
let order: "asc" | "desc" = "asc";
|
|
if (s.startsWith("-")) {
|
|
order = "desc";
|
|
}
|
|
return { by: s.replace("-", ""), order };
|
|
});
|
|
setSortBy(sortObject);
|
|
}, [searchParams]);
|
|
// const [selectedRows, setSelectedRows] = useState<T[]>([]);
|
|
|
|
// useEffect(() => {
|
|
// if (onSelected) {
|
|
// onSelected(selectedRows);
|
|
// }
|
|
// }, [selectedRows]);
|
|
|
|
function toggleSelected(_: number, item: T) {
|
|
if (!selectedRows || !onSelected) {
|
|
return;
|
|
}
|
|
if (selectedRowsSet.has(item)) {
|
|
onSelected(selectedRows.filter((i) => i !== item));
|
|
} else {
|
|
onSelected([...selectedRows, item]);
|
|
}
|
|
}
|
|
|
|
const checkbox = useRef(null);
|
|
const [checked, setChecked] = useState(false);
|
|
const [indeterminate, setIndeterminate] = useState(false);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!selectedRows || !onSelected) {
|
|
return;
|
|
}
|
|
const isIndeterminate = selectedRows.length > 0 && selectedRows.length < items.length;
|
|
setChecked(selectedRows.length === items.length && items.length > 0);
|
|
setIndeterminate(isIndeterminate);
|
|
// @ts-ignore
|
|
checkbox.current.indeterminate = isIndeterminate;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [onSelected, selectedRows]);
|
|
|
|
function toggleAll() {
|
|
if (!selectedRows || !onSelected) {
|
|
return;
|
|
}
|
|
onSelected(checked || indeterminate ? [] : items);
|
|
setChecked(!checked && !indeterminate);
|
|
setIndeterminate(false);
|
|
}
|
|
|
|
function onHeaderClick(header: RowHeaderDisplayDto<T>) {
|
|
if (!header.sortBy) {
|
|
return;
|
|
}
|
|
let currentSort = sortBy?.find((s) => s.by === header.sortBy);
|
|
let newSort = header.sortBy;
|
|
if (currentSort?.order === "asc") {
|
|
newSort = `-${header.sortBy}`;
|
|
}
|
|
searchParams.set("sort", newSort);
|
|
setSearchParams(searchParams);
|
|
}
|
|
|
|
function getSortDirection(header: RowHeaderDisplayDto<T>) {
|
|
if (!header.sortBy) {
|
|
return;
|
|
}
|
|
let currentSort = sortBy?.find((s) => s.by === header.sortBy);
|
|
if (!currentSort) {
|
|
return;
|
|
}
|
|
return currentSort.order;
|
|
}
|
|
|
|
return (
|
|
<div className={clsx("shadow-2xs border-border-default w-full overflow-hidden rounded-lg border", darkMode && "")}>
|
|
{/* tabIndex makes the horizontally-scrollable region keyboard-reachable
|
|
(axe scrollable-region-focusable) when the table overflows on mobile. */}
|
|
<div className="bg-surface-neutral-primary w-full overflow-x-auto" tabIndex={0}>
|
|
{/* Subframe Table root layout: full-width, fit height, collapsed borders. */}
|
|
<table className="whitespace-no-wrap h-fit w-full border-collapse">
|
|
<thead className={clsx("", darkMode && "")}>
|
|
{/* Subframe header row typo: extra-small medium label on secondary text. */}
|
|
<tr
|
|
className={clsx(
|
|
"border-border-default text-text-neutral-secondary text-body-extra-small-medium-0 font-body-extra-small-medium-0 border-b text-left tracking-wide",
|
|
darkMode && ""
|
|
)}
|
|
>
|
|
{firstColumnActions.length > 0 && <th scope="col" className="px-2 py-1"></th>}
|
|
{onSelected && (
|
|
<th scope="col" className="relative w-10 px-6 py-5 sm:w-12 sm:px-6">
|
|
<Checkbox
|
|
title={t("shared.selectAll")}
|
|
ref={checkbox}
|
|
className="text-text-neutral-on-solid focus:shadow-focus border-border-default absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded sm:left-6"
|
|
checked={checked}
|
|
onCheckedChange={toggleAll}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</th>
|
|
)}
|
|
{visibleHeaders.map((header, idxHeader) => {
|
|
return (
|
|
<th
|
|
key={idxHeader}
|
|
scope="col"
|
|
onClick={() => onHeaderClick(header)}
|
|
className={clsx(
|
|
idxHeader === 0 && firstColumnActions.length === 0 && "pl-4",
|
|
// Subframe HeaderCell measures: h-10 row, px-4 cell padding.
|
|
"h-10 whitespace-nowrap px-4 tracking-wider",
|
|
header.breakpoint === "sm" && "hidden sm:table-cell",
|
|
header.breakpoint === "md" && "hidden md:table-cell",
|
|
header.breakpoint === "lg" && "hidden lg:table-cell",
|
|
header.breakpoint === "xl" && "hidden xl:table-cell",
|
|
header.breakpoint === "2xl" && "hidden 2xl:table-cell",
|
|
header.sortBy && "cursor-pointer"
|
|
)}
|
|
>
|
|
<div
|
|
className={clsx(
|
|
"group flex space-x-2",
|
|
!header.align && "justify-between",
|
|
header.align === "right" && "justify-end",
|
|
header.align === "center" && "justify-center",
|
|
header.align === "left" && "justify-start"
|
|
)}
|
|
>
|
|
<div className={clsx(header.className, "select-none truncate")}>{translateLabel(t, i18n, header.title)}</div>
|
|
{header.sortBy && (
|
|
<div className="text text-text-neutral-tertiary group-hover:text-text-neutral-primary">
|
|
{getSortDirection(header) === "desc" ? (
|
|
<DownArrow className="h-4 w-4" />
|
|
) : (
|
|
getSortDirection(header) === "asc" && <UpArrow className="h-4 w-4" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</th>
|
|
);
|
|
})}
|
|
{trailingActions.length > 0 && <th scope="col" className="px-2 py-1"></th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody className={clsx("divide-divide divide-y", darkMode && "")}>
|
|
{items.length === 0 ? (
|
|
<tr className={clsx("", darkMode && "")}>
|
|
<td colSpan={visibleHeaders.length + actions.length + (onSelected ? 1 : 0)} className="text-center">
|
|
{noRecords ?? (
|
|
<div className="text-text-neutral-tertiary p-3 text-sm">
|
|
{!emptyState ? (
|
|
<span>{t("shared.noRecords")}</span>
|
|
) : (
|
|
<div className="space-y-1">
|
|
<div className="font-medium">{emptyState.title}</div>
|
|
<div>{emptyState.description}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
items.map((item, idxRow) => {
|
|
const href = onClickRoute?.(idxRow, item);
|
|
return (
|
|
<tr
|
|
key={idxRow}
|
|
onClick={
|
|
href
|
|
? (e) => {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
window.open(href, "_blank");
|
|
} else {
|
|
navigate(href);
|
|
}
|
|
}
|
|
: undefined
|
|
}
|
|
className={clsx(
|
|
// Subframe Cell minimum row height (min-h-[48px]).
|
|
"group h-12",
|
|
href && "hover:bg-surface-neutral-secondary cursor-pointer",
|
|
darkMode && ""
|
|
)}
|
|
>
|
|
<ActionsCells actions={firstColumnActions} className={className} item={item} idxRow={idxRow} />
|
|
{onSelected && (
|
|
<td className={clsx("relative w-10 px-6 sm:w-12 sm:px-6", darkMode && "")}>
|
|
{selectedRowsSet.has(item) && <div className="bg-brand-brand-surface-solid-default absolute inset-y-0 left-0 w-0.5" />}
|
|
<div className="flex items-center space-x-1">
|
|
<Checkbox
|
|
title={t("shared.select")}
|
|
className="text-text-neutral-on-solid focus:shadow-focus border-border-default absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded sm:left-6"
|
|
checked={selectedRowsSet.has(item)}
|
|
onCheckedChange={(e) => {
|
|
toggleSelected(idxRow, item);
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
</td>
|
|
)}
|
|
{visibleHeaders.map((header, idxHeader) => {
|
|
return (
|
|
<td
|
|
key={idxHeader}
|
|
className={clsx(
|
|
idxHeader === 0 && firstColumnActions.length === 0 && "pl-4",
|
|
"whitespace-nowrap text-sm",
|
|
darkMode && "",
|
|
header.className,
|
|
padding ?? "px-4 py-1",
|
|
header.breakpoint === "sm" && "hidden sm:table-cell",
|
|
header.breakpoint === "md" && "hidden md:table-cell",
|
|
header.breakpoint === "lg" && "hidden lg:table-cell",
|
|
header.breakpoint === "xl" && "hidden xl:table-cell",
|
|
header.breakpoint === "2xl" && "hidden 2xl:table-cell",
|
|
className && className(idxRow, item)
|
|
)}
|
|
>
|
|
{RowDisplayValueHelper.displayRowValue(t, header, item, idxRow)}
|
|
</td>
|
|
);
|
|
})}
|
|
<ActionsCells actions={trailingActions} className={className} item={item} idxRow={idxRow} />
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{pagination && (
|
|
<TablePagination totalItems={pagination.totalItems} totalPages={pagination.totalPages} page={pagination.page} pageSize={pagination.pageSize} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionsCells<T>({
|
|
item,
|
|
actions,
|
|
idxRow,
|
|
className,
|
|
}: {
|
|
item: T;
|
|
actions: RowHeaderActionDto<T>[];
|
|
idxRow: number;
|
|
className?: (idx: number, item: T) => string;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const refConfirm = useRef<RefConfirmModal>(null);
|
|
function onConfirmed({ action, item }: { action: RowHeaderActionDto<T>; item: T }) {
|
|
if (action.onClick) {
|
|
action.onClick(idxRow, item);
|
|
}
|
|
}
|
|
return (
|
|
<>
|
|
{actions && actions.length > 0 && (
|
|
<td className={clsx("whitespace-nowrap px-4 py-1", className && className(idxRow, item))}>
|
|
<div className="flex space-x-2">
|
|
{actions
|
|
.filter((f) => !f.hidden || !f.hidden(item))
|
|
.map((action, idx) => {
|
|
return (
|
|
<ButtonTertiary
|
|
disabled={action.disabled !== undefined ? action.disabled(item) : action.disabled}
|
|
key={idx}
|
|
destructive={action.renderIsDestructive !== undefined ? action.renderIsDestructive(item) : action.destructive}
|
|
prefetch={action.prefetch}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
if (action.onClick) {
|
|
if (action.confirmation) {
|
|
const confirmation = action.confirmation(item);
|
|
refConfirm.current?.setDestructive(action.destructive || false);
|
|
refConfirm.current?.setValue({
|
|
action: action,
|
|
item: item,
|
|
});
|
|
refConfirm.current?.show(confirmation.title, t("shared.confirm"), t("shared.cancel"), confirmation.description);
|
|
} else {
|
|
action.onClick(idxRow, item);
|
|
}
|
|
}
|
|
}}
|
|
to={action.onClickRoute && action.onClickRoute(idxRow, item)}
|
|
target={action.onClickRouteTarget}
|
|
>
|
|
{action.renderTitle ? action.renderTitle(item) : action.title}
|
|
</ButtonTertiary>
|
|
);
|
|
})}
|
|
</div>
|
|
<ConfirmModal ref={refConfirm} onYes={onConfirmed} />
|
|
</td>
|
|
)}
|
|
</>
|
|
);
|
|
}
|