Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Outlet, useLoaderData, useNavigate, useParams } from "react-router";
|
||||
import RowForm from "~/components/entities/rows/RowForm";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { getEntityPermission, getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import NewPageLayout from "~/components/ui/layouts/NewPageLayout";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import { Rows_Edit } from "../routes/Rows_Edit.server";
|
||||
import { translateEntityTitle } from "~/utils/i18n/translateLabel";
|
||||
|
||||
type EditRowOptions = {
|
||||
hideTitle?: boolean;
|
||||
hideMenu?: boolean;
|
||||
hideShare?: boolean;
|
||||
hideTags?: boolean;
|
||||
hideTasks?: boolean;
|
||||
hideActivity?: boolean;
|
||||
disableUpdate?: boolean;
|
||||
disableDelete?: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
layout?: "edit" | "simple";
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
rowFormChildren?: ReactNode;
|
||||
options?: EditRowOptions;
|
||||
}
|
||||
|
||||
export default function RowEditRoute({ rowFormChildren, options }: Props) {
|
||||
const { rowData, routes, allEntities, relationshipRows } = useLoaderData<Rows_Edit.LoaderData>();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<NewPageLayout
|
||||
title={t("shared.edit") + " " + translateEntityTitle(t, i18n, rowData.entity)}
|
||||
menu={EntityHelper.getLayoutBreadcrumbsMenu({ type: "edit", t, appOrAdminData, entity: rowData.entity, item: rowData.item, params, routes })}
|
||||
>
|
||||
<RowForm
|
||||
allEntities={allEntities}
|
||||
entity={rowData.entity}
|
||||
routes={routes}
|
||||
item={rowData.item}
|
||||
editing={true}
|
||||
canDelete={
|
||||
!options?.disableDelete &&
|
||||
getUserHasPermission(appOrAdminData, getEntityPermission(rowData.entity, "delete")) &&
|
||||
(rowData.rowPermissions.canDelete || appOrAdminData.isSuperUser)
|
||||
}
|
||||
canUpdate={
|
||||
!options?.disableUpdate &&
|
||||
getUserHasPermission(appOrAdminData, getEntityPermission(rowData.entity, "update")) &&
|
||||
(rowData.rowPermissions.canDelete || appOrAdminData.isSuperUser)
|
||||
}
|
||||
relationshipRows={relationshipRows}
|
||||
onCancel={() => navigate(EntityHelper.getRoutes({ routes, entity: rowData.entity, item: rowData.item })?.overview ?? "")}
|
||||
>
|
||||
{rowFormChildren}
|
||||
</RowForm>
|
||||
<Outlet />
|
||||
</NewPageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, Outlet, useActionData, useLoaderData, useParams } from "react-router";
|
||||
import CheckPlanFeatureLimit from "~/components/core/settings/subscription/CheckPlanFeatureLimit";
|
||||
import RowForm from "~/components/entities/rows/RowForm";
|
||||
import NewPageLayout from "~/components/ui/layouts/NewPageLayout";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import clsx from "clsx";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import { Rows_New } from "../routes/Rows_New.server";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import toast from "react-hot-toast";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { translateEntityTitle, translatePropertyTitle } from "~/utils/i18n/translateLabel";
|
||||
|
||||
interface Props {
|
||||
showBreadcrumb?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
export default function RowNewRoute({ showBreadcrumb = true, className }: Props) {
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const data = useLoaderData<Rows_New.LoaderData>();
|
||||
const params = useParams();
|
||||
const { t, i18n } = useTranslation();
|
||||
const actionData = useActionData<Rows_New.ActionData>();
|
||||
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<{ title: string; config: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
}
|
||||
}, [actionData, t]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<NewPageLayout
|
||||
className={className}
|
||||
title={t("models.row.actions.createForEntity", { entity: translateEntityTitle(t, i18n, data.entityData.entity) })}
|
||||
menu={
|
||||
showBreadcrumb
|
||||
? EntityHelper.getLayoutBreadcrumbsMenu({
|
||||
type: "new",
|
||||
t,
|
||||
appOrAdminData,
|
||||
entity: data.entityData.entity,
|
||||
item: undefined,
|
||||
params,
|
||||
routes: data.routes,
|
||||
})
|
||||
: []
|
||||
}
|
||||
>
|
||||
{data.entityData.entity.templates.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{/* <label htmlFor="template" className="text-sm font-medium leading-3 text-foreground">
|
||||
{t("models.entity.templates")}
|
||||
</label> */}
|
||||
<div className="flex flex-wrap space-x-2">
|
||||
{data.entityData.entity.templates
|
||||
.sort((a, b) => (new Date(b.createdAt).getTime() > new Date(a.createdAt).getTime() ? -1 : 1))
|
||||
.map((template) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={template.title}
|
||||
className={clsx(
|
||||
"bg-background rounded-md border p-2 text-sm font-medium",
|
||||
selectedTemplate?.title === template.title
|
||||
? "border-border border"
|
||||
: "hover:bg-secondary border-border border-dashed hover:border-dotted"
|
||||
)}
|
||||
onClick={() => setSelectedTemplate(template)}
|
||||
>
|
||||
{template.title}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{actionData?.newRow && actionData?.saveAndAdd ? (
|
||||
<RowCreated entityData={data.entityData} routes={data.routes} newRow={actionData?.newRow} />
|
||||
) : (
|
||||
<CheckPlanFeatureLimit item={data.entityData.featureUsageEntity}>
|
||||
<RowForm
|
||||
key={actionData?.newRow?.id || selectedTemplate?.title}
|
||||
entity={data.entityData.entity}
|
||||
routes={data.routes}
|
||||
onCreatedRedirect={data.entityData.entity.onCreated ?? undefined}
|
||||
allEntities={data.allEntities}
|
||||
relationshipRows={data.relationshipRows}
|
||||
adding={true}
|
||||
template={selectedTemplate}
|
||||
/>
|
||||
<Outlet />
|
||||
</CheckPlanFeatureLimit>
|
||||
)}
|
||||
</div>
|
||||
</NewPageLayout>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
function RowCreated({ entityData, routes, newRow }: { entityData: EntitiesApi.GetEntityData; routes: EntitiesApi.Routes; newRow: RowWithDetails }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
// const params = useParams();
|
||||
return (
|
||||
<div>
|
||||
{/* <NotificationSimple /> */}
|
||||
<div className="text-foreground text-sm font-medium">{t("shared.created")}</div>
|
||||
<div className="border-border bg-background hover:border-border group relative mt-2 w-full rounded-md border-2 border-dashed text-left text-sm">
|
||||
<Link to={EntityHelper.getRoutes({ routes, entity: entityData.entity, item: newRow })?.overview ?? ""} className="grid grid-cols-2 gap-2 px-4 py-3">
|
||||
{entityData.entity.properties.filter((f) => f.isDisplay).length === 0 ? (
|
||||
<div>{RowHelper.getTextDescription({ entity: entityData.entity, item: newRow, t })}</div>
|
||||
) : (
|
||||
<>
|
||||
{entityData.entity.properties
|
||||
.filter((f) => f.isDisplay)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((property, idx) => (
|
||||
<div key={property.id} className={clsx(idx === 0 ? "text-foreground/80 font-medium" : "text-muted-foreground")}>
|
||||
<div className="flex flex-col">
|
||||
<div className="text-muted-foreground text-xs uppercase">
|
||||
{translatePropertyTitle(t, i18n, entityData.entity.name, property)}
|
||||
</div>
|
||||
<div>{RowHelper.getCellValue({ entity: entityData.entity, property, item: newRow })}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-2 flex justify-between space-x-2">
|
||||
<ButtonSecondary to={EntityHelper.getRoutes({ routes, entity: entityData.entity })?.list}>{t("shared.viewAll")}</ButtonSecondary>
|
||||
|
||||
<div className="items-between flex space-x-2">
|
||||
<ButtonSecondary to={EntityHelper.getRoutes({ routes, entity: entityData.entity, item: newRow })?.overview}>{t("shared.view")}</ButtonSecondary>
|
||||
<ButtonPrimary autoFocus to=".">
|
||||
{t("shared.addAnother")}
|
||||
</ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { Fragment, ReactNode, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useOutlet, useSearchParams, useParams, useActionData } from "react-router";
|
||||
import RowActivity from "~/components/entities/rows/RowActivity";
|
||||
import RowForm from "~/components/entities/rows/RowForm";
|
||||
import RowTags from "~/components/entities/rows/RowTags";
|
||||
import RowTasks from "~/components/entities/rows/RowTasks";
|
||||
import EditPageLayout from "~/components/ui/layouts/EditPageLayout";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { getEntityPermission, getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
import clsx from "clsx";
|
||||
import { Rows_Overview } from "../routes/Rows_Overview.server";
|
||||
import toast from "react-hot-toast";
|
||||
import ActionResultModal from "~/components/ui/modals/ActionResultModal";
|
||||
import RowOverviewHeader from "~/components/entities/rows/RowOverviewHeader";
|
||||
import { translateEntityTitle } from "~/utils/i18n/translateLabel";
|
||||
|
||||
type EditRowOptions = {
|
||||
hideTitle?: boolean;
|
||||
hideMenu?: boolean;
|
||||
hideShare?: boolean;
|
||||
hideTags?: boolean;
|
||||
hideTasks?: boolean;
|
||||
hideActivity?: boolean;
|
||||
disableUpdate?: boolean;
|
||||
disableDelete?: boolean;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
rowData: RowsApi.GetRowData;
|
||||
item: RowWithDetails;
|
||||
// permissions: RowPermission[];
|
||||
routes?: EntitiesApi.Routes;
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
rowFormChildren?: ReactNode;
|
||||
afterRowForm?: ReactNode;
|
||||
options?: EditRowOptions;
|
||||
relationshipRows?: RowsApi.GetRelationshipRowsData;
|
||||
onSubmit?: (formData: FormData) => void;
|
||||
}
|
||||
export default function RowOverviewRoute({
|
||||
rowData,
|
||||
item,
|
||||
routes,
|
||||
children,
|
||||
title,
|
||||
rowFormChildren,
|
||||
afterRowForm,
|
||||
options,
|
||||
relationshipRows,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const params = useParams();
|
||||
const outlet = useOutlet();
|
||||
const navigate = useNavigate();
|
||||
const actionData = useActionData<Rows_Overview.ActionData>();
|
||||
|
||||
useEffect(() => {
|
||||
if (actionData?.success) {
|
||||
toast.success(actionData?.success);
|
||||
} else if (actionData?.error) {
|
||||
toast.error(actionData.error);
|
||||
}
|
||||
}, [actionData, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditPageLayout
|
||||
title={options?.hideTitle ? "" : translateEntityTitle(t, i18n, rowData.entity)}
|
||||
menu={
|
||||
options?.hideMenu || !routes
|
||||
? undefined
|
||||
: EntityHelper.getLayoutBreadcrumbsMenu({
|
||||
type: "overview",
|
||||
t,
|
||||
appOrAdminData,
|
||||
entity: rowData.entity,
|
||||
item: rowData.item,
|
||||
params,
|
||||
routes,
|
||||
})
|
||||
}
|
||||
withHome={false}
|
||||
>
|
||||
<EditRow
|
||||
rowData={rowData}
|
||||
className="mx-auto pb-10"
|
||||
item={item}
|
||||
routes={routes}
|
||||
title={title}
|
||||
options={options}
|
||||
rowFormChildren={rowFormChildren}
|
||||
afterRowForm={afterRowForm}
|
||||
onSubmit={onSubmit}
|
||||
relationshipRows={relationshipRows}
|
||||
actionData={actionData}
|
||||
>
|
||||
{children}
|
||||
</EditRow>
|
||||
</EditPageLayout>
|
||||
|
||||
<SlideOverWideEmpty
|
||||
title={""}
|
||||
open={!!outlet}
|
||||
onClose={() => {
|
||||
navigate(".", { replace: true });
|
||||
}}
|
||||
className="sm:max-w-sm"
|
||||
overflowYScroll={true}
|
||||
>
|
||||
<div className="-mx-1 -mt-3">
|
||||
<div className="space-y-4">{outlet}</div>
|
||||
</div>
|
||||
</SlideOverWideEmpty>
|
||||
|
||||
<ActionResultModal actionData={actionData} showSuccess={false} showError={false} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditRowProps {
|
||||
rowData: RowsApi.GetRowData;
|
||||
item: RowWithDetails;
|
||||
routes?: EntitiesApi.Routes;
|
||||
className: string;
|
||||
children?: ReactNode;
|
||||
title?: ReactNode;
|
||||
options?: EditRowOptions;
|
||||
rowFormChildren?: ReactNode;
|
||||
afterRowForm?: ReactNode;
|
||||
relationshipRows?: RowsApi.GetRelationshipRowsData;
|
||||
onSubmit?: (formData: FormData) => void;
|
||||
actionData: Rows_Overview.ActionData | null | undefined;
|
||||
}
|
||||
function EditRow({
|
||||
rowData,
|
||||
routes,
|
||||
item,
|
||||
className,
|
||||
title,
|
||||
options,
|
||||
children,
|
||||
rowFormChildren,
|
||||
afterRowForm,
|
||||
relationshipRows,
|
||||
onSubmit,
|
||||
actionData,
|
||||
}: EditRowProps) {
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
function canUpdate() {
|
||||
if (options?.disableUpdate) {
|
||||
return false;
|
||||
}
|
||||
if (!getUserHasPermission(appOrAdminData, getEntityPermission(rowData.entity, "update"))) {
|
||||
return false;
|
||||
}
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function isEditing() {
|
||||
if (rowData.entity.onEdit === "overviewAlwaysEditable") {
|
||||
return true;
|
||||
}
|
||||
return searchParams.get("editing") !== null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
{!rowData.rowPermissions.canRead ? (
|
||||
<div className="font-medium">{t("models.row.permissionMessages.cannotViewRecord")}</div>
|
||||
) : (
|
||||
<div className={className}>
|
||||
<RowOverviewHeader rowData={rowData} item={item} canUpdate={canUpdate()} isEditing={isEditing()} routes={routes} title={title} options={options} />
|
||||
<div className="mt-4 space-y-4 lg:flex lg:space-x-4 lg:space-y-0">
|
||||
<div className={clsx("shrink-0 space-y-4 lg:w-4/6")}>
|
||||
{children ?? (
|
||||
<RowForm
|
||||
entity={rowData.entity}
|
||||
routes={routes}
|
||||
item={item}
|
||||
editing={isEditing()}
|
||||
canDelete={false}
|
||||
canUpdate={canUpdate()}
|
||||
allEntities={appOrAdminData.entities}
|
||||
onSubmit={onSubmit}
|
||||
relationshipRows={relationshipRows}
|
||||
>
|
||||
{rowFormChildren}
|
||||
</RowForm>
|
||||
)}
|
||||
|
||||
{afterRowForm}
|
||||
</div>
|
||||
<div className={clsx("shrink-0 space-y-4 pt-3 lg:w-2/6")}>
|
||||
{rowData.entity.hasTags && !options?.hideTags && <RowTags items={rowData.tags} onSetTagsRoute={canUpdate() ? "tags" : undefined} />}
|
||||
{rowData.entity.hasTasks && !options?.hideTasks && <RowTasks items={rowData.tasks} />}
|
||||
{rowData.entity.hasActivity && rowData.rowPermissions.canComment && !options?.hideActivity && (
|
||||
<RowActivity items={rowData.logs} onSubmit={onSubmit} hasComments={rowData.entity.hasComments} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Rows_Share } from "../routes/Rows_Share.server";
|
||||
import RowSettingsPermissions from "~/components/entities/rows/RowSettingsPermissions";
|
||||
|
||||
export default function RowShareRoute() {
|
||||
const data = useLoaderData<Rows_Share.LoaderData>();
|
||||
return <RowSettingsPermissions item={data.rowData.item} items={data.rowData.item.permissions} tenants={data.tenants} users={data.users} withTitle={true} />;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useLoaderData } from "react-router";
|
||||
import { Rows_Tags } from "../routes/Rows_Tags.server";
|
||||
import RowSettingsTags from "~/components/entities/rows/RowSettingsTags";
|
||||
|
||||
export default function RowTagsRoute() {
|
||||
const data = useLoaderData<Rows_Tags.LoaderData>();
|
||||
return <RowSettingsTags item={data.rowData.item} tags={data.tags} />;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Link, useLoaderData } from "react-router";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RowEditFetcher from "~/modules/rows/fetchers/RowEditFetcher";
|
||||
import RowNewFetcher from "~/modules/rows/fetchers/RowNewFetcher";
|
||||
import ExternalLinkEmptyIcon from "~/components/ui/icons/ExternalLinkEmptyIcon";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import { Rows_List } from "../routes/Rows_List.server";
|
||||
import RowsViewRoute from "./RowsViewRoute";
|
||||
import { getEntityPermission, getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import { translateEntityTitle } from "~/utils/i18n/translateLabel";
|
||||
|
||||
export default function RowsAllInOneRoute() {
|
||||
const data = useLoaderData<Rows_List.LoaderData>();
|
||||
const { t, i18n } = useTranslation();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [editing, setEditing] = useState<RowWithDetails>();
|
||||
const [rows, setRows] = useState<RowWithDetails[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setRows(data.rowsData.items);
|
||||
}, [data.rowsData.items]);
|
||||
|
||||
function onCreated(row: RowWithDetails) {
|
||||
setRows([row, ...rows]);
|
||||
setAdding(false);
|
||||
}
|
||||
function onUpdated(row: RowWithDetails) {
|
||||
setRows(rows.map((r) => (r.id === row.id ? row : r)));
|
||||
setEditing(undefined);
|
||||
}
|
||||
function onDeleted(row: RowWithDetails | undefined) {
|
||||
if (row) {
|
||||
setRows(rows.filter((r) => r.id !== row.id));
|
||||
setEditing(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RowsViewRoute
|
||||
key={data.rowsData.entity.id}
|
||||
rowsData={data.rowsData}
|
||||
items={rows}
|
||||
routes={data.routes}
|
||||
onNewRow={() => setAdding(true)}
|
||||
onEditRow={(row) => setEditing(row)}
|
||||
saveCustomViews={true}
|
||||
permissions={{
|
||||
create: getUserHasPermission(appOrAdminData, getEntityPermission(data.rowsData.entity, "create")),
|
||||
}}
|
||||
currentSession={{
|
||||
user: appOrAdminData.user,
|
||||
isSuperAdmin: appOrAdminData.isSuperAdmin,
|
||||
}}
|
||||
/>
|
||||
<SlideOverWideEmpty
|
||||
title={t("models.row.actions.createForEntity", { entity: translateEntityTitle(t, i18n, data.rowsData.entity) })}
|
||||
className="max-w-md"
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
>
|
||||
<RowNewFetcher
|
||||
url={EntityHelper.getRoutes({ routes: data.routes, entity: data.rowsData.entity })?.new ?? ""}
|
||||
onCreated={onCreated}
|
||||
allEntities={appOrAdminData.entities}
|
||||
/>
|
||||
</SlideOverWideEmpty>
|
||||
|
||||
<SlideOverWideEmpty
|
||||
title={editing ? RowHelper.getRowFolio(data.rowsData?.entity, editing) : ""}
|
||||
className="max-w-md"
|
||||
open={!!editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
buttons={
|
||||
<>
|
||||
<Link
|
||||
to={EntityHelper.getRoutes({ routes: data.routes, entity: data.rowsData.entity, item: editing })?.overview ?? ""}
|
||||
className="hover:text-muted-foreground text-muted-foreground bg-background rounded-md focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 focus:outline-hidden"
|
||||
>
|
||||
<span className="sr-only">{t("shared.openDetails")}</span>
|
||||
<ExternalLinkEmptyIcon className="h-6 w-6" aria-hidden="true" />
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<RowEditFetcher
|
||||
url={EntityHelper.getRoutes({ routes: data.routes, entity: data.rowsData.entity, item: editing })?.edit ?? ""}
|
||||
allEntities={appOrAdminData.entities}
|
||||
onUpdated={onUpdated}
|
||||
onDeleted={() => onDeleted(editing)}
|
||||
/>
|
||||
</SlideOverWideEmpty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import RowsImportRoute from "./RowsImportRoute";
|
||||
|
||||
let routeLoaderData: any;
|
||||
let appOrAdminData: any;
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-router")>("react-router");
|
||||
return {
|
||||
...actual,
|
||||
Form: ({ children, ...props }: React.ComponentProps<"form">) => <form {...props}>{children}</form>,
|
||||
useLoaderData: () => routeLoaderData,
|
||||
useActionData: () => undefined,
|
||||
useNavigation: () => ({ state: "idle" }),
|
||||
useSubmit: () => vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
i18n: { language: "en-US", exists: () => false },
|
||||
t: (key: string, options?: Record<string, unknown>) => {
|
||||
const translations: Record<string, string> = {
|
||||
"models.row.import.previewCta": "click here to preview",
|
||||
"models.row.import.downloadSample": "Download sample",
|
||||
"models.row.import.delimiter": "Delimiter",
|
||||
"models.row.import.headers": "Headers",
|
||||
"models.row.import.primary": "Primary",
|
||||
"models.row.import.mapFieldsFor": "Map fields for",
|
||||
"models.row.import.rowsCount": "{{formattedCount}} rows",
|
||||
"models.row.import.notMapped": "Not mapped",
|
||||
"models.row.import.values": "Values",
|
||||
"models.row.import.steps.uploadCsvFile": "Upload CSV file",
|
||||
"models.row.import.steps.mapColumns": "Map columns",
|
||||
"models.row.import.steps.confirm": "Confirm",
|
||||
"shared.continue": "Continue",
|
||||
"shared.cancel": "Cancel",
|
||||
"models.row.import.dryRunSummary": "Dry-run summary: {{formattedAccepted}} accepted, {{formattedRejected}} rejected, {{formattedTotal}} total.",
|
||||
"shared.importRecords": "Import {{formattedCount}} records",
|
||||
"models.row.import.tagPromptLabel": "Import label",
|
||||
"models.row.import.tagPromptDescription": "Add an optional label to the imported records.",
|
||||
"models.row.import.defaultTag": "import",
|
||||
"models.row.import.diagnosticRow": "Row {{formattedRow}}: ",
|
||||
"models.row.import.diagnosticColumn": "{{column}}: ",
|
||||
"models.row.import.diagnostics.duplicate_key": "Duplicate row detected. Review the duplicate group {{value}}.",
|
||||
"models.row.import.diagnostics.missing_required_field": "{{column}} is required for this import preview.",
|
||||
"models.row.import.diagnostics.unknown": "Review this row before importing.",
|
||||
"models.row.import.allPreviewRowsRejected": "All preview rows were rejected. Fix mappings before continuing.",
|
||||
"models.row.import.errors.networkPreview": "Network error while previewing import.",
|
||||
"models.row.import.errors.previewFailed": "Import dry-run failed.",
|
||||
"models.row.import.defaultColumnName": "Column {{number}}",
|
||||
"models.row.import.sampleValues.text": "Sample text {{number}}",
|
||||
"models.row.import.sampleValues.option": "Option {{number}}",
|
||||
};
|
||||
let value = translations[key] ?? key;
|
||||
for (const [name, replacement] of Object.entries(options ?? {})) {
|
||||
value = value.replaceAll(`{{${name}}}`, String(replacement));
|
||||
}
|
||||
return value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/data/useAppOrAdminData", () => ({
|
||||
useAppOrAdminData: () => appOrAdminData,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/steps/ProgressBar", () => ({
|
||||
default: ({ steps }: { steps: { title: string; status: string }[] }) => <div data-testid="steps">{steps.map((step) => `${step.title}:${step.status}`).join(",")}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/buttons/ButtonPrimary", () => ({
|
||||
default: ({ children, disabled, onClick }: { children: React.ReactNode; disabled?: boolean; onClick?: () => void }) => (
|
||||
<button type="button" disabled={disabled} onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/buttons/ButtonSecondary", () => ({
|
||||
default: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/input/InputCheckboxInline", () => ({
|
||||
default: ({ title, value, disabled, setValue }: { title: string; value?: boolean; disabled?: boolean; setValue: (value: boolean) => void }) => (
|
||||
<label>
|
||||
{title}
|
||||
<input type="checkbox" checked={!!value} disabled={disabled} onChange={(event) => setValue(event.currentTarget.checked)} />
|
||||
</label>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/input/InputSelect", () => ({
|
||||
default: ({ title }: { title: string }) => <label>{title}</label>,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/input/InputSelector", () => ({
|
||||
default: ({ name, value, setValue, options }: { name: string; value?: string; setValue: (value: string | undefined) => void; options: { name: string; value?: string }[] }) => (
|
||||
<select aria-label={name} value={value ?? ""} onChange={(event) => setValue(event.currentTarget.value || undefined)}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value ?? "none"} value={option.value ?? ""}>
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/icons/ExclamationTriangleIcon", () => ({
|
||||
default: () => <span data-testid="warning-icon" />,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/modals/Modal", () => ({
|
||||
default: ({ open, children }: { open: boolean; children: React.ReactNode }) => (open ? <section role="dialog">{children}</section> : null),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/tables/TableSimple", () => ({
|
||||
default: ({ items, headers }: { items: any[]; headers: { title: string; value: (item: any) => React.ReactNode }[] }) => (
|
||||
<table>
|
||||
<tbody>
|
||||
{items.map((item, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{headers.map((header) => (
|
||||
<td key={header.title}>{header.value(item)}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/uploaders/UploadDocument", () => ({
|
||||
default: ({ onDropped }: { onDropped: (base64: string, file: File) => void }) => (
|
||||
<button type="button" onClick={() => onDropped("", new File(["Email|Name\nada@example.com|Ada\nada@example.com|Duplicate"], "rows.csv", { type: "text/csv" }))}>
|
||||
Upload CSV
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
const entity = {
|
||||
id: "entity_1",
|
||||
name: "contacts",
|
||||
titlePlural: "Contacts",
|
||||
properties: [
|
||||
{ name: "email", title: "Email", type: 0, isDefault: false },
|
||||
{ name: "name", title: "Name", type: 0, isDefault: false },
|
||||
],
|
||||
};
|
||||
|
||||
const sampleEntity = {
|
||||
...entity,
|
||||
properties: [
|
||||
{ name: "notes", title: "Notes", type: 1, isDefault: false },
|
||||
{ name: "status", title: "Status", type: 8, isDefault: false },
|
||||
],
|
||||
};
|
||||
|
||||
function mockFetch(payload: unknown, ok = true) {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok, json: vi.fn().mockResolvedValue(payload) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
async function renderMappedImport() {
|
||||
render(<RowsImportRoute />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Upload CSV" }));
|
||||
fireEvent.change(await screen.findByLabelText("map-to-0"), { target: { value: "email" } });
|
||||
}
|
||||
|
||||
describe("RowsImportRoute dry-run mapping preview", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
routeLoaderData = { entity, allTenants: [{ id: "tenant_selected", name: "Selected", slug: "selected" }] };
|
||||
appOrAdminData = { currentTenant: { id: "tenant_current", slug: "current" } };
|
||||
});
|
||||
|
||||
it("calls the dry-run API and renders accepted/rejected summary diagnostics", async () => {
|
||||
const fetchMock = mockFetch({
|
||||
summary: { totalRows: 1, acceptedRows: 1, rejectedRows: 0, diagnostics: [{ code: "duplicate_key", message: "Duplicate primary skipped", rowNumber: 1234, valuePreview: "duplicate-group-0001" }] },
|
||||
rows: [{ ok: true, rowNumber: 1 }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await renderMappedImport();
|
||||
fireEvent.click(screen.getByText("(click here to preview)"));
|
||||
|
||||
expect(await screen.findByText("Dry-run summary: 1 accepted, 0 rejected, 1 total.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Row 1,234: Duplicate row detected. Review the duplicate group duplicate-group-0001.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Duplicate primary skipped/)).not.toBeInTheDocument();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/current/internal/imports/dry-run",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining('"tenantId":"tenant_current"'),
|
||||
})
|
||||
);
|
||||
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||
expect(requestBody.format).toBe("csv");
|
||||
expect(requestBody.importType).toBe("contacts");
|
||||
expect(requestBody.source).toEqual(expect.objectContaining({ tenantId: "tenant_current", sourceKey: "tenants/tenant_current/imports/dry-run/contacts-import.csv" }));
|
||||
expect(requestBody.mappings).toEqual([expect.objectContaining({ sourceColumn: "Column 1", targetField: "email" })]);
|
||||
expect(requestBody.rows[0]).toMatchObject({ rowNumber: 1, values: { "Column 1": "ada@example.com", "Column 2": "Ada" } });
|
||||
expect(screen.getAllByRole("button", { name: "Continue" }).at(-1)).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps admin dry-run requests on the admin internal route", async () => {
|
||||
appOrAdminData = { currentTenant: null };
|
||||
const fetchMock = mockFetch({
|
||||
summary: { totalRows: 1, acceptedRows: 1, rejectedRows: 0, diagnostics: [] },
|
||||
rows: [{ ok: true, rowNumber: 1 }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await renderMappedImport();
|
||||
fireEvent.click(screen.getByText("(click here to preview)"));
|
||||
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/internal/imports/dry-run");
|
||||
});
|
||||
|
||||
it("blocks continue when every preview row is rejected", async () => {
|
||||
mockFetch({
|
||||
summary: { totalRows: 1, acceptedRows: 0, rejectedRows: 1, diagnostics: [{ code: "missing_required_field", message: "Email is required", rowNumber: 1, column: "Email" }] },
|
||||
rows: [{ ok: false, rowNumber: 1 }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await renderMappedImport();
|
||||
fireEvent.click(screen.getByText("(click here to preview)"));
|
||||
|
||||
expect(await screen.findByText("All preview rows were rejected. Fix mappings before continuing.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Row 1: Email: Email is required for this import preview.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Email is required$/)).not.toBeInTheDocument();
|
||||
expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders row-level dry-run diagnostics and warnings with localized copy", async () => {
|
||||
mockFetch({
|
||||
summary: { totalRows: 2, acceptedRows: 1, rejectedRows: 1, diagnostics: [] },
|
||||
rows: [
|
||||
{ ok: true, rowNumber: 1 },
|
||||
{
|
||||
ok: false,
|
||||
rowNumber: 2,
|
||||
diagnostics: [{ code: "missing_required_field", message: "Email is required", column: "Email" }],
|
||||
warnings: [{ code: "duplicate_key", message: "Duplicate primary skipped", valuePreview: "duplicate-group-2" }],
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await renderMappedImport();
|
||||
fireEvent.click(screen.getByText("(click here to preview)"));
|
||||
|
||||
expect(await screen.findByText("Dry-run summary: 1 accepted, 1 rejected, 2 total.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Row 2: Email: Email is required for this import preview.")).toBeInTheDocument();
|
||||
expect(screen.getByText("Row 2: Duplicate row detected. Review the duplicate group duplicate-group-2.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Duplicate primary skipped/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders unknown diagnostic codes as generic localized copy without leaking parser text", async () => {
|
||||
mockFetch({
|
||||
summary: {
|
||||
totalRows: 1,
|
||||
acceptedRows: 0,
|
||||
rejectedRows: 1,
|
||||
diagnostics: [{ code: "future_parser_code", message: "rawBody={secret-token} Email is required", rowNumber: 1 }],
|
||||
},
|
||||
rows: [{ ok: false, rowNumber: 1 }],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
await renderMappedImport();
|
||||
fireEvent.click(screen.getByText("(click here to preview)"));
|
||||
|
||||
expect(await screen.findByText("Row 1: Review this row before importing.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/rawBody/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/secret-token/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Email is required/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a successful preview stale when mappings change", async () => {
|
||||
mockFetch({ summary: { totalRows: 1, acceptedRows: 1, rejectedRows: 0, diagnostics: [] }, rows: [{ ok: true, rowNumber: 1 }], diagnostics: [] });
|
||||
|
||||
await renderMappedImport();
|
||||
fireEvent.click(screen.getByText("(click here to preview)"));
|
||||
expect(await screen.findByText("Dry-run summary: 1 accepted, 0 rejected, 1 total.")).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.change(screen.getByLabelText("map-to-0"), { target: { value: "name" } });
|
||||
});
|
||||
await waitFor(() => expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled());
|
||||
|
||||
expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("renders localized API errors and keeps continue disabled without leaking raw backend text", async () => {
|
||||
mockFetch(
|
||||
{
|
||||
error: "authorization: Bearer secret-token failed",
|
||||
diagnostics: [{ code: "future_parser_code", message: "rawBody={secret-token} password=secret-token", rowNumber: 1 }],
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
await renderMappedImport();
|
||||
fireEvent.click(screen.getByText("(click here to preview)"));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Import dry-run failed.")).toBeInTheDocument());
|
||||
expect(screen.getByText("Row 1: Review this row before importing.")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/authorization/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/rawBody/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/password/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/secret-token/)).not.toBeInTheDocument();
|
||||
expect(within(screen.getByRole("dialog")).getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe("RowsImportRoute sample download", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
routeLoaderData = { entity: sampleEntity, allTenants: [] };
|
||||
appOrAdminData = { currentTenant: { id: "tenant_current", slug: "current" } };
|
||||
});
|
||||
|
||||
it("uses localized sample text in generated CSV downloads", () => {
|
||||
const appendChild = vi.spyOn(document.body, "appendChild");
|
||||
const removeChild = vi.spyOn(document.body, "removeChild");
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
|
||||
|
||||
render(<RowsImportRoute />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Download sample" }));
|
||||
|
||||
const link = appendChild.mock.calls.at(-1)?.[0] as HTMLAnchorElement;
|
||||
expect(link).toBeInstanceOf(HTMLAnchorElement);
|
||||
expect(decodeURI(link.getAttribute("href") ?? "")).toContain('"Sample text 1"|"Option 1"');
|
||||
expect(decodeURI(link.getAttribute("href") ?? "")).not.toContain("Abc, efg");
|
||||
expect(link.getAttribute("download")).toBe("contacts-import-sample.csv");
|
||||
expect(click).toHaveBeenCalled();
|
||||
expect(removeChild).toHaveBeenCalledWith(link);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,71 @@
|
||||
import clsx from "clsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Kanban from "~/components/ui/lists/Kanban";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import { Rows_Relationships } from "../routes/Rows_Relationships.server";
|
||||
import { useLoaderData } from "react-router";
|
||||
import { translateEntityTitlePlural } from "~/utils/i18n/translateLabel";
|
||||
|
||||
export default function RowsRelationshipsRoute() {
|
||||
const { t } = useTranslation();
|
||||
const data = useLoaderData<Rows_Relationships.LoaderData>();
|
||||
return (
|
||||
<div className="mx-auto mb-12 max-w-5xl space-y-5 px-4 py-4 sm:px-6 lg:px-8 xl:max-w-7xl">
|
||||
<div className="flex overflow-hidden overflow-x-auto">
|
||||
{data.entitiesData.map((data, idx) => (
|
||||
<EntityRowsRelationships className="w-64" key={idx} data={data.rowsData} />
|
||||
))}
|
||||
</div>
|
||||
{data.entitiesData.length === 0 && (
|
||||
<div className="border-border bg-secondary text-muted-foreground rounded-md border px-4 py-3 text-sm">{t("models.entity.relationships.empty")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityRowsRelationships({ data, className }: { data: RowsApi.GetRowsData; className?: string }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
return (
|
||||
<div className={clsx(className)}>
|
||||
{data?.entity && (
|
||||
<Kanban
|
||||
// withTitle={withTitle}
|
||||
columns={[
|
||||
{
|
||||
name: "entity",
|
||||
title: data.entity ? translateEntityTitlePlural(t, i18n, data.entity) : "",
|
||||
items: data?.items ?? [],
|
||||
card: (item) => (
|
||||
<div className="hover:bg-secondary border-border bg-background group w-full truncate rounded-md border p-3 text-left shadow-2xs">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<button
|
||||
className="grow truncate text-left"
|
||||
type="button"
|
||||
// onClick={() => {
|
||||
// if (isSelected(item)) {
|
||||
// const rows = searchParams.getAll(`${entity}[id]`).filter((id) => id !== item.id);
|
||||
// searchParams.delete(`${entity}[id]`);
|
||||
// for (const row of rows) searchParams.append(`${entity}[id]`, row);
|
||||
// } else {
|
||||
// searchParams.append(`${entity}[id]`, item.id);
|
||||
// }
|
||||
// setSearchParams(searchParams);
|
||||
// }}
|
||||
>
|
||||
<div>{RowHelper.getTextDescription({ entity: data.entity!, item, t })}</div>
|
||||
</button>
|
||||
{/* <div className="w-4 shrink-0">{isSelected(item) ? <CheckIcon className="h-4 w-4 text-muted-foreground" /> : null}</div> */}
|
||||
{/* <button type="button" onClick={() => alert("edit: " + item.id)}>
|
||||
Edit
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/rows/components/RowNewRoute.tsx");
|
||||
|
||||
describe("RowNewRoute i18n", () => {
|
||||
it("uses semantic translation helpers for row creation copy", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("models.row.actions.createForEntity");
|
||||
expect(source).toContain("translateEntityTitle");
|
||||
expect(source).toContain("translatePropertyTitle");
|
||||
expect(source).not.toContain('t("shared.create") + " "');
|
||||
expect(source).not.toContain("t(property.title)");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
});
|
||||
});
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/rows/components/RowsAllInOneRoute.tsx");
|
||||
|
||||
describe("RowsAllInOneRoute i18n", () => {
|
||||
it("uses semantic translation keys for slide-over row creation copy", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("models.row.actions.createForEntity");
|
||||
expect(source).toContain("translateEntityTitle");
|
||||
expect(source).not.toContain('t("shared.create") + " "');
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/rows/components/RowsImportRoute.tsx");
|
||||
|
||||
describe("RowsImportRoute i18n", () => {
|
||||
it("uses semantic translation keys for visible import copy", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("models.row.import.steps.uploadCsvFile");
|
||||
expect(source).toContain("models.row.import.delimiterOptions.comma");
|
||||
expect(source).toContain("models.row.import.defaultColumnName");
|
||||
expect(source).toContain("models.row.import.tagPromptLabel");
|
||||
expect(source).toContain("models.row.import.tagPromptDescription");
|
||||
expect(source).toContain("models.row.import.defaultTag");
|
||||
expect(source).toContain("<Modal open={importTagDialogOpen}");
|
||||
expect(source).toContain("<InputText name=\"tag\"");
|
||||
expect(source).toContain("models.row.import.sampleValues.text");
|
||||
expect(source).toContain("models.row.import.sampleValues.option");
|
||||
expect(source).toContain("models.row.import.removeColumn");
|
||||
expect(source).toContain("models.row.import.dryRunSummary");
|
||||
expect(source).toContain("models.row.import.diagnosticColumn");
|
||||
expect(source).toContain("models.row.import.diagnostics.");
|
||||
expect(source).toContain("models.row.import.errors.previewFailed");
|
||||
expect(source).toContain("translateRowImportError");
|
||||
expect(source).toContain("models.row.import.errors.rowFailed");
|
||||
expect(source).toContain("models.row.import.errors.entityNotFound");
|
||||
expect(source).toContain("models.row.import.errors.rowAccessDenied");
|
||||
expect(source).toContain("formattedRow");
|
||||
expect(source).toContain("translateEntityTitlePlural");
|
||||
expect(source).toContain("translatePropertyTitle");
|
||||
expect(source).toContain("diagnostic.rowNumber ?? diagnostic.row");
|
||||
expect(source).toContain('t("shared.importRecords", { count: items.length, formattedCount: formatters.number(items.length) })');
|
||||
expect(source).toContain('t("shared.importRecords", { count: pendingItemCount, formattedCount: formatters.number(pendingItemCount) })');
|
||||
expect(source).toContain('t("models.row.import.rowsCount", { count: uniqueRows.length, formattedCount: formatters.number(uniqueRows.length) })');
|
||||
expect(source).not.toContain("defaultValue: fallback");
|
||||
expect(source).not.toContain("{redactDryRunMessage(diagnostic.message)}");
|
||||
expect(source).not.toMatch(/shared\.importRecord["')]/);
|
||||
expect(source).not.toContain('t("models.row.import.rowsCount", { count: uniqueRows.length })');
|
||||
expect(source).toContain("models.row.import.errorStatus");
|
||||
expect(source).not.toContain("error: i.error");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
expect(source).not.toContain("Dry-run summary:");
|
||||
expect(source).not.toContain("Network error while previewing import.");
|
||||
expect(source).not.toContain("Import dry-run failed.");
|
||||
expect(source).not.toContain("window.prompt");
|
||||
expect(source).not.toMatch(/\bprompt\(/);
|
||||
expect(source).not.toContain("window.confirm");
|
||||
expect(source).not.toMatch(/\balert\(/);
|
||||
expect(fs.readFileSync(path.resolve(process.cwd(), "apps/web/app/modules/rows/routes/Rows_Import.server.ts"), "utf8")).toContain(
|
||||
't("models.row.import.defaultTag")'
|
||||
);
|
||||
expect(source).not.toContain("`Column ${index + 1}`");
|
||||
expect(source).not.toContain("t(entity.titlePlural)");
|
||||
expect(source).not.toContain("t(prop.title)");
|
||||
expect(source).not.toContain('t(property?.title ?? "")');
|
||||
expect(source).not.toContain("Abc, efg");
|
||||
expect(source).not.toContain("Option 1");
|
||||
expect(source).not.toContain('aria-label="Remove column"');
|
||||
});
|
||||
|
||||
it("uses clearer import locale labels", () => {
|
||||
const en = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), "apps/web/public/locales/en/translations.json"), "utf8"));
|
||||
const de = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), "apps/web/public/locales/de/translations.json"), "utf8"));
|
||||
|
||||
expect(en.models.row.import.tagPromptLabel).toBe("Import label");
|
||||
expect(en.models.row.import.primary).toBe("Match field");
|
||||
expect(en.models.row.import.identifyExistingRecords).toBe("Used to identify existing records");
|
||||
expect(en.models.row.import.removeColumn).toBe("Remove column {{column}}");
|
||||
expect(en.models.row.import.errors.invalidJsonBody).toBe("Invalid JSON payload.");
|
||||
expect(en.models.row.import.errors.rowFailed).toBe("This row could not be imported. Review its values and try again.");
|
||||
expect(en.models.row.import.errors.rowAccessDenied).toBe("You do not have access to one of the referenced rows.");
|
||||
expect(de.models.row.import.tagPromptLabel).toBe("Importkennzeichnung");
|
||||
expect(de.models.row.import.primary).toBe("Abgleichsfeld");
|
||||
expect(de.models.row.import.identifyExistingRecords).toBe("Wird zum Erkennen vorhandener Datensätze verwendet");
|
||||
expect(de.models.row.import.removeColumn).toBe("Spalte {{column}} entfernen");
|
||||
expect(de.models.row.import.errors.invalidJsonBody).toBe("Ungültiger JSON-Inhalt.");
|
||||
expect(de.models.row.import.errors.rowFailed).toBe("Dieser Datensatz konnte nicht importiert werden. Prüfe die Werte und versuche es erneut.");
|
||||
expect(de.models.row.import.errors.rowAccessDenied).toBe("Du hast keinen Zugriff auf einen der referenzierten Datensätze.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const sourcePath = path.resolve(process.cwd(), "apps/web/app/modules/rows/components/RowsViewRoute.tsx");
|
||||
|
||||
describe("RowsViewRoute i18n", () => {
|
||||
it("uses semantic translation keys for custom view slide-over titles", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("models.view.actions.edit");
|
||||
expect(source).toContain("models.view.actions.newForEntity");
|
||||
expect(source).toContain("useLocaleFormatters");
|
||||
expect(source).toContain("formatters.number(rowsData.pagination.totalItems)");
|
||||
expect(source).toContain("shared.exportResults");
|
||||
expect(source).not.toContain('"Edit view"');
|
||||
expect(source).not.toContain("`New ${t(rowsData.entity.title)} view`");
|
||||
expect(source).not.toMatch(/shared\.exportResult["')]/);
|
||||
expect(source).not.toContain("{ 0: rowsData.pagination.totalItems }");
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user