290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Link, Outlet, useActionData, useLocation, useNavigation, useSearchParams, useSubmit } from "react-router";
|
|
import RowsList from "~/components/entities/rows/RowsList";
|
|
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
|
import InputFilters, { FilterDto } from "~/components/ui/input/InputFilters";
|
|
import TabsWithIcons from "~/components/ui/tabs/TabsWithIcons";
|
|
import EntityHelper from "~/utils/helpers/EntityHelper";
|
|
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
|
import { EntityViewWithDetails } from "~/utils/db/entities/entityViews.db.server";
|
|
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
|
import { useAppData } from "~/utils/data/useAppData";
|
|
import EntityViewForm from "~/components/entities/views/EntityViewForm";
|
|
import { UserSimple } from "~/utils/db/users.db.server";
|
|
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
|
import { Rows_List } from "../routes/Rows_List.server";
|
|
import { toast } from "sonner";
|
|
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
|
import ConfirmModal, { RefConfirmModal } from "~/components/ui/modals/ConfirmModal";
|
|
import TrashIcon from "~/components/ui/icons/TrashIcon";
|
|
import clsx from "clsx";
|
|
import { translateEntityTitle, translateEntityTitlePlural, translateEntityViewTitle } from "~/utils/i18n/translateLabel";
|
|
import { useLocaleFormatters } from "~/locale/format";
|
|
|
|
interface Props {
|
|
title?: ReactNode;
|
|
rowsData: RowsApi.GetRowsData;
|
|
items: RowWithDetails[];
|
|
routes?: EntitiesApi.Routes;
|
|
onNewRow?: () => void;
|
|
onEditRow?: (item: RowWithDetails) => void;
|
|
saveCustomViews?: boolean;
|
|
permissions: {
|
|
create: boolean;
|
|
};
|
|
currentSession: {
|
|
user: UserSimple;
|
|
isSuperAdmin: boolean;
|
|
} | null;
|
|
}
|
|
export default function RowsViewRoute({ title, rowsData, items, routes, onNewRow, onEditRow, saveCustomViews, permissions, currentSession }: Props) {
|
|
const { t, i18n } = useTranslation();
|
|
const formatters = useLocaleFormatters();
|
|
const actionData = useActionData<Rows_List.ActionData>();
|
|
const appData = useAppData();
|
|
const submit = useSubmit();
|
|
const location = useLocation();
|
|
const [searchParams] = useSearchParams();
|
|
|
|
const confirmDeleteRows = useRef<RefConfirmModal>(null);
|
|
|
|
const [bulkActions, setBulkActions] = useState<string[]>([]);
|
|
|
|
const [view, setView] = useState(rowsData.currentView?.layout ?? searchParams.get("view") ?? "table");
|
|
const [filters, setFilters] = useState<FilterDto[]>([]);
|
|
|
|
const [showCustomViewModal, setShowCustomViewModal] = useState(false);
|
|
const [editingView, setEditingView] = useState<EntityViewWithDetails | null>(null);
|
|
|
|
const [selectedRows, setSelectedRows] = useState<RowWithDetails[]>([]);
|
|
|
|
useEffect(() => {
|
|
setFilters(EntityHelper.getFilters({ t, entity: rowsData.entity, pagination: rowsData.pagination }));
|
|
const bulkActions: string[] = [];
|
|
if (rowsData.entity.hasBulkDelete) {
|
|
bulkActions.push("bulk-delete");
|
|
}
|
|
setBulkActions(bulkActions);
|
|
}, [rowsData, t]);
|
|
|
|
useEffect(() => {
|
|
const newView = rowsData.currentView?.layout ?? searchParams.get("view") ?? "table";
|
|
setView(newView);
|
|
}, [searchParams, rowsData.entity, rowsData.currentView?.layout]);
|
|
|
|
useEffect(() => {
|
|
if (actionData?.error) {
|
|
toast.error(actionData.error);
|
|
} else if (actionData?.success) {
|
|
toast.success(actionData.success);
|
|
setSelectedRows([]);
|
|
} else if (actionData?.rowsDeleted) {
|
|
setSelectedRows((rows) => rows.filter((row) => !actionData?.rowsDeleted?.includes(row.id)));
|
|
}
|
|
if (actionData?.updatedView) {
|
|
setShowCustomViewModal(false);
|
|
setEditingView(null);
|
|
}
|
|
}, [actionData]);
|
|
|
|
useEffect(() => {
|
|
setShowCustomViewModal(false);
|
|
setEditingView(null);
|
|
}, [searchParams]);
|
|
|
|
function onCreateView() {
|
|
setShowCustomViewModal(true);
|
|
setEditingView(null);
|
|
}
|
|
|
|
function onUpdateView() {
|
|
setShowCustomViewModal(true);
|
|
setEditingView(rowsData.currentView);
|
|
}
|
|
|
|
function isCurrenView(view: EntityViewWithDetails) {
|
|
return rowsData.currentView?.id === view.id;
|
|
}
|
|
|
|
function canUpdateCurrentView() {
|
|
if (currentSession?.isSuperAdmin) {
|
|
return true;
|
|
}
|
|
if (!rowsData.currentView) {
|
|
return false;
|
|
}
|
|
if (rowsData.currentView.userId === currentSession?.user.id) {
|
|
return true;
|
|
}
|
|
if (appData?.currentTenant?.id && rowsData.currentView.tenantId === appData?.currentTenant.id && appData?.isSuperUser) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function onDeleteSelectedRows() {
|
|
confirmDeleteRows.current?.show(t("shared.confirmDelete"), t("shared.delete"), t("shared.cancel"), t("shared.warningCannotUndo"));
|
|
}
|
|
function onDeleteSelectedRowsConfirmed() {
|
|
const form = new FormData();
|
|
form.set("action", "bulk-delete");
|
|
selectedRows.forEach((item) => {
|
|
form.append("rowIds[]", item.id);
|
|
});
|
|
submit(form, {
|
|
method: "post",
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="mx-auto w-full max-w-5xl space-y-3 px-4 py-2 pb-6 sm:px-6 sm:pt-3 lg:px-8 xl:max-w-full">
|
|
<div className="flex items-center justify-between space-x-2 md:py-2">
|
|
{selectedRows.length > 0 ? (
|
|
<div className="flex space-x-1">{bulkActions.includes("bulk-delete") && <DeleteIconButton onClick={onDeleteSelectedRows} />}</div>
|
|
) : (
|
|
<Fragment>
|
|
{rowsData.views.length > 1 ? (
|
|
<TabsWithIcons
|
|
className="grow xl:flex"
|
|
tabs={rowsData.views.map((item) => {
|
|
// if (views.find((f) => f.name === item.name && f.isDefault)) {
|
|
// searchParams.delete("v");
|
|
// } else {
|
|
searchParams.set("v", item.name);
|
|
// }
|
|
searchParams.delete("page");
|
|
return {
|
|
name: translateEntityViewTitle(t, i18n, rowsData.entity.name, item),
|
|
href: location.pathname + "?" + searchParams.toString(),
|
|
current: isCurrenView(item),
|
|
};
|
|
})}
|
|
/>
|
|
) : (
|
|
title ?? (
|
|
<h3 className="flex flex-1 items-center truncate font-bold">
|
|
{rowsData.currentView
|
|
? translateEntityViewTitle(t, i18n, rowsData.entity.name, rowsData.currentView)
|
|
: translateEntityTitlePlural(t, i18n, rowsData.entity)}
|
|
</h3>
|
|
)
|
|
)}
|
|
</Fragment>
|
|
)}
|
|
<div className="flex items-center space-x-1">
|
|
{filters.length > 0 && <InputFilters filters={filters} />}
|
|
{permissions.create && (
|
|
<ButtonPrimary disabled={!permissions.create} to={!onNewRow ? "new" : undefined} onClick={onNewRow}>
|
|
<span aria-hidden="true" className="sm:text-sm">
|
|
+
|
|
</span>
|
|
<span className="sr-only">
|
|
{t("shared.new")} {translateEntityTitle(t, i18n, rowsData.entity)}
|
|
</span>
|
|
</ButtonPrimary>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<RowsList
|
|
view={view as "table" | "board" | "grid" | "card"}
|
|
entity={rowsData.entity}
|
|
items={items}
|
|
routes={routes}
|
|
pagination={rowsData.pagination}
|
|
onEditRow={onEditRow}
|
|
currentView={rowsData.currentView}
|
|
selectedRows={selectedRows}
|
|
onSelected={!bulkActions.length ? undefined : (rows) => setSelectedRows(rows)}
|
|
/>
|
|
<div className="mt-2 flex items-center justify-between space-x-2">
|
|
<div>
|
|
<div className="hidden sm:block">
|
|
{rowsData.pagination && rowsData.pagination.totalItems > 0 && routes && (
|
|
<Link
|
|
className="text-muted-foreground text-xs font-medium hover:underline"
|
|
to={EntityHelper.getRoutes({ routes, entity: rowsData.entity })?.export + "?" + searchParams}
|
|
reloadDocument
|
|
>
|
|
<div>{t("shared.exportResults", { count: rowsData.pagination.totalItems, formattedCount: formatters.number(rowsData.pagination.totalItems) })}</div>
|
|
</Link>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{saveCustomViews && rowsData.entity.hasViews && (
|
|
<Fragment>
|
|
{canUpdateCurrentView() ? (
|
|
<div className="text-muted-foreground flex items-center space-x-2">
|
|
<button type="button" className="text-xs font-medium hover:underline" disabled={!canUpdateCurrentView()} onClick={onUpdateView}>
|
|
{t("models.view.actions.update")}
|
|
</button>
|
|
<div>•</div>
|
|
<button type="button" className="text-xs font-medium hover:underline" onClick={onCreateView}>
|
|
{t("models.view.actions.create")}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button type="button" className="text-muted-foreground text-xs font-medium hover:underline" onClick={onCreateView}>
|
|
{t("models.view.actions.create")}
|
|
</button>
|
|
)}
|
|
</Fragment>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Outlet />
|
|
|
|
<ConfirmModal ref={confirmDeleteRows} onYes={onDeleteSelectedRowsConfirmed} />
|
|
|
|
<div className="z-50">
|
|
<SlideOverWideEmpty
|
|
title={editingView ? t("models.view.actions.edit") : t("models.view.actions.newForEntity", { entity: translateEntityTitle(t, i18n, rowsData.entity) })}
|
|
className="sm:max-w-2xl"
|
|
open={showCustomViewModal}
|
|
onClose={() => setShowCustomViewModal(false)}
|
|
>
|
|
{showCustomViewModal && (
|
|
<EntityViewForm
|
|
entity={rowsData.entity}
|
|
tenantId={appData.currentTenant?.id ?? null}
|
|
userId={currentSession?.user.id ?? null}
|
|
item={editingView}
|
|
canDelete={true}
|
|
onClose={() => setShowCustomViewModal(false)}
|
|
actionNames={{
|
|
create: "view-create",
|
|
update: "view-edit",
|
|
delete: "view-delete",
|
|
}}
|
|
isSystem={false}
|
|
showViewType={currentSession?.isSuperAdmin ?? false}
|
|
/>
|
|
)}
|
|
</SlideOverWideEmpty>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeleteIconButton({ onClick }: { onClick: () => void }) {
|
|
const navigation = useNavigation();
|
|
const { t } = useTranslation();
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={t("shared.delete")}
|
|
className={clsx(
|
|
"hover:bg-secondary/90 focus:bg-secondary/90 group flex items-center rounded-md border border-transparent px-4 py-2 focus:outline-hidden focus:ring-2 focus:ring-gray-400 focus:ring-offset-1",
|
|
navigation.state === "submitting" && navigation.formData?.get("action") === "bulk-delete" && "base-spinner"
|
|
)}
|
|
disabled={navigation.state !== "idle"}
|
|
onClick={onClick}
|
|
>
|
|
<TrashIcon aria-hidden="true" className="group-hover:text-muted-foreground h-4 w-4 text-gray-300" />
|
|
</button>
|
|
);
|
|
}
|