Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/components/ui/tables/TableSimple.tsx
T

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>
)}
</>
);
}