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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
export const EntityViewLayoutTypes = [
|
||||
{ labelKey: "models.view.layouts.table", value: "table" },
|
||||
{ labelKey: "models.view.layouts.grid", value: "grid" },
|
||||
{ labelKey: "models.view.layouts.card", value: "card" },
|
||||
{ labelKey: "models.view.layouts.board", value: "board" },
|
||||
] as const;
|
||||
export type EntityViewLayoutType = (typeof EntityViewLayoutTypes)[number]["value"];
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Loading from "~/components/ui/loaders/Loading";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import { getEntityPermission, getUserHasPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import RowForm from "../../../components/entities/rows/RowForm";
|
||||
import RowTags from "../../../components/entities/rows/RowTags";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
onUpdated?: (row: RowWithDetails) => void;
|
||||
onDeleted?: () => void;
|
||||
allEntities: EntityWithDetails[];
|
||||
}
|
||||
export default function RowEditFetcher({ url, onUpdated, allEntities, onDeleted }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const fetcher = useFetcher<{
|
||||
rowData?: RowsApi.GetRowData;
|
||||
routes?: EntitiesApi.Routes;
|
||||
updatedRow?: RowsApi.GetRowData;
|
||||
relationshipRows?: RowsApi.GetRelationshipRowsData;
|
||||
}>();
|
||||
const appOrAdminData = useAppOrAdminData();
|
||||
|
||||
const [data, setData] = useState<{
|
||||
rowData?: RowsApi.GetRowData;
|
||||
routes?: EntitiesApi.Routes;
|
||||
updatedRow?: RowsApi.GetRowData;
|
||||
relationshipRows?: RowsApi.GetRelationshipRowsData;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
fetcher.load(url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line no-console
|
||||
if (fetcher.data?.rowData && fetcher.data?.routes) {
|
||||
setData(fetcher.data);
|
||||
}
|
||||
if (fetcher.data?.updatedRow?.item && onUpdated) {
|
||||
onUpdated(fetcher.data.updatedRow.item);
|
||||
}
|
||||
// if (fetcher.data?.deleted && onDeleted) {
|
||||
// onDeleted();
|
||||
// }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher.data]);
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
fetcher.submit(formData, {
|
||||
action: url,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
const formData = new FormData();
|
||||
formData.set("action", "delete");
|
||||
formData.set("id", data?.rowData?.item.id ?? "");
|
||||
fetcher.submit(formData, {
|
||||
action: url,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!fetcher.data ? (
|
||||
<Loading small loading />
|
||||
) : !data?.rowData ? (
|
||||
<div>{t("models.row.empty.noData")}</div>
|
||||
) : data ? (
|
||||
<div>
|
||||
{data.routes && (
|
||||
<div className="space-y-2">
|
||||
{data.rowData.item.tags.length > 0 && <RowTags items={data.rowData.item.tags} />}
|
||||
<RowForm
|
||||
allEntities={allEntities}
|
||||
entity={data.rowData.entity}
|
||||
routes={data.routes}
|
||||
item={data.rowData.item}
|
||||
editing={true}
|
||||
canDelete={getUserHasPermission(appOrAdminData, getEntityPermission(data.rowData.entity, "delete")) && data.rowData.rowPermissions.canDelete}
|
||||
canUpdate={getUserHasPermission(appOrAdminData, getEntityPermission(data.rowData.entity, "update")) && data.rowData.rowPermissions.canUpdate}
|
||||
onSubmit={onSubmit}
|
||||
onDelete={onDelete}
|
||||
relationshipRows={data.relationshipRows}
|
||||
state={{
|
||||
loading: fetcher.state === "loading",
|
||||
submitting: fetcher.state === "submitting",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>{t("shared.unknownError")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useFetcher, useSearchParams } from "react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import type { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import RowNewFetcher from "./RowNewFetcher";
|
||||
import RowsList from "~/components/entities/rows/RowsList";
|
||||
import type { EntityViewWithDetails } from "~/utils/db/entities/entityViews.db.server";
|
||||
import { RowDisplayDefaultProperty } from "~/utils/helpers/PropertyHelper";
|
||||
import Loading from "~/components/ui/loaders/Loading";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import { translateEntityTitle, translateEntityTitlePlural } from "~/utils/i18n/translateLabel";
|
||||
|
||||
interface Props {
|
||||
currentView: EntityViewWithDetails | null;
|
||||
listUrl: string;
|
||||
newUrl: string;
|
||||
parentEntity?: EntityWithDetails;
|
||||
onSelected: (rows: RowWithDetails[]) => void;
|
||||
multipleSelection?: boolean;
|
||||
allEntities: EntityWithDetails[];
|
||||
}
|
||||
export default function RowListFetcher({ currentView, listUrl, newUrl, parentEntity, onSelected, multipleSelection, allEntities }: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const fetcher = useFetcher<{ rowsData: RowsApi.GetRowsData; routes: EntitiesApi.Routes }>();
|
||||
const [data, setData] = useState<{ rowsData: RowsApi.GetRowsData; routes: EntitiesApi.Routes }>();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [rows, setRows] = useState<RowWithDetails[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<RowWithDetails[]>([]);
|
||||
const [searchParams] = useSearchParams();
|
||||
const createTitle = data?.rowsData?.entity
|
||||
? t("models.row.actions.createForEntity", { entity: translateEntityTitle(t, i18n, data.rowsData.entity) })
|
||||
: t("shared.create");
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(listUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView) {
|
||||
searchParams.set("v", currentView.name);
|
||||
}
|
||||
fetcher.load(listUrl + "?" + searchParams.toString());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams, currentView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
const data: { rowsData: RowsApi.GetRowsData } = fetcher.data;
|
||||
setData(fetcher.data);
|
||||
setRows(data.rowsData.items);
|
||||
const selectedRowIds = new Set(selectedRows.map((row) => row.id));
|
||||
setSelectedRows(data.rowsData.items.filter((row) => selectedRowIds.has(row.id)));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetcher.data]);
|
||||
|
||||
function onRowsSelected(rows: RowWithDetails[]) {
|
||||
setSelectedRows(rows);
|
||||
}
|
||||
|
||||
function onCreated(row: RowWithDetails) {
|
||||
setRows([row, ...rows]);
|
||||
setSelectedRows([row, ...selectedRows]);
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
function onConfirm(rows: RowWithDetails[]) {
|
||||
onSelected(rows);
|
||||
}
|
||||
|
||||
function getSelectionLabel() {
|
||||
const entity = data?.rowsData?.entity;
|
||||
if (!entity) {
|
||||
return t("shared.select");
|
||||
}
|
||||
const entityLabel = selectedRows.length === 1 ? translateEntityTitle(t, i18n, entity) : translateEntityTitlePlural(t, i18n, entity);
|
||||
return t("models.row.selection.selectRows", {
|
||||
count: selectedRows.length,
|
||||
formattedCount: formatters.number(selectedRows.length),
|
||||
entity: entityLabel,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!fetcher.data ? (
|
||||
<Loading small loading />
|
||||
) : !data?.rowsData?.entity ? (
|
||||
<div className="border-border relative block w-full cursor-not-allowed rounded-lg border-2 border-dashed p-4 text-center">
|
||||
{t("models.row.loadingRows")}
|
||||
</div>
|
||||
) : !data?.rowsData ? (
|
||||
<div>{t("models.row.empty.noData")}</div>
|
||||
) : data?.rowsData ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
{/* <div className="text-lg font-bold text-foreground">{t(data.rowsData?.entity.titlePlural)}</div> */}
|
||||
<ButtonPrimary type="button" onClick={() => onConfirm(selectedRows)} disabled={selectedRows.length > 1 && !multipleSelection}>
|
||||
{getSelectionLabel()}
|
||||
</ButtonPrimary>
|
||||
<div className="flex space-x-2">
|
||||
<InputFilters filters={EntityHelper.getFilters({ t, entity: data.rowsData.entity })} />
|
||||
<ButtonSecondary type="button" onClick={() => setAdding(true)}>
|
||||
+
|
||||
{/* {t("shared.new")}
|
||||
<span className="ml-1 lowercase">{t(data.rowsData?.entity.title)}</span> */}
|
||||
</ButtonSecondary>
|
||||
</div>
|
||||
</div>
|
||||
<RowsList
|
||||
view={(currentView?.layout ?? "table") as "table" | "board" | "grid" | "card"}
|
||||
currentView={currentView}
|
||||
entity={data.rowsData.entity}
|
||||
items={rows}
|
||||
pagination={data.rowsData.pagination}
|
||||
selectedRows={selectedRows}
|
||||
onSelected={onRowsSelected}
|
||||
readOnly={true}
|
||||
// routes={data.routes}
|
||||
ignoreColumns={[RowDisplayDefaultProperty.FOLIO]}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>{t("shared.unknownError")}</div>
|
||||
)}
|
||||
<SlideOverWideEmpty
|
||||
title={createTitle}
|
||||
className="max-w-md"
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
>
|
||||
<RowNewFetcher url={newUrl} parentEntity={parentEntity} onCreated={onCreated} allEntities={allEntities} />
|
||||
</SlideOverWideEmpty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CheckPlanFeatureLimit from "~/components/core/settings/subscription/CheckPlanFeatureLimit";
|
||||
import Loading from "~/components/ui/loaders/Loading";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import RowForm from "../../../components/entities/rows/RowForm";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
interface Props {
|
||||
url: string;
|
||||
parentEntity?: EntityWithDetails;
|
||||
onCreated?: (row: RowWithDetails) => void;
|
||||
allEntities: EntityWithDetails[];
|
||||
customSearchParams?: URLSearchParams;
|
||||
// onSelected: (entity: EntityWithDetails, item: RowWithDetails) => void;
|
||||
}
|
||||
export default function RowNewFetcher({ url, parentEntity, onCreated, allEntities, customSearchParams }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const fetcher = useFetcher<{
|
||||
newRow?: RowWithDetails;
|
||||
entityData?: EntitiesApi.GetEntityData;
|
||||
routes?: EntitiesApi.Routes;
|
||||
relationshipRows?: RowsApi.GetRelationshipRowsData;
|
||||
}>();
|
||||
// const actionData = useActionData<{ newRow?: RowWithDetails }>();
|
||||
|
||||
// useEffect(() => {
|
||||
// if (actionData?.newRow && onCreated) {
|
||||
// onCreated(actionData.newRow);
|
||||
// }
|
||||
// }, [actionData, onCreated]);
|
||||
|
||||
const [data, setData] = useState<{
|
||||
newRow?: RowWithDetails;
|
||||
entityData?: EntitiesApi.GetEntityData;
|
||||
routes?: EntitiesApi.Routes;
|
||||
relationshipRows?: RowsApi.GetRelationshipRowsData;
|
||||
}>();
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.newRow && onCreated) {
|
||||
onCreated(data.newRow);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
setData(fetcher.data);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
function onSubmit(formData: FormData) {
|
||||
fetcher.submit(formData, {
|
||||
action: url,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!fetcher.data ? (
|
||||
<Loading small loading />
|
||||
) : !data?.entityData ? (
|
||||
<div>{t("models.row.empty.noData")}</div>
|
||||
) : data ? (
|
||||
<CheckPlanFeatureLimit item={data.entityData.featureUsageEntity}>
|
||||
{data.routes && (
|
||||
<RowForm
|
||||
entity={data.entityData.entity}
|
||||
routes={data.routes}
|
||||
parentEntity={parentEntity}
|
||||
onSubmit={onSubmit}
|
||||
onCreatedRedirect={undefined}
|
||||
allEntities={allEntities}
|
||||
relationshipRows={data.relationshipRows}
|
||||
state={{
|
||||
loading: fetcher.state === "loading",
|
||||
submitting: fetcher.state === "submitting",
|
||||
}}
|
||||
customSearchParams={customSearchParams}
|
||||
/>
|
||||
)}
|
||||
</CheckPlanFeatureLimit>
|
||||
) : (
|
||||
<div>{t("shared.unknownError")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SlideOverWideEmpty from "~/components/ui/slideOvers/SlideOverWideEmpty";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import RowNewFetcher from "./RowNewFetcher";
|
||||
import InputSelector from "~/components/ui/input/InputSelector";
|
||||
import RowModel from "../repositories/RowModel";
|
||||
import { useFetcher } from "react-router";
|
||||
import { translateEntityTitle } from "~/utils/i18n/translateLabel";
|
||||
|
||||
interface Props {
|
||||
entity: EntityWithDetails;
|
||||
// listUrl: string;
|
||||
// newUrl: string;
|
||||
routes: EntitiesApi.Routes;
|
||||
allEntities: EntityWithDetails[];
|
||||
initial?: string;
|
||||
onSelected: (row: RowWithDetails) => void;
|
||||
onClear?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
export default function RowSelectorFetcher({ entity, routes, allEntities, initial, onSelected, onClear, className }: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const fetcher = useFetcher<{ rowsData: RowsApi.GetRowsData; routes: EntitiesApi.Routes }>();
|
||||
const entityLabel = translateEntityTitle(t, i18n, entity);
|
||||
|
||||
const listUrl = EntityHelper.getRoutes({ routes, entity })?.list ?? "";
|
||||
const newUrl = EntityHelper.getRoutes({ routes, entity })?.new ?? "";
|
||||
|
||||
const [selected, setSelected] = useState<string | null>(initial ?? null);
|
||||
|
||||
const [data, setData] = useState<{ rowsData: RowsApi.GetRowsData; routes: EntitiesApi.Routes }>();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [rows, setRows] = useState<RowWithDetails[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const item = rows.find((f) => f.id === selected);
|
||||
if (item && item.id !== initial) {
|
||||
onSelected(item);
|
||||
}
|
||||
if (selected === "{new}") {
|
||||
setAdding(true);
|
||||
// setSelected(undefined);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(listUrl);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [listUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(listUrl + `?view=null&pageSize=-1`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.data) {
|
||||
const data: { rowsData: RowsApi.GetRowsData } = fetcher.data;
|
||||
setData(fetcher.data);
|
||||
setRows(data.rowsData.items);
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
function onCreated(row: RowWithDetails) {
|
||||
setRows([row, ...rows]);
|
||||
setSelected(row.id);
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<InputSelector
|
||||
title={entityLabel}
|
||||
disabled={!data}
|
||||
isLoading={!data || fetcher.state === "loading"}
|
||||
options={[
|
||||
{
|
||||
value: "{new}",
|
||||
name: t("models.row.actions.addForEntity", { entity: entityLabel }),
|
||||
},
|
||||
...(rows.map((f) => {
|
||||
const model = new RowModel(f);
|
||||
return {
|
||||
value: model.row.id,
|
||||
name: model.toString(),
|
||||
};
|
||||
}) ?? []),
|
||||
]}
|
||||
value={selected ?? undefined}
|
||||
setValue={(e) => setSelected(e?.toString() ?? "")}
|
||||
hint={
|
||||
<>
|
||||
{onClear && selected && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground text-xs hover:underline"
|
||||
onClick={() => {
|
||||
setSelected(null);
|
||||
onClear();
|
||||
}}
|
||||
>
|
||||
{t("shared.clear")}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<SlideOverWideEmpty
|
||||
title={
|
||||
data?.rowsData?.entity
|
||||
? t("models.row.actions.createForEntity", { entity: translateEntityTitle(t, i18n, data.rowsData.entity) })
|
||||
: t("shared.create")
|
||||
}
|
||||
className="max-w-md"
|
||||
open={adding}
|
||||
onClose={() => {
|
||||
setAdding(false);
|
||||
if (selected === "{new}") {
|
||||
setSelected(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RowNewFetcher url={newUrl} parentEntity={entity} onCreated={onCreated} allEntities={allEntities} />
|
||||
</SlideOverWideEmpty>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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/fetchers/RowListFetcher.tsx");
|
||||
|
||||
describe("RowListFetcher i18n", () => {
|
||||
it("uses a semantic plural key for selected row counts", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("models.row.selection.selectRows");
|
||||
expect(source).toContain("models.row.actions.createForEntity");
|
||||
expect(source).toContain("models.row.loadingRows");
|
||||
expect(source).toContain("formatters.number(selectedRows.length)");
|
||||
expect(source).toContain("translateEntityTitle");
|
||||
expect(source).toContain("translateEntityTitlePlural");
|
||||
expect(source).not.toContain("toLocaleLowerCase");
|
||||
expect(source).not.toContain('{t("shared.select")} {selectedRows.length}');
|
||||
expect(source).not.toContain('{t("shared.select")} 1');
|
||||
expect(source).not.toContain('{t("shared.loading")}...');
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
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/fetchers/RowSelectorFetcher.tsx");
|
||||
|
||||
describe("RowSelectorFetcher i18n", () => {
|
||||
it("uses entity translation helpers for dynamic labels", () => {
|
||||
const source = fs.readFileSync(sourcePath, "utf8");
|
||||
|
||||
expect(source).toContain("translateEntityTitle");
|
||||
expect(source).toContain("const { t, i18n } = useTranslation()");
|
||||
expect(source).toContain("entityLabel");
|
||||
expect(source).toContain("models.row.actions.addForEntity");
|
||||
expect(source).toContain("models.row.actions.createForEntity");
|
||||
expect(source).not.toContain("t(entity.title)");
|
||||
expect(source).not.toContain("t(data?.rowsData?.entity.title");
|
||||
expect(source).not.toContain("` - ${t(\"shared.add\")} ${entityLabel} - `");
|
||||
expect(source).not.toContain('t("shared.create") + " "');
|
||||
});
|
||||
});
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import { getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import EntitiesSingleton from "../EntitiesSingleton";
|
||||
|
||||
export async function loadEntities() {
|
||||
const instace = EntitiesSingleton.getInstance();
|
||||
if (!instace.isSet()) {
|
||||
const entities = await getAllEntities({ tenantId: null });
|
||||
instace.setEntities(entities);
|
||||
}
|
||||
return instace.getEntities();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
export default class EntitiesSingleton {
|
||||
private static instance: EntitiesSingleton;
|
||||
private entities: EntityWithDetails[] | undefined;
|
||||
private constructor(entities?: EntityWithDetails[]) {
|
||||
this.entities = entities;
|
||||
}
|
||||
public static getInstance(entities?: EntityWithDetails[]): EntitiesSingleton {
|
||||
if (!EntitiesSingleton.instance) {
|
||||
EntitiesSingleton.instance = new EntitiesSingleton(entities);
|
||||
}
|
||||
return EntitiesSingleton.instance;
|
||||
}
|
||||
public isSet() {
|
||||
return this.entities !== undefined;
|
||||
}
|
||||
public getEntities(): EntityWithDetails[] {
|
||||
if (!this.entities) {
|
||||
throw new Error("Entities not set: Call await EntitiesSingleton.load() before using RowRepository or EntityRepository");
|
||||
}
|
||||
return this.entities;
|
||||
}
|
||||
public setEntities(entities: EntityWithDetails[]): void {
|
||||
const instance = EntitiesSingleton.getInstance();
|
||||
instance.entities = entities;
|
||||
}
|
||||
public static getEntity(data: { name: string } | { id: string }): EntityWithDetails {
|
||||
const entities = EntitiesSingleton.getInstance().getEntities();
|
||||
if ("name" in data) {
|
||||
const entity = entities.find((f) => f.name === data.name);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found: " + data.name);
|
||||
}
|
||||
return entity;
|
||||
} else {
|
||||
const entity = entities.find((f) => f.id === data.id);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found: " + data.id);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
public static getEntityByIdNameOrSlug(idNameOrSlug: string) {
|
||||
const entity = EntitiesSingleton.getInstance()
|
||||
.getEntities()
|
||||
.find((f) => f.id === idNameOrSlug || f.name === idNameOrSlug || f.slug === idNameOrSlug);
|
||||
if (!entity) {
|
||||
throw new Error(`Entity not found`);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/api/.server/RowsApi", () => ({
|
||||
RowsApi: {
|
||||
getAll: vi.fn(),
|
||||
get: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/RowHelper", () => ({
|
||||
default: {
|
||||
getRowPropertiesFromForm: vi.fn(() => ({})),
|
||||
getTextDescription: vi.fn(() => ""),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/api/.server/RowRelationshipsApi", () => ({
|
||||
RowRelationshipsApi: {
|
||||
createRelationship: vi.fn(),
|
||||
deleteRelationship: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/.server/RowValueService", () => ({
|
||||
default: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import EntityRepository from "./EntityRepository.server";
|
||||
import EntitiesSingleton from "./EntitiesSingleton";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
|
||||
const rowsApi = vi.mocked(RowsApi);
|
||||
|
||||
const session = { tenantId: "tenant-1", userId: "user-1" };
|
||||
const entity = { id: "entity-1", name: "contacts", slug: "contacts", properties: [], parentEntities: [], childEntities: [] };
|
||||
const row = { id: "row-1", entityId: "entity-1", tenantId: "tenant-1", values: [], parentRows: [], childRows: [] };
|
||||
|
||||
describe("EntityRepository tenant session propagation", () => {
|
||||
it("keeps the session on repositories returned from list, read, and create calls", async () => {
|
||||
EntitiesSingleton.getInstance().setEntities([entity as any]);
|
||||
rowsApi.getAll.mockResolvedValue({ items: [row as any] } as any);
|
||||
rowsApi.get.mockResolvedValue({ item: row as any } as any);
|
||||
rowsApi.create.mockResolvedValue(row as any);
|
||||
const repository = new EntityRepository({ session });
|
||||
|
||||
const rows = await repository.getRows("contacts");
|
||||
const fetched = await repository.getRow("contacts", "row-1");
|
||||
const created = await repository.createRow("contacts", []);
|
||||
|
||||
expect(rows[0].session).toEqual(session);
|
||||
expect(fetched.session).toEqual(session);
|
||||
expect(created.session).toEqual(session);
|
||||
expect(rowsApi.getAll).toHaveBeenCalledWith(expect.objectContaining({ tenantId: "tenant-1", userId: "user-1" }));
|
||||
expect(rowsApi.get).toHaveBeenCalledWith("row-1", expect.objectContaining({ tenantId: "tenant-1", userId: "user-1" }));
|
||||
expect(rowsApi.create).toHaveBeenCalledWith(expect.objectContaining({ tenantId: "tenant-1", userId: "user-1" }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import RowHelper, { RowValueCreateDto } from "~/utils/helpers/RowHelper";
|
||||
import EntitiesSingleton from "./EntitiesSingleton";
|
||||
import RowRepository from "./RowRepository.server";
|
||||
|
||||
export default class EntityRepository {
|
||||
session: { tenantId: string | null; userId?: string };
|
||||
constructor(options: { session: { tenantId: string | null; userId?: string } }) {
|
||||
this.session = options?.session;
|
||||
}
|
||||
async getRows(entityName: string) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = allEntities.find((entity) => entity.name === entityName);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found: " + entityName);
|
||||
}
|
||||
const { items } = await RowsApi.getAll({
|
||||
entity,
|
||||
tenantId: this.session.tenantId,
|
||||
userId: this.session?.userId,
|
||||
});
|
||||
return items.map((row) => new RowRepository(row, { session: this.session }));
|
||||
}
|
||||
async getRow(entityName: string, rowId: string) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = allEntities.find((entity) => entity.name === entityName);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found: " + entityName);
|
||||
}
|
||||
const existing = await RowsApi.get(rowId, {
|
||||
entity,
|
||||
tenantId: this.session.tenantId,
|
||||
userId: this.session?.userId,
|
||||
});
|
||||
return new RowRepository(existing.item, { session: this.session });
|
||||
}
|
||||
async createRow(entityName: string, values: RowValueCreateDto[]) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = allEntities.find((entity) => entity.name === entityName);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found: " + entityName);
|
||||
}
|
||||
const row = await RowsApi.create({
|
||||
entity,
|
||||
tenantId: this.session.tenantId,
|
||||
userId: this.session?.userId,
|
||||
rowValues: RowHelper.getRowPropertiesFromForm({
|
||||
entity,
|
||||
values,
|
||||
}),
|
||||
});
|
||||
return new RowRepository(row, { session: this.session });
|
||||
}
|
||||
async deleteRow(entityName: string, rowId: string, options = { checkPermissions: true }) {
|
||||
return RowsApi.del(rowId, {
|
||||
entity: { name: entityName },
|
||||
tenantId: this.session.tenantId,
|
||||
userId: this.session?.userId,
|
||||
checkPermissions: options.checkPermissions,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Row } from "@toolctrl/db/prisma-browser";
|
||||
import { RowValueWithDetails, RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
|
||||
export type RowDto = (
|
||||
| (Row & {
|
||||
values: RowValueWithDetails[];
|
||||
})
|
||||
| RowWithDetails
|
||||
) & {
|
||||
metadata?: { [key: string]: any } | null;
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowValueHelper from "~/utils/helpers/RowValueHelper";
|
||||
import EntitiesSingleton from "./EntitiesSingleton";
|
||||
import { RowDto } from "./RowDto";
|
||||
|
||||
export default class RowModel {
|
||||
row: RowDto;
|
||||
constructor(
|
||||
row: RowDto,
|
||||
options?: {
|
||||
metadata?: string | null;
|
||||
}
|
||||
) {
|
||||
this.row = row;
|
||||
try {
|
||||
if (options?.metadata) {
|
||||
this.row.metadata = options?.metadata ? JSON.parse(options.metadata) : null;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
public getEntity() {
|
||||
const entities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = entities.find((entity) => entity.id === this.row.entityId);
|
||||
if (!entity) {
|
||||
throw new Error("Entity not found with ID: " + this.row.entityId);
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
public getParents(entityName: string) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = allEntities.find((e) => e.name === entityName);
|
||||
const relationship = this.getEntity().parentEntities.find((e) => e.parentId === entity?.id);
|
||||
if (!relationship) {
|
||||
return [];
|
||||
}
|
||||
if (!("parentRows" in this.row)) {
|
||||
return [];
|
||||
}
|
||||
const rows = this.row.parentRows
|
||||
.filter((r) => r.relationshipId === relationship.id)
|
||||
.sort((a, b) => {
|
||||
return a.createdAt > b.createdAt ? -1 : 1;
|
||||
});
|
||||
return rows.map(({ parent, metadata }) => new RowModel(parent, { metadata }));
|
||||
}
|
||||
public getFirstParent(entityName: string) {
|
||||
const parents = this.getParents(entityName);
|
||||
if (parents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return parents[0];
|
||||
}
|
||||
public getChildren(entityName: string) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = allEntities.find((e) => e.name === entityName);
|
||||
const relationship = this.getEntity().childEntities.find((e) => e.childId === entity?.id);
|
||||
if (!relationship) {
|
||||
return [];
|
||||
}
|
||||
if (!("childRows" in this.row)) {
|
||||
return [];
|
||||
}
|
||||
const rows = this.row.childRows
|
||||
.filter((r) => r.relationshipId === relationship.id)
|
||||
.sort((a, b) => {
|
||||
return a.createdAt > b.createdAt ? -1 : 1;
|
||||
});
|
||||
return rows.map(({ child, metadata }) => new RowModel(child, { metadata }));
|
||||
}
|
||||
public getFirstChild(entityName: string) {
|
||||
const children = this.getChildren(entityName);
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return children[0];
|
||||
}
|
||||
public getRelated(entityName: string) {
|
||||
const children = this.getChildren(entityName);
|
||||
const parents = this.getParents(entityName);
|
||||
return [...children, ...parents];
|
||||
}
|
||||
public getText(name: string) {
|
||||
return RowValueHelper.getText({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getBoolean(name: string) {
|
||||
return RowValueHelper.getBoolean({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getNumber(name: string) {
|
||||
return RowValueHelper.getNumber({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getDate(name: string) {
|
||||
return RowValueHelper.getDate({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getMedia(name: string) {
|
||||
return RowValueHelper.getMedia({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getFirstMedia(name: string) {
|
||||
return RowValueHelper.getFirstMedia({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getFirstMediaFile(name: string) {
|
||||
return RowValueHelper.getFirstMediaFile({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getMediaPublicUrl(name: string) {
|
||||
return RowValueHelper.getFirstMedia({ row: this.row, entity: this.getEntity(), name })?.publicUrl;
|
||||
}
|
||||
public getMediaPublicUrlOrFile(name: string) {
|
||||
const value = RowValueHelper.getFirstMedia({ row: this.row, entity: this.getEntity(), name });
|
||||
return value?.publicUrl || value?.file;
|
||||
}
|
||||
public getSelected(name: string) {
|
||||
return RowValueHelper.getSelected({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getMultiple(name: string) {
|
||||
return RowValueHelper.getMultiple({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getNumberRange(name: string) {
|
||||
return RowValueHelper.getNumberRange({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getDateRange(name: string) {
|
||||
return RowValueHelper.getDateRange({ row: this.row, entity: this.getEntity(), name });
|
||||
}
|
||||
public getValue(name: string) {
|
||||
const property = this.getEntity().properties.find((p) => p.name === name);
|
||||
const value = this.row.values.find((v) => v.propertyId === property?.id);
|
||||
return {
|
||||
property,
|
||||
value,
|
||||
};
|
||||
}
|
||||
public toString() {
|
||||
return RowHelper.getTextDescription({ item: this.row, entity: this.getEntity() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/api/.server/RowRelationshipsApi", () => ({
|
||||
RowRelationshipsApi: {
|
||||
createRelationship: vi.fn(),
|
||||
deleteRelationship: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/.server/RowValueService", () => ({
|
||||
default: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/RowHelper", () => ({
|
||||
default: {
|
||||
getTextDescription: vi.fn(() => ""),
|
||||
},
|
||||
}));
|
||||
|
||||
import EntitiesSingleton from "./EntitiesSingleton";
|
||||
import RowRepository from "./RowRepository.server";
|
||||
|
||||
const relationship = { id: "relationship-1", parentId: "parent-entity", childId: "child-entity" };
|
||||
const parentEntity = { id: "parent-entity", name: "parents", slug: "parents", properties: [], parentEntities: [], childEntities: [relationship] };
|
||||
const childEntity = { id: "child-entity", name: "children", slug: "children", properties: [], parentEntities: [relationship], childEntities: [] };
|
||||
const parentRow = { id: "parent-row", entityId: "parent-entity", tenantId: "tenant-1", values: [], parentRows: [], childRows: [] };
|
||||
const childRow = { id: "child-row", entityId: "child-entity", tenantId: "tenant-1", values: [], parentRows: [], childRows: [] };
|
||||
|
||||
describe("RowRepository tenant session propagation", () => {
|
||||
it("keeps the session on related parent and child repositories", () => {
|
||||
const session = { tenantId: "tenant-1", userId: "user-1" };
|
||||
EntitiesSingleton.getInstance().setEntities([parentEntity as any, childEntity as any]);
|
||||
const childRepository = new RowRepository(
|
||||
{
|
||||
...childRow,
|
||||
parentRows: [{ relationshipId: "relationship-1", parent: parentRow }],
|
||||
} as any,
|
||||
{ session },
|
||||
);
|
||||
const parentRepository = new RowRepository(
|
||||
{
|
||||
...parentRow,
|
||||
childRows: [{ relationshipId: "relationship-1", child: childRow }],
|
||||
} as any,
|
||||
{ session },
|
||||
);
|
||||
|
||||
expect(childRepository.getParents("parents")[0].session).toEqual(session);
|
||||
expect(parentRepository.getChildren("children")[0].session).toEqual(session);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { RowMedia } from "@toolctrl/db/prisma-browser";
|
||||
import { MediaDto } from "~/application/dtos/entities/MediaDto";
|
||||
import { RowValueMultipleDto } from "~/application/dtos/entities/RowValueMultipleDto";
|
||||
import { RowValueRangeDto } from "~/application/dtos/entities/RowValueRangeDto";
|
||||
import { RowRelationshipsApi } from "~/utils/api/.server/RowRelationshipsApi";
|
||||
import RowValueService, { RowValueUpdateDto } from "~/utils/helpers/.server/RowValueService";
|
||||
import EntitiesSingleton from "./EntitiesSingleton";
|
||||
import RowModel from "./RowModel";
|
||||
import { RowDto } from "./RowDto";
|
||||
|
||||
export default class RowRepository extends RowModel {
|
||||
session: { tenantId: string | null; userId?: string } | undefined;
|
||||
constructor(
|
||||
row: RowDto,
|
||||
options?: {
|
||||
session?: { tenantId: string | null; userId?: string } | undefined;
|
||||
}
|
||||
) {
|
||||
super(row);
|
||||
this.session = options?.session;
|
||||
}
|
||||
private async update(value: RowValueUpdateDto, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
await RowValueService.update({
|
||||
entity: this.getEntity(),
|
||||
row: this.row,
|
||||
session: this.session,
|
||||
values: [value],
|
||||
checkPermissions: options?.checkPermissions,
|
||||
options: { createLog: options?.createLog },
|
||||
});
|
||||
}
|
||||
public async updateMany(values: RowValueUpdateDto[], options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
await RowValueService.update({
|
||||
entity: this.getEntity(),
|
||||
row: this.row,
|
||||
session: this.session,
|
||||
values,
|
||||
checkPermissions: options?.checkPermissions,
|
||||
options: { createLog: options?.createLog },
|
||||
});
|
||||
}
|
||||
public async updateText(name: string, textValue: string | undefined, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
const value: RowValueUpdateDto = { name, textValue };
|
||||
await this.update(value, options);
|
||||
}
|
||||
public async updateNumber(name: string, numberValue: number | undefined, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
const value: RowValueUpdateDto = { name, numberValue };
|
||||
await this.update(value, options);
|
||||
}
|
||||
public async updateBoolean(name: string, booleanValue: boolean | undefined, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
const value: RowValueUpdateDto = { name, booleanValue };
|
||||
await this.update(value, options);
|
||||
}
|
||||
public async updateDate(name: string, dateValue: Date | undefined, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
const value: RowValueUpdateDto = { name, dateValue };
|
||||
await this.update(value, options);
|
||||
}
|
||||
public async updateMedia(name: string, media: MediaDto[] | undefined, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
const value: RowValueUpdateDto = { name, media: media as RowMedia[] };
|
||||
await this.update(value, options);
|
||||
}
|
||||
public async updateMultiple(name: string, multiple: RowValueMultipleDto[] | undefined, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
const value: RowValueUpdateDto = { name, multiple };
|
||||
await this.update(value, options);
|
||||
}
|
||||
public async updateRange(name: string, range: RowValueRangeDto | undefined, options?: { checkPermissions?: boolean; createLog?: boolean }) {
|
||||
const value: RowValueUpdateDto = { name, range };
|
||||
await this.update(value, options);
|
||||
}
|
||||
public async addChild(
|
||||
relatedRow:
|
||||
| { id: string; entityId: string }
|
||||
| {
|
||||
id: string;
|
||||
entityName: string;
|
||||
},
|
||||
options?: { metadata?: { [key: string]: any } | null }
|
||||
) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
let metadataString: string | null = null;
|
||||
if (options?.metadata) {
|
||||
try {
|
||||
metadataString = JSON.stringify(options?.metadata);
|
||||
} catch {}
|
||||
}
|
||||
return await RowRelationshipsApi.createRelationship({ parent: this.row, child: relatedRow, tenantId: this.session?.tenantId ?? null, metadata: metadataString, allEntities });
|
||||
}
|
||||
public async removeChild(relatedRow: { id: string; entityId: string } | { id: string; entityName: string }) {
|
||||
const deleted = await RowRelationshipsApi.deleteRelationship({ parent: this.row, child: relatedRow, tenantId: this.session?.tenantId ?? null });
|
||||
if (deleted.count) {
|
||||
return;
|
||||
}
|
||||
// remove child
|
||||
if (!("childRows" in this.row)) {
|
||||
return [];
|
||||
}
|
||||
const childRowIndex = this.row.childRows.findIndex((r) => r.childId === relatedRow.id);
|
||||
if (childRowIndex !== -1) {
|
||||
this.row.childRows.splice(childRowIndex, 1);
|
||||
}
|
||||
}
|
||||
public async addParent(
|
||||
relatedRow: { id: string; entityId: string } | { id: string; entityName: string },
|
||||
options?: { metadata?: { [key: string]: any } | null }
|
||||
) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
let metadataString: string | null = null;
|
||||
if (options?.metadata) {
|
||||
try {
|
||||
metadataString = JSON.stringify(options.metadata);
|
||||
} catch {}
|
||||
}
|
||||
return await RowRelationshipsApi.createRelationship({ parent: relatedRow, child: this.row, tenantId: this.session?.tenantId ?? null, metadata: metadataString, allEntities });
|
||||
}
|
||||
public async removeParent(relatedRow: { id: string; entityId: string } | { id: string; entityName: string }) {
|
||||
return await RowRelationshipsApi.deleteRelationship({ parent: relatedRow, child: this.row, tenantId: this.session?.tenantId ?? null });
|
||||
}
|
||||
public getParents(entityName: string) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = allEntities.find((e) => e.name === entityName);
|
||||
const relationship = this.getEntity().parentEntities.find((e) => e.parentId === entity?.id);
|
||||
if (!relationship) {
|
||||
return [];
|
||||
}
|
||||
if (!("parentRows" in this.row)) {
|
||||
return [];
|
||||
}
|
||||
const rows = this.row.parentRows.filter((r) => r.relationshipId === relationship.id).map((r) => r.parent);
|
||||
return rows.map((row) => new RowRepository(row, { session: this.session }));
|
||||
}
|
||||
public getFirstParent(entityName: string) {
|
||||
const parents = this.getParents(entityName);
|
||||
if (parents.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return parents[0];
|
||||
}
|
||||
public getChildren(entityName: string) {
|
||||
const allEntities = EntitiesSingleton.getInstance().getEntities();
|
||||
const entity = allEntities.find((e) => e.name === entityName);
|
||||
const relationship = this.getEntity().childEntities.find((e) => e.childId === entity?.id);
|
||||
if (!relationship) {
|
||||
return [];
|
||||
}
|
||||
if (!("childRows" in this.row)) {
|
||||
return [];
|
||||
}
|
||||
const rows = this.row.childRows.filter((r) => r.relationshipId === relationship.id).map((r) => r.child);
|
||||
return rows.map((row) => new RowRepository(row, { session: this.session }));
|
||||
}
|
||||
public getFirstChild(entityName: string) {
|
||||
const children = this.getChildren(entityName);
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return children[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ActionFunction, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import type { TFunction } from "i18next";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { EntityWithDetails, getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import { getUser } from "~/utils/db/users.db.server";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import { verifyUserHasEntityPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import FormulaService from "~/modules/formulas/services/.server/FormulaService";
|
||||
import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server";
|
||||
import { createScopedLogger } from "~/utils/logger.server";
|
||||
import i18next from "i18next";
|
||||
import { translateEntityTitlePlural } from "~/utils/i18n/translateLabel";
|
||||
import { getRowsRouteErrorCode, getRowsRouteErrorStatus, translateRowsRouteError } from "./Rows_Error.server";
|
||||
|
||||
const rowsEditLogger = createScopedLogger("rows.edit");
|
||||
|
||||
export namespace Rows_Edit {
|
||||
export type ActionData = {
|
||||
updatedRow?: RowsApi.GetRowData;
|
||||
deleted?: boolean;
|
||||
error?: string;
|
||||
errorCode?: import("~/utils/api/.server/RowsApi").RowApiErrorCode;
|
||||
};
|
||||
|
||||
export type LoaderData = {
|
||||
meta: MetaTagsDto;
|
||||
rowData: RowsApi.GetRowData;
|
||||
routes: EntitiesApi.Routes;
|
||||
allEntities: EntityWithDetails[];
|
||||
relationshipRows: RowsApi.GetRelationshipRowsData;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Edit] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
const user = await time(getUser(userId), "getUser");
|
||||
await time(verifyUserHasEntityPermission(request, entity, "update", tenantId), "verifyUserHasPermission");
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404?entity=" + params.entity);
|
||||
}
|
||||
const rowData = await time(
|
||||
RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
}),
|
||||
"RowsApi.get"
|
||||
);
|
||||
if (!rowData.rowPermissions.canUpdate && !user?.admin) {
|
||||
throw Error(t("shared.unauthorized"));
|
||||
}
|
||||
const entityPluralLabel = translateEntityTitlePlural(t, i18next, entity);
|
||||
const data: LoaderData = {
|
||||
meta: [
|
||||
{
|
||||
title: `${t("shared.edit")} | ${RowHelper.getTextDescription({ entity, item: rowData.item, t })} | ${entityPluralLabel} | ${
|
||||
process.env.APP_NAME
|
||||
}`,
|
||||
},
|
||||
],
|
||||
rowData,
|
||||
routes: EntitiesApi.getNoCodeRoutes({ request, params }),
|
||||
allEntities: await time(getAllEntities({ tenantId }), "getAllEntities"),
|
||||
relationshipRows: await time(RowsApi.getRelationshipRows({ entity, tenantId, userId }), "RowsApi.getRelationshipRows"),
|
||||
};
|
||||
return Response.json(data, { headers: getServerTimingHeader() });
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Edit] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity, form } = await RowsRequestUtils.getAction({ request, params });
|
||||
const user = await getUser(userId);
|
||||
const { item } = await time(
|
||||
RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
}),
|
||||
"RowsApi.get"
|
||||
);
|
||||
const action = form.get("action");
|
||||
let rowValues: any = {};
|
||||
if (action === "edit") {
|
||||
try {
|
||||
rowValues = RowHelper.getRowPropertiesFromForm({ t, entity, form, existing: item });
|
||||
const updatedRow = await time(
|
||||
RowsApi.update(params.id!, {
|
||||
entity,
|
||||
request,
|
||||
tenantId,
|
||||
userId,
|
||||
rowValues,
|
||||
}),
|
||||
"RowsApi.update"
|
||||
);
|
||||
await time(
|
||||
FormulaService.trigger({ trigger: "AFTER_UPDATED", rows: [updatedRow], entity: entity, session: { tenantId, userId }, t }),
|
||||
"FormulaService.trigger.AFTER_UPDATED"
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return rowActionError(t, error, getServerTimingHeader());
|
||||
}
|
||||
const updatedRow = await RowsApi.get(params.id!, { entity, tenantId, userId });
|
||||
if (item.createdByUser) {
|
||||
rowsEditLogger.info({ createdByUser: item.createdByUser }, "Sending notification");
|
||||
await NotificationService.send({
|
||||
channel: "my-rows",
|
||||
to: item.createdByUser,
|
||||
tenantId,
|
||||
notification: {
|
||||
from: { user },
|
||||
message: t("notifications.messages.row.updated", {
|
||||
email: user?.email ?? t("shared.unknown"),
|
||||
row: RowHelper.getTextDescription({ entity, item, t }),
|
||||
}),
|
||||
action: {
|
||||
title: t("shared.view"),
|
||||
url: EntityHelper.getRoutes({ routes: EntitiesApi.getNoCodeRoutes({ request, params }), entity, item })?.overview ?? "",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const redirectTo = form.get("redirect")?.toString() || new URL(request.url).searchParams.get("redirect")?.toString();
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo), { headers: getServerTimingHeader() });
|
||||
}
|
||||
return Response.json({ updatedRow } satisfies ActionData, { headers: getServerTimingHeader() });
|
||||
} else if (action === "delete") {
|
||||
try {
|
||||
await time(verifyUserHasEntityPermission(request, entity, "delete", tenantId), "verifyUserHasPermission");
|
||||
await RowsApi.del(params.id!, {
|
||||
entity,
|
||||
request,
|
||||
tenantId,
|
||||
userId,
|
||||
checkPermissions: !user?.admin,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
return rowActionError(t, error, getServerTimingHeader());
|
||||
}
|
||||
const redirectTo = form.get("redirect")?.toString() || new URL(request.url).searchParams.get("redirect")?.toString();
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo), { headers: getServerTimingHeader() });
|
||||
}
|
||||
return Response.json({ deleted: true } satisfies ActionData, { headers: getServerTimingHeader() });
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function rowActionError(t: TFunction, error: unknown, headers: HeadersInit) {
|
||||
return Response.json(
|
||||
{ error: translateRowsRouteError(t, error), errorCode: getRowsRouteErrorCode(error) } satisfies Rows_Edit.ActionData,
|
||||
{ status: getRowsRouteErrorStatus(error), headers }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const routesDir = path.resolve("apps/web/app/modules/rows/routes");
|
||||
const rowsApiSource = readFileSync(path.resolve("apps/web/app/utils/api/.server/RowsApi.ts"), "utf8");
|
||||
const rowsErrorSource = readFileSync(path.join(routesDir, "Rows_Error.server.ts"), "utf8");
|
||||
|
||||
function readRoute(file: string) {
|
||||
return readFileSync(path.join(routesDir, file), "utf8");
|
||||
}
|
||||
|
||||
describe("Rows route error i18n mapping", () => {
|
||||
it("maps stable row API error codes to i18n keys with an unexpected fallback", () => {
|
||||
expect(rowsErrorSource).toContain("Record<RowApiErrorCode, string>");
|
||||
expect(rowsErrorSource).toContain('"row-access-denied": "models.row.errors.accessDenied"');
|
||||
expect(rowsErrorSource).toContain('"row-update-denied": "models.row.errors.updateDenied"');
|
||||
expect(rowsErrorSource).toContain('"row-delete-denied": "models.row.errors.deleteDenied"');
|
||||
expect(rowsErrorSource).toContain('t("shared.unexpectedError")');
|
||||
});
|
||||
|
||||
it("does not return raw thrown messages from visible rows mutations", () => {
|
||||
for (const file of ["Rows_Overview.server.ts", "Rows_List.server.tsx", "Rows_New.server.ts", "Rows_Edit.server.ts", "Rows_Share.server.ts", "Rows_Import.server.ts"]) {
|
||||
const source = readRoute(file);
|
||||
|
||||
expect(source).not.toContain("error.message");
|
||||
expect(source).not.toContain("e.message");
|
||||
expect(source).toContain("translateRowsRouteError");
|
||||
}
|
||||
});
|
||||
|
||||
it("does not expose raw English row access errors from RowsApi", () => {
|
||||
expect(rowsApiSource).not.toContain('Response.json({ error: "You don');
|
||||
expect(rowsApiSource).toContain('rowApiError("row-access-denied", 403)');
|
||||
expect(rowsApiSource).toContain('rowApiError("row-update-denied", 403)');
|
||||
expect(rowsApiSource).toContain('rowApiError("row-delete-denied", 403)');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { RowsApi, type RowApiErrorCode } from "~/utils/api/.server/RowsApi";
|
||||
|
||||
const rowApiErrorKeys: Record<RowApiErrorCode, string> = {
|
||||
"entity-not-found": "models.row.errors.entityNotFound",
|
||||
"row-not-found": "models.row.errors.notFound",
|
||||
"row-access-denied": "models.row.errors.accessDenied",
|
||||
"row-update-denied": "models.row.errors.updateDenied",
|
||||
"row-delete-denied": "models.row.errors.deleteDenied",
|
||||
"permission-unknown": "models.row.errors.unknownPermission",
|
||||
"permission-missing": "models.row.errors.missingPermission",
|
||||
"usage-limit-disabled": "models.row.errors.usageLimitDisabled",
|
||||
};
|
||||
|
||||
export function getRowsRouteErrorStatus(error: unknown) {
|
||||
return RowsApi.isRowApiError(error) ? error.status : 400;
|
||||
}
|
||||
|
||||
export function translateRowsRouteError(t: TFunction, error: unknown) {
|
||||
if (!RowsApi.isRowApiError(error)) {
|
||||
return t("shared.unexpectedError");
|
||||
}
|
||||
return t(rowApiErrorKeys[error.code], error.details ?? {});
|
||||
}
|
||||
|
||||
export function getRowsRouteErrorCode(error: unknown) {
|
||||
return RowsApi.isRowApiError(error) ? error.code : undefined;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { serializeExportTableToCsv } from "@toolctrl/exports/csv";
|
||||
import Constants from "~/application/Constants";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import ApiHelper from "~/utils/helpers/ApiHelper";
|
||||
import { getEntityPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import i18next from "i18next";
|
||||
import { translateEntityTitlePlural } from "~/utils/i18n/translateLabel";
|
||||
|
||||
export namespace Rows_Export {
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Export] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
await time(verifyUserHasPermission(request, getEntityPermission(entity, "view"), tenantId), "verifyUserHasPermission");
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
const rowsData = await time(
|
||||
RowsApi.getAll({
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
urlSearchParams: new URL(request.url).searchParams,
|
||||
pageSize: Constants.MAX_EXPORT_PAGE_SIZE,
|
||||
time,
|
||||
}),
|
||||
"RowsApi.getAll"
|
||||
);
|
||||
const apiFormat = rowsData.items.map((item) => {
|
||||
return ApiHelper.getApiFormat(entity, item);
|
||||
});
|
||||
const csv = serializeExportTableToCsv({ columns: entity.properties.map((f) => f.name), rows: apiFormat }, { delimiter: "|" });
|
||||
const entityPluralLabel = translateEntityTitlePlural(t, i18next, entity);
|
||||
return new Response(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv",
|
||||
"Content-Disposition": `attachment; filename=${entityPluralLabel}.csv`,
|
||||
...getServerTimingHeader(),
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Rows_Import } from "./Rows_Import.server";
|
||||
|
||||
const getActionMock = vi.fn();
|
||||
const verifyUserHasEntityPermissionMock = vi.fn();
|
||||
const rowsCreateMock = vi.fn();
|
||||
const rowsAddTagMock = vi.fn();
|
||||
const getMaxRowFolioMock = vi.fn();
|
||||
const getRowByIdMock = vi.fn();
|
||||
|
||||
vi.mock("../utils/RowsRequestUtils", () => ({
|
||||
default: {
|
||||
getAction: (...args: unknown[]) => getActionMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/.server/PermissionsService", () => ({
|
||||
verifyUserHasEntityPermission: (...args: unknown[]) => verifyUserHasEntityPermissionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/api/.server/RowsApi", () => ({
|
||||
RowsApi: {
|
||||
create: (...args: unknown[]) => rowsCreateMock(...args),
|
||||
addTag: (...args: unknown[]) => rowsAddTagMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/db/entities/rows.db.server", () => ({
|
||||
getMaxRowFolio: (...args: unknown[]) => getMaxRowFolioMock(...args),
|
||||
getRowById: (...args: unknown[]) => getRowByIdMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/helpers/RowHelper", () => ({
|
||||
default: {
|
||||
getRowPropertiesFromForm: vi.fn(({ values }) => values),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("~/utils/db/tenants.db.server", () => ({
|
||||
adminGetAllTenantsIdsAndNames: vi.fn(),
|
||||
getTenant: vi.fn(),
|
||||
}));
|
||||
|
||||
function buildForm(rows: number) {
|
||||
const form = new FormData();
|
||||
form.set("action", "import");
|
||||
for (let index = 0; index < rows; index += 1) {
|
||||
form.append("rows[]", JSON.stringify({ properties: [{ name: "name", value: `Row ${index + 1}` }] }));
|
||||
}
|
||||
return form;
|
||||
}
|
||||
|
||||
function mockActionContext(form: FormData) {
|
||||
getActionMock.mockResolvedValue({
|
||||
t: (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
|
||||
userId: "user-1",
|
||||
tenantId: "tenant-1",
|
||||
entity: { id: "entity-1", name: "contacts", titlePlural: "Contacts", isAutogenerated: true, type: "app" },
|
||||
form,
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
describe("Rows_Import.action", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
verifyUserHasEntityPermissionMock.mockResolvedValue(undefined);
|
||||
rowsAddTagMock.mockResolvedValue(undefined);
|
||||
getMaxRowFolioMock.mockResolvedValue({ _max: { folio: 10 } });
|
||||
getRowByIdMock.mockImplementation(async (id: string) => ({ id }));
|
||||
});
|
||||
|
||||
it("rejects oversized import requests before creating rows", async () => {
|
||||
mockActionContext(buildForm(Rows_Import.MAX_IMPORT_ROWS + 1));
|
||||
|
||||
const response = await Rows_Import.action({
|
||||
request: new Request("http://localhost/import", { method: "POST" }),
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect((response as Response).status).toBe(400);
|
||||
await expect((response as Response).json()).resolves.toMatchObject({
|
||||
error: expect.stringContaining("models.row.import.errors.tooManyRows"),
|
||||
});
|
||||
expect(rowsCreateMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports rows with bounded concurrency while preserving response order", async () => {
|
||||
mockActionContext(buildForm(8));
|
||||
let active = 0;
|
||||
let maxActive = 0;
|
||||
rowsCreateMock.mockImplementation(async ({ nextFolio }: { nextFolio: number }) => {
|
||||
active += 1;
|
||||
maxActive = Math.max(maxActive, active);
|
||||
await delay(5);
|
||||
active -= 1;
|
||||
return { id: `row-${nextFolio}` };
|
||||
});
|
||||
|
||||
const result = await Rows_Import.action({
|
||||
request: new Request("http://localhost/import", { method: "POST" }),
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
expect(result).not.toBeInstanceOf(Response);
|
||||
expect(rowsCreateMock).toHaveBeenCalledTimes(8);
|
||||
expect(maxActive).toBeLessThanOrEqual(5);
|
||||
expect((result as Rows_Import.ActionData).rows?.map((row) => row.row?.id)).toEqual([
|
||||
"row-11",
|
||||
"row-12",
|
||||
"row-13",
|
||||
"row-14",
|
||||
"row-15",
|
||||
"row-16",
|
||||
"row-17",
|
||||
"row-18",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import { mapWithConcurrency } from "@toolctrl/foundation";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import { getMaxRowFolio, getRowById, RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import { adminGetAllTenantsIdsAndNames, getTenant } from "~/utils/db/tenants.db.server";
|
||||
import { verifyUserHasEntityPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import i18next from "i18next";
|
||||
import { translateEntityTitlePlural } from "~/utils/i18n/translateLabel";
|
||||
import { translateRowsRouteError } from "./Rows_Error.server";
|
||||
|
||||
export namespace Rows_Import {
|
||||
export const MAX_IMPORT_ROWS = 500;
|
||||
const IMPORT_ROW_CONCURRENCY = 5;
|
||||
|
||||
export type LoaderData = {
|
||||
meta: MetaTagsDto;
|
||||
entity: EntityWithDetails;
|
||||
allTenants: { id: string; name: string; slug: string }[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
const entityPluralLabel = translateEntityTitlePlural(t, i18next, entity);
|
||||
const data: LoaderData = {
|
||||
meta: [{ title: `${t("shared.import")} ${entityPluralLabel} | ${process.env.APP_NAME}` }],
|
||||
entity,
|
||||
allTenants: !tenantId ? await adminGetAllTenantsIdsAndNames() : [],
|
||||
};
|
||||
return Response.json(data);
|
||||
};
|
||||
|
||||
export interface ImportRow {
|
||||
properties: { name: string; value: string }[];
|
||||
row?: RowWithDetails | null;
|
||||
error?: string;
|
||||
}
|
||||
export type ActionData = {
|
||||
rows?: ImportRow[];
|
||||
error?: string;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t, userId, tenantId, entity, form } = await RowsRequestUtils.getAction({ request, params });
|
||||
const action = form.get("action");
|
||||
if (action === "import") {
|
||||
await verifyUserHasEntityPermission(request, entity, "create", tenantId);
|
||||
const tag = form.get("tag")?.toString() ?? t("models.row.import.defaultTag");
|
||||
const rawRows: ImportRow[] = form.getAll("rows[]").map((f: FormDataEntryValue) => {
|
||||
return JSON.parse(f.toString());
|
||||
});
|
||||
let tenantToImport = tenantId;
|
||||
if (tenantId === null) {
|
||||
const selectedTenantId = form.get("selectedTenantId")?.toString() || "{null}";
|
||||
if (selectedTenantId === "{null}") {
|
||||
tenantToImport = null;
|
||||
} else {
|
||||
const existingTenant = await getTenant(selectedTenantId);
|
||||
if (!existingTenant) {
|
||||
return Response.json(
|
||||
{
|
||||
error: t("models.row.import.errors.invalidTenantId", { tenantId: selectedTenantId }),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
tenantToImport = selectedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
if (rawRows.length === 0) {
|
||||
return Response.json(
|
||||
{ error: t("models.row.import.errors.noRowsToImport") },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (rawRows.length > MAX_IMPORT_ROWS) {
|
||||
return Response.json(
|
||||
{ error: t("models.row.import.errors.tooManyRows", { count: rawRows.length, max: MAX_IMPORT_ROWS }) },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const rows: ImportRow[] = new Array(rawRows.length);
|
||||
let folio = 1;
|
||||
const maxFolio = await getMaxRowFolio({ tenantId: tenantToImport, entityId: entity.id });
|
||||
if (maxFolio && maxFolio._max.folio !== null) {
|
||||
folio = maxFolio._max.folio + 1;
|
||||
}
|
||||
await mapWithConcurrency(
|
||||
rawRows.map((importRow, idx) => ({ importRow, idx })),
|
||||
IMPORT_ROW_CONCURRENCY,
|
||||
async ({ importRow, idx }) => {
|
||||
try {
|
||||
const rowValues = RowHelper.getRowPropertiesFromForm({ t, entity, values: importRow.properties });
|
||||
const newRow = await RowsApi.create({
|
||||
entity,
|
||||
tenantId: tenantToImport,
|
||||
userId,
|
||||
rowValues,
|
||||
nextFolio: folio + idx,
|
||||
request,
|
||||
});
|
||||
if (tag) {
|
||||
await RowsApi.addTag({ row: newRow, tag: { value: tag, color: Colors.INDIGO } });
|
||||
}
|
||||
importRow.row = await getRowById(newRow.id, { entityId: entity.id, tenantId: tenantToImport });
|
||||
} catch (e: unknown) {
|
||||
importRow.error = translateRowsRouteError(t, e);
|
||||
}
|
||||
rows[idx] = importRow;
|
||||
}
|
||||
);
|
||||
const data: ActionData = {
|
||||
rows,
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function readRouteSource() {
|
||||
return readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "Rows_List.server.tsx"), "utf8");
|
||||
}
|
||||
|
||||
describe("Rows_List mutation permissions", () => {
|
||||
it("requires entity update permission for view and order mutations", () => {
|
||||
const source = readRouteSource();
|
||||
|
||||
expect(source).toContain('["view-create", "view-edit", "view-delete", "move-up", "move-down"].includes(action)');
|
||||
expect(source).toContain('verifyUserHasEntityPermission(request, entity, "update", tenantId)');
|
||||
expect(source.indexOf("verifyUserHasPermission.update")).toBeLessThan(source.indexOf('if (action === "view-create")'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const rowsListSource = readFileSync(path.join(appDir, "modules/rows/routes/Rows_List.server.tsx"), "utf8");
|
||||
const entityViewsApiSource = readFileSync(path.join(appDir, "utils/api/.server/EntityViewsApi.ts"), "utf8");
|
||||
|
||||
describe("Rows_List view mutations", () => {
|
||||
it("loads and mutates entity views through scoped helpers", () => {
|
||||
expect(rowsListSource).toContain("getEntityView(id, { entityId: entity.id, tenantId, userId })");
|
||||
expect(rowsListSource).toContain("deleteScopedEntityView({ id: item.id, entityId: entity.id, tenantId, userId: item.userId })");
|
||||
expect(entityViewsApiSource).toContain("updateScopedEntityView(viewScope");
|
||||
expect(entityViewsApiSource).toContain("updateScopedEntityViewProperties(viewScope");
|
||||
expect(entityViewsApiSource).toContain("updateScopedEntityViewFilters(viewScope");
|
||||
expect(entityViewsApiSource).toContain("updateScopedEntityViewSort(viewScope");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { ActionFunction, LoaderFunctionArgs, redirect } from "react-router";
|
||||
import type { TFunction } from "i18next";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import { verifyUserHasEntityPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { EntityViewsApi } from "~/utils/api/.server/EntityViewsApi";
|
||||
import { deleteScopedEntityView, getEntityView } from "~/utils/db/entities/entityViews.db.server";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import FormulaService from "~/modules/formulas/services/.server/FormulaService";
|
||||
import { EntityView } from "@toolctrl/db/prisma-browser";
|
||||
import { getRowsInIds } from "~/utils/db/entities/rows.db.server";
|
||||
import i18next from "i18next";
|
||||
import { translateEntityTitlePlural } from "~/utils/i18n/translateLabel";
|
||||
import { getRowsRouteErrorCode, getRowsRouteErrorStatus, translateRowsRouteError } from "./Rows_Error.server";
|
||||
|
||||
export namespace Rows_List {
|
||||
export type LoaderData = {
|
||||
meta: MetaTagsDto;
|
||||
rowsData: RowsApi.GetRowsData;
|
||||
routes: EntitiesApi.Routes;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_List] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
await time(verifyUserHasEntityPermission(request, entity, "view", tenantId), "verifyUserHasPermission");
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
const rowsData = await time(
|
||||
RowsApi.getAll({
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
urlSearchParams: new URL(request.url).searchParams,
|
||||
time,
|
||||
}),
|
||||
"RowsApi.getAll"
|
||||
);
|
||||
await time(
|
||||
FormulaService.trigger({ trigger: "BEFORE_LISTED", rows: rowsData.items, entity: rowsData.entity, session: { tenantId, userId }, t }),
|
||||
"FormulaService.trigger.BEFORE_LISTED"
|
||||
);
|
||||
const entityPluralLabel = translateEntityTitlePlural(t, i18next, entity);
|
||||
const data: LoaderData = {
|
||||
meta: [{ title: `${entityPluralLabel} | ${process.env.APP_NAME}` }],
|
||||
rowsData,
|
||||
routes: EntitiesApi.getNoCodeRoutes({ request, params }),
|
||||
};
|
||||
return Response.json(data, { headers: getServerTimingHeader() });
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
errorCode?: import("~/utils/api/.server/RowsApi").RowApiErrorCode;
|
||||
updatedView?: EntityView;
|
||||
rowsDeleted?: string[];
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_List] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity, form } = await RowsRequestUtils.getAction({ request, params });
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const requiresEntityUpdate = ["view-create", "view-edit", "view-delete", "move-up", "move-down"].includes(action);
|
||||
if (requiresEntityUpdate) {
|
||||
await time(verifyUserHasEntityPermission(request, entity, "update", tenantId), "verifyUserHasPermission.update");
|
||||
}
|
||||
if (action === "view-create") {
|
||||
try {
|
||||
form.set("tenantId", tenantId ?? "");
|
||||
form.set("userId", "");
|
||||
const view = await time(EntityViewsApi.createFromForm({ entity, form, createdByUserId: userId }), "EntityViewsApi.createFromForm");
|
||||
return redirect(new URL(request.url, request.url).pathname + "?v=" + view.name, { headers: getServerTimingHeader() });
|
||||
} catch (e: unknown) {
|
||||
return rowActionError(t, e, getServerTimingHeader());
|
||||
}
|
||||
} else if (action === "view-edit" || action === "view-delete") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const item = await time(getEntityView(id, { entityId: entity.id, tenantId, userId }), "getEntityView");
|
||||
if (!item || item.entityId !== entity.id || item.tenantId !== tenantId || item.isSystem || (item.userId && item.userId !== userId)) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
try {
|
||||
if (action === "view-edit") {
|
||||
const updatedView = await time(EntityViewsApi.updateFromForm({ entity, item, form }), "EntityViewsApi.updateFromForm");
|
||||
const actionData: ActionData = {
|
||||
updatedView,
|
||||
};
|
||||
return Response.json(actionData, { headers: getServerTimingHeader() });
|
||||
} else {
|
||||
await time(deleteScopedEntityView({ id: item.id, entityId: entity.id, tenantId, userId: item.userId }), "deleteEntityView");
|
||||
return redirect(new URL(request.url, request.url).pathname + "?v=", { headers: getServerTimingHeader() });
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return rowActionError(t, e, getServerTimingHeader());
|
||||
}
|
||||
} else if (["move-up", "move-down"].includes(action)) {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
await time(
|
||||
RowsApi.changeOrder(
|
||||
{ id, entityId: entity.id, tenantId },
|
||||
{
|
||||
target: action === "move-up" ? "up" : "down",
|
||||
}
|
||||
),
|
||||
"RowsApi.changeOrder"
|
||||
);
|
||||
return Response.json({}, { headers: getServerTimingHeader() });
|
||||
} else if (action === "bulk-delete") {
|
||||
if (!entity.hasBulkDelete) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
const rowIds = form.getAll("rowIds[]") as string[];
|
||||
try {
|
||||
const rows = await getRowsInIds(rowIds, tenantId);
|
||||
const inexistentRows = rowIds.filter((id) => !rows.find((f) => f.id === id && f.entityId === entity.id && f.tenantId === tenantId));
|
||||
if (inexistentRows.length > 0) {
|
||||
return Response.json({ error: t("shared.notFound") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
const rowsDeleted: string[] = [];
|
||||
await Promise.all(
|
||||
rows.map(async (row) => {
|
||||
const deleted = await RowsApi.del(row.id, { entity, request, tenantId, userId });
|
||||
rowsDeleted.push(deleted.id);
|
||||
})
|
||||
);
|
||||
return Response.json({ success: t("shared.deleted"), rowsDeleted }, { headers: getServerTimingHeader() });
|
||||
} catch (e: unknown) {
|
||||
return rowActionError(t, e, getServerTimingHeader());
|
||||
}
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function rowActionError(t: TFunction, error: unknown, headers: HeadersInit) {
|
||||
return Response.json(
|
||||
{ error: translateRowsRouteError(t, error), errorCode: getRowsRouteErrorCode(error) } satisfies Rows_List.ActionData,
|
||||
{ status: getRowsRouteErrorStatus(error), headers }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import type { TFunction } from "i18next";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { EntityWithDetails, getAllEntities } from "~/utils/db/entities/entities.db.server";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import { verifyUserHasEntityPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import { getUserInfo } from "~/utils/session.server";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import FormulaService from "~/modules/formulas/services/.server/FormulaService";
|
||||
import { RowWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server";
|
||||
import i18next from "i18next";
|
||||
import { translateEntityTitle } from "~/utils/i18n/translateLabel";
|
||||
import { getRowsRouteErrorCode, getRowsRouteErrorStatus, translateRowsRouteError } from "./Rows_Error.server";
|
||||
|
||||
export namespace Rows_New {
|
||||
export type LoaderData = {
|
||||
meta: MetaTagsDto;
|
||||
entityData: EntitiesApi.GetEntityData;
|
||||
routes: EntitiesApi.Routes;
|
||||
allEntities: EntityWithDetails[];
|
||||
relationshipRows: RowsApi.GetRelationshipRowsData;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_New] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404", { status: 404, headers: getServerTimingHeader() });
|
||||
}
|
||||
const entityData = await time(
|
||||
EntitiesApi.get({
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
}),
|
||||
"EntitiesApi"
|
||||
);
|
||||
await time(verifyUserHasEntityPermission(request, entity, "create", tenantId), "verifyUserHasPermission");
|
||||
const entityLabel = translateEntityTitle(t, i18next, entity);
|
||||
const data: LoaderData = {
|
||||
meta: [{ title: `${t("shared.create")} ${entityLabel} | ${process.env.APP_NAME}` }],
|
||||
entityData,
|
||||
routes: EntitiesApi.getNoCodeRoutes({ request, params }),
|
||||
allEntities: await time(getAllEntities({ tenantId, active: true }), "getAllEntities"),
|
||||
relationshipRows: await time(RowsApi.getRelationshipRows({ entity, tenantId, userId }), "RowsApi.getRelationshipRows"),
|
||||
};
|
||||
return Response.json(data, { headers: getServerTimingHeader() });
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
saveAndAdd?: boolean;
|
||||
newRow?: RowWithDetails;
|
||||
error?: string;
|
||||
errorCode?: import("~/utils/api/.server/RowsApi").RowApiErrorCode;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_New] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity, form } = await RowsRequestUtils.getAction({ request, params });
|
||||
const action = form.get("action");
|
||||
if (action === "create") {
|
||||
try {
|
||||
await time(verifyUserHasEntityPermission(request, entity, "create", tenantId), "verifyUserHasPermission");
|
||||
const rowValues = RowHelper.getRowPropertiesFromForm({ t: t, entity, form });
|
||||
const newRow = await time(
|
||||
RowsApi.create({
|
||||
entity,
|
||||
tenantId,
|
||||
userId: (await getUserInfo(request)).userId,
|
||||
rowValues,
|
||||
request,
|
||||
}),
|
||||
"RowsApi.create"
|
||||
);
|
||||
await time(
|
||||
FormulaService.trigger({ trigger: "AFTER_CREATED", rows: [newRow], entity: entity, session: { tenantId, userId }, t }),
|
||||
"FormulaService.trigger.AFTER_CREATED"
|
||||
);
|
||||
const onCreatedRedirect = form.get("onCreatedRedirect");
|
||||
if (onCreatedRedirect) {
|
||||
if (onCreatedRedirect === "addAnother") {
|
||||
return Response.json({ saveAndAdd: true, newRow, headers: getServerTimingHeader() });
|
||||
}
|
||||
const routes = EntityHelper.getRoutes({ routes: EntitiesApi.getNoCodeRoutes({ request, params }), entity, item: newRow });
|
||||
if (routes) {
|
||||
if (!entity.onCreated || entity.onCreated === "redirectToOverview") {
|
||||
return redirect(routes?.overview ?? "", { headers: getServerTimingHeader() });
|
||||
} else if (entity.onCreated === "redirectToEdit") {
|
||||
return redirect(routes?.edit ?? "", { headers: getServerTimingHeader() });
|
||||
} else if (entity.onCreated === "redirectToList") {
|
||||
return redirect(routes?.list ?? "", { headers: getServerTimingHeader() });
|
||||
} else if (entity.onCreated === "redirectToNew") {
|
||||
return Response.json({ newRow, replace: true }, { headers: getServerTimingHeader() });
|
||||
} else if (params.group && entity.onCreated === "redirectToGroup") {
|
||||
return redirect(routes?.group ?? "", { headers: getServerTimingHeader() });
|
||||
}
|
||||
}
|
||||
}
|
||||
const redirectTo = form.get("redirect")?.toString() || new URL(request.url).searchParams.get("redirect")?.toString();
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo), { headers: getServerTimingHeader() });
|
||||
}
|
||||
return Response.json({ newRow, headers: getServerTimingHeader() });
|
||||
} catch (error: unknown) {
|
||||
return rowActionError(t, error, getServerTimingHeader());
|
||||
}
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function rowActionError(t: TFunction, error: unknown, headers: HeadersInit) {
|
||||
return Response.json(
|
||||
{ error: translateRowsRouteError(t, error), errorCode: getRowsRouteErrorCode(error) } satisfies Rows_New.ActionData,
|
||||
{ status: getRowsRouteErrorStatus(error), headers }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const source = fs.readFileSync(path.resolve("apps/web/app/modules/rows/routes/Rows_Overview.server.ts"), "utf8");
|
||||
|
||||
describe("Rows_Overview action permission gates", () => {
|
||||
it("uses delete permission for row delete actions", () => {
|
||||
expect(source).toContain('getEntityPermission(entity, "delete")');
|
||||
expect(source).not.toContain('getEntityPermission(entity, "create"), tenantId), "verifyUserHasPermission"');
|
||||
});
|
||||
|
||||
it("requires row comment permission before comment mutations", () => {
|
||||
expect(source).toContain('action === "comment"');
|
||||
expect(source).toContain("!rowData.rowPermissions.canComment");
|
||||
expect(source).toContain('action === "comment-reaction"');
|
||||
});
|
||||
|
||||
it("requires row update permission before task mutations", () => {
|
||||
expect(source).toContain('action === "task-new"');
|
||||
expect(source).toContain('action === "task-complete-toggle"');
|
||||
expect(source).toContain('action === "task-delete"');
|
||||
expect(source.match(/!rowData\.rowPermissions\.canUpdate/g)?.length ?? 0).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("scopes comment and task lookups to the current row before mutation", () => {
|
||||
expect(source).toContain("getRowCommentInRow({ id: rowCommentId, rowId: item.id })");
|
||||
expect(source).toContain("updateRowCommentInRow({ id: comment.id, rowId: item.id }");
|
||||
expect(source).toContain("getRowTaskInRow({ id: taskId, rowId: item.id })");
|
||||
expect(source).toMatch(/updateRowTaskInRow\(\s*\{\s*id: taskId,\s*rowId: item\.id\s*\}/);
|
||||
expect(source).toContain("deleteRowTaskInRow({ id: taskId, rowId: item.id })");
|
||||
expect(source).not.toContain("getRowComment(rowCommentId)");
|
||||
expect(source).not.toContain("getRowTask(taskId)");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import type { TFunction } from "i18next";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowCommentsApi } from "~/utils/api/.server/RowCommentsApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { setRowCommentReaction } from "~/utils/db/entities/rowCommentReaction.db.server";
|
||||
import { getRowCommentInRow, updateRowCommentInRow } from "~/utils/db/entities/rowComments.db.server";
|
||||
import { createRowTask, deleteRowTaskInRow, getRowTaskInRow, updateRowTaskInRow } from "~/utils/db/entities/rowTasks.db.server";
|
||||
import { getUser } from "~/utils/db/users.db.server";
|
||||
import EntityHelper from "~/utils/helpers/EntityHelper";
|
||||
import { getEntityPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import { verifyUserHasPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
import FormulaService from "~/modules/formulas/services/.server/FormulaService";
|
||||
import EventsService from "~/modules/events/services/.server/EventsService";
|
||||
import { RowTasksCreatedDto } from "~/modules/events/dtos/RowTasksCreatedDto";
|
||||
import { RowTasksUpdatedDto } from "~/modules/events/dtos/RowTasksUpdatedDto";
|
||||
import { RowTasksDeletedDto } from "~/modules/events/dtos/RowTasksDeletedDto";
|
||||
import { RowCommentsCreatedDto } from "~/modules/events/dtos/RowCommentsCreatedDto";
|
||||
import { RowCommentsReactedDto } from "~/modules/events/dtos/RowCommentsReactedDto";
|
||||
import { RowCommentsDeletedDto } from "~/modules/events/dtos/RowCommentsDeletedDto";
|
||||
import { safeRedirectPathFromRequest } from "~/utils/app/RedirectSafety.server";
|
||||
import i18next from "i18next";
|
||||
import { translateEntityTitlePlural } from "~/utils/i18n/translateLabel";
|
||||
import { getRowsRouteErrorCode, getRowsRouteErrorStatus, translateRowsRouteError } from "./Rows_Error.server";
|
||||
|
||||
export namespace Rows_Overview {
|
||||
export type LoaderData = {
|
||||
meta: MetaTagsDto;
|
||||
rowData: RowsApi.GetRowData;
|
||||
routes: EntitiesApi.Routes;
|
||||
relationshipRows: RowsApi.GetRelationshipRowsData;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Overview] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
await time(verifyUserHasPermission(request, getEntityPermission(entity, "read"), tenantId), "verifyUserHasPermission");
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
const rowData = await time(
|
||||
RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
}),
|
||||
"RowsApi.get"
|
||||
);
|
||||
await time(
|
||||
FormulaService.trigger({ trigger: "BEFORE_VIEWED", rows: [rowData.item], entity: rowData.entity, session: { tenantId, userId }, t }),
|
||||
"FormulaService.trigger.BEFORE_VIEWED"
|
||||
);
|
||||
const entityPluralLabel = translateEntityTitlePlural(t, i18next, entity);
|
||||
const data: LoaderData = {
|
||||
meta: [{ title: `${RowHelper.getTextDescription({ entity, item: rowData.item, t })} | ${entityPluralLabel} | ${process.env.APP_NAME}` }],
|
||||
rowData,
|
||||
routes: EntitiesApi.getNoCodeRoutes({ request, params }),
|
||||
relationshipRows: await time(RowsApi.getRelationshipRows({ entity, tenantId, userId }), "RowsApi.getRelationshipRows"),
|
||||
};
|
||||
return Response.json(data, { headers: getServerTimingHeader() });
|
||||
};
|
||||
|
||||
export type ActionData = {
|
||||
updatedRow?: RowsApi.GetRowData;
|
||||
success?: string;
|
||||
error?: string;
|
||||
errorCode?: import("~/utils/api/.server/RowsApi").RowApiErrorCode;
|
||||
};
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Overview] ${params.entity}`);
|
||||
const { t, userId, tenantId, entity, form } = await RowsRequestUtils.getAction({ request, params });
|
||||
const action = form.get("action")?.toString() ?? "";
|
||||
const user = await time(getUser(userId), "getUser");
|
||||
const rowData = await time(
|
||||
RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
}),
|
||||
"RowsApi.get"
|
||||
);
|
||||
const { item } = rowData;
|
||||
|
||||
if (action === "edit") {
|
||||
try {
|
||||
const rowValues = RowHelper.getRowPropertiesFromForm({ t, entity, form, existing: item });
|
||||
await time(
|
||||
RowsApi.update(params.id!, {
|
||||
entity,
|
||||
request,
|
||||
tenantId,
|
||||
userId,
|
||||
rowValues,
|
||||
}),
|
||||
"RowsApi.update"
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return rowActionError(t, error, getServerTimingHeader());
|
||||
}
|
||||
// const redirectTo = form.get("redirect")?.toString() || new URL(request.url).searchParams.get("redirect")?.toString();
|
||||
// if (redirectTo) {
|
||||
// return redirect(redirectTo, { headers: getServerTimingHeader() });
|
||||
// }
|
||||
const updatedRow = await time(RowsApi.get(params.id!, { entity, tenantId, userId }), "RowsApi.get");
|
||||
return Response.json({ updatedRow, success: t("shared.saved") }, { headers: getServerTimingHeader() });
|
||||
} else if (action === "delete") {
|
||||
try {
|
||||
await time(verifyUserHasPermission(request, getEntityPermission(entity, "delete"), tenantId), "verifyUserHasPermission");
|
||||
await time(
|
||||
RowsApi.del(params.id!, {
|
||||
entity,
|
||||
request,
|
||||
tenantId,
|
||||
userId,
|
||||
}),
|
||||
"RowsApi.del"
|
||||
);
|
||||
if (item.createdByUser) {
|
||||
await time(
|
||||
NotificationService.send({
|
||||
channel: "my-rows",
|
||||
to: item.createdByUser,
|
||||
tenantId,
|
||||
notification: {
|
||||
from: { user },
|
||||
message: t("notifications.messages.row.deleted", {
|
||||
email: user?.email ?? t("shared.unknown"),
|
||||
row: RowHelper.getTextDescription({ entity, item, t }),
|
||||
}),
|
||||
action: {
|
||||
title: t("shared.view"),
|
||||
url: EntityHelper.getRoutes({ routes: EntitiesApi.getNoCodeRoutes({ request, params }), entity, item })?.overview ?? "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"NotificationService.send"
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
return rowActionError(t, error, getServerTimingHeader());
|
||||
}
|
||||
const redirectTo = form.get("redirect")?.toString() || new URL(request.url).searchParams.get("redirect")?.toString();
|
||||
if (redirectTo) {
|
||||
return redirect(safeRedirectPathFromRequest(request, redirectTo), { headers: getServerTimingHeader() });
|
||||
}
|
||||
const routes = EntitiesApi.getNoCodeRoutes({ request, params });
|
||||
const listRoute = EntityHelper.getRoutes({ routes, entity })?.list;
|
||||
if (listRoute) {
|
||||
return redirect(listRoute, { headers: getServerTimingHeader() });
|
||||
}
|
||||
return Response.json({ deleted: true }, { headers: getServerTimingHeader() });
|
||||
} else if (action === "comment") {
|
||||
if (!rowData.rowPermissions.canComment) {
|
||||
return rowActionForbidden(t, getServerTimingHeader());
|
||||
}
|
||||
let comment = form.get("comment")?.toString();
|
||||
if (!comment) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
await time(
|
||||
RowCommentsApi.create(item.id, {
|
||||
comment,
|
||||
tenantId,
|
||||
userId,
|
||||
}),
|
||||
"RowCommentsApi.create"
|
||||
);
|
||||
await EventsService.create({
|
||||
request,
|
||||
event: "row.comments.created",
|
||||
tenantId,
|
||||
userId,
|
||||
data: {
|
||||
rowId: item.id,
|
||||
comment: { id: item.id, text: comment },
|
||||
user: !user ? null : { id: user.id, email: user.email },
|
||||
} satisfies RowCommentsCreatedDto,
|
||||
});
|
||||
if (item.createdByUser) {
|
||||
await time(
|
||||
NotificationService.send({
|
||||
channel: "my-rows",
|
||||
to: item.createdByUser,
|
||||
tenantId,
|
||||
notification: {
|
||||
from: { user },
|
||||
message: t("notifications.messages.row.commented", {
|
||||
email: user?.email ?? t("shared.unknown"),
|
||||
row: RowHelper.getTextDescription({ entity, item, t }),
|
||||
}),
|
||||
action: {
|
||||
title: t("shared.view"),
|
||||
url: EntityHelper.getRoutes({ routes: EntitiesApi.getNoCodeRoutes({ request, params }), entity, item })?.overview ?? "",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"NotificationService.send"
|
||||
);
|
||||
}
|
||||
return Response.json({ newComment: comment }, { headers: getServerTimingHeader() });
|
||||
} else if (action === "comment-reaction") {
|
||||
if (!rowData.rowPermissions.canComment) {
|
||||
return rowActionForbidden(t, getServerTimingHeader());
|
||||
}
|
||||
const rowCommentId = form.get("comment-id")?.toString();
|
||||
const reaction = form.get("reaction")?.toString();
|
||||
if (!rowCommentId || !reaction) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
const comment = await time(getRowCommentInRow({ id: rowCommentId, rowId: item.id }), "getRowCommentInRow");
|
||||
if (!comment) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
if (comment) {
|
||||
await time(
|
||||
setRowCommentReaction({
|
||||
createdByUserId: userId,
|
||||
rowCommentId,
|
||||
reaction,
|
||||
}),
|
||||
"setRowCommentReaction"
|
||||
);
|
||||
await EventsService.create({
|
||||
request,
|
||||
event: "row.comments.reacted",
|
||||
tenantId,
|
||||
userId,
|
||||
data: {
|
||||
rowId: item.id,
|
||||
comment: { id: comment.id, text: comment.value },
|
||||
reaction,
|
||||
user: !user ? null : { id: user.id, email: user.email },
|
||||
} satisfies RowCommentsReactedDto,
|
||||
});
|
||||
}
|
||||
return Response.json({ newCommentReaction: reaction }, { headers: getServerTimingHeader() });
|
||||
} else if (action === "comment-delete") {
|
||||
const rowCommentId = form.get("comment-id")?.toString() ?? "";
|
||||
const comment = await getRowCommentInRow({ id: rowCommentId, rowId: item.id });
|
||||
if (!comment) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
if (!rowData.rowPermissions.canUpdate && comment.createdByUserId !== userId) {
|
||||
return rowActionForbidden(t, getServerTimingHeader());
|
||||
}
|
||||
await time(updateRowCommentInRow({ id: comment.id, rowId: item.id }, { isDeleted: true }), "updateRowCommentInRow");
|
||||
await EventsService.create({
|
||||
request,
|
||||
event: "row.comments.deleted",
|
||||
tenantId,
|
||||
userId,
|
||||
data: {
|
||||
rowId: item.id,
|
||||
comment: { id: comment.id, text: comment.value },
|
||||
user: !user ? null : { id: user.id, email: user.email },
|
||||
} satisfies RowCommentsDeletedDto,
|
||||
});
|
||||
return Response.json({ deletedComment: rowCommentId }, { headers: getServerTimingHeader() });
|
||||
} else if (action === "task-new") {
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
return rowActionForbidden(t, getServerTimingHeader());
|
||||
}
|
||||
const taskTitle = form.get("task-title")?.toString();
|
||||
if (!taskTitle) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
const task = await time(
|
||||
createRowTask({
|
||||
createdByUserId: userId,
|
||||
rowId: item.id,
|
||||
title: taskTitle,
|
||||
}),
|
||||
"createRowTask"
|
||||
);
|
||||
await EventsService.create({
|
||||
request,
|
||||
event: "row.tasks.created",
|
||||
tenantId,
|
||||
userId,
|
||||
data: {
|
||||
rowId: item.id,
|
||||
task: { id: task.id, name: task.title },
|
||||
user: !user ? null : { id: user.id, email: user.email },
|
||||
} satisfies RowTasksCreatedDto,
|
||||
});
|
||||
return Response.json({ newTask: task }, { headers: getServerTimingHeader() });
|
||||
} else if (action === "task-complete-toggle") {
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
return rowActionForbidden(t, getServerTimingHeader());
|
||||
}
|
||||
const taskId = form.get("task-id")?.toString() ?? "";
|
||||
const task = await time(getRowTaskInRow({ id: taskId, rowId: item.id }), "getRowTaskInRow");
|
||||
if (!task) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
if (task) {
|
||||
if (task.completed) {
|
||||
await time(
|
||||
updateRowTaskInRow(
|
||||
{ id: taskId, rowId: item.id },
|
||||
{
|
||||
completed: false,
|
||||
completedAt: null,
|
||||
completedByUserId: null,
|
||||
}
|
||||
),
|
||||
"updateRowTask"
|
||||
);
|
||||
} else {
|
||||
await time(
|
||||
updateRowTaskInRow(
|
||||
{ id: taskId, rowId: item.id },
|
||||
{
|
||||
completed: true,
|
||||
completedAt: new Date(),
|
||||
completedByUserId: userId,
|
||||
}
|
||||
),
|
||||
"updateRowTask"
|
||||
);
|
||||
}
|
||||
await EventsService.create({
|
||||
request,
|
||||
event: "row.tasks.created",
|
||||
tenantId,
|
||||
userId,
|
||||
data: {
|
||||
rowId: item.id,
|
||||
old: { id: task.id, name: task.title, completedAt: task.completedAt },
|
||||
new: { id: task.id, name: task.title, completedAt: task.completed ? new Date() : null },
|
||||
user: !user ? null : { id: user.id, email: user.email },
|
||||
} satisfies RowTasksUpdatedDto,
|
||||
});
|
||||
}
|
||||
return Response.json({ completedTask: taskId }, { headers: getServerTimingHeader() });
|
||||
} else if (action === "task-delete") {
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
return rowActionForbidden(t, getServerTimingHeader());
|
||||
}
|
||||
const taskId = form.get("task-id")?.toString() ?? "";
|
||||
const task = await time(getRowTaskInRow({ id: taskId, rowId: item.id }), "getRowTaskInRow");
|
||||
if (!task) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
if (task) {
|
||||
await time(deleteRowTaskInRow({ id: taskId, rowId: item.id }), "deleteRowTaskInRow");
|
||||
await EventsService.create({
|
||||
request,
|
||||
event: "row.tasks.deleted",
|
||||
tenantId,
|
||||
userId,
|
||||
data: {
|
||||
rowId: item.id,
|
||||
task: { id: task.id, name: task.title },
|
||||
user: !user ? null : { id: user.id, email: user.email },
|
||||
} satisfies RowTasksDeletedDto,
|
||||
});
|
||||
}
|
||||
return Response.json({ deletedTask: taskId }, { headers: getServerTimingHeader() });
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400, headers: getServerTimingHeader() });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function rowActionForbidden(t: TFunction, headers: HeadersInit) {
|
||||
return Response.json({ error: t("foundation.errors.forbidden") }, { status: 403, headers });
|
||||
}
|
||||
|
||||
function rowActionError(t: TFunction, error: unknown, headers: HeadersInit) {
|
||||
return Response.json(
|
||||
{ error: translateRowsRouteError(t, error), errorCode: getRowsRouteErrorCode(error) } satisfies Rows_Overview.ActionData,
|
||||
{ status: getRowsRouteErrorStatus(error), headers }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { LoaderFunctionArgs, redirect } from "react-router";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { verifyUserHasEntityPermission } from "~/utils/helpers/.server/PermissionsService";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import { createMetrics } from "~/modules/metrics/services/.server/MetricTracker";
|
||||
|
||||
export namespace Rows_Relationships {
|
||||
export type LoaderData = {
|
||||
entitiesData: { rowsData: RowsApi.GetRowsData }[];
|
||||
routes: EntitiesApi.Routes;
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { time, getServerTimingHeader } = await createMetrics({ request, params }, `[Rows_Relationships] ${params.entity}`);
|
||||
const { userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
await time(verifyUserHasEntityPermission(request, entity, "view", tenantId), "verifyUserHasPermission");
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
|
||||
const entitiesData: {
|
||||
rowsData: RowsApi.GetRowsData;
|
||||
}[] = [];
|
||||
|
||||
async function getRowsData(entity: { id?: string; name?: string }) {
|
||||
return await RowsApi.getAll({
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
urlSearchParams: new URL(request.url).searchParams,
|
||||
});
|
||||
}
|
||||
await time(
|
||||
Promise.all(
|
||||
entity.parentEntities.map(async (relationship) => {
|
||||
entitiesData.push({
|
||||
rowsData: await getRowsData({ name: relationship.parent.name }),
|
||||
});
|
||||
})
|
||||
),
|
||||
"parentEntities.getRowsData"
|
||||
);
|
||||
entitiesData.push({
|
||||
rowsData: await time(getRowsData({ name: entity.name }), "getRowsData"),
|
||||
});
|
||||
await time(
|
||||
Promise.all(
|
||||
entity.childEntities.map(async (relationship) => {
|
||||
entitiesData.push({
|
||||
rowsData: await getRowsData({ name: relationship.child.name }),
|
||||
});
|
||||
})
|
||||
),
|
||||
"childEntities.getRowsData"
|
||||
);
|
||||
|
||||
const data: LoaderData = {
|
||||
entitiesData: entitiesData.sort((a, b) => (a.rowsData.entity.order > b.rowsData.entity.order ? 1 : -1)),
|
||||
routes: EntitiesApi.getNoCodeRoutes({ request, params }),
|
||||
};
|
||||
return Response.json(data, { headers: getServerTimingHeader() });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
describe("row share public route boundary", () => {
|
||||
it("does not accept public share targets after public row routes were removed", () => {
|
||||
const routeSource = readFileSync(join(repoRoot, "apps/web/app/modules/rows/routes/Rows_Share.server.ts"), "utf8");
|
||||
const generatedShareSource = readFileSync(join(repoRoot, "apps/web/app/modules/codeGenerator/templates/routes/api/CodeTemplate.Routes.Share.Api.ts"), "utf8");
|
||||
|
||||
expect(routeSource).toContain('const allowedShareTypes = ["tenant", "user", "role", "group"] as const');
|
||||
expect(routeSource).not.toContain('"tenant" | "user" | "role" | "group" | "public"');
|
||||
expect(generatedShareSource).toContain('const allowedShareTypes = ["tenant", "user", "role", "group"] as const');
|
||||
expect(generatedShareSource).not.toContain('"tenant" | "user" | "role" | "group" | "public"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Tenant } from "@toolctrl/db/prisma-browser";
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import type { TFunction } from "i18next";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { RowAccess } from "~/application/enums/entities/RowAccess";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowPermissionsApi } from "~/utils/api/.server/RowPermissionsApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { adminGetAllTenants, getTenant } from "~/utils/db/tenants.db.server";
|
||||
import { UserWithDetails, getUser, getUsersByTenant } from "~/utils/db/users.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import EventsService from "~/modules/events/services/.server/EventsService";
|
||||
import { RowSharedDto } from "~/modules/events/dtos/RowSharedDto";
|
||||
import { getRowsRouteErrorCode, getRowsRouteErrorStatus, translateRowsRouteError } from "./Rows_Error.server";
|
||||
|
||||
const allowedShareTypes = ["tenant", "user", "role", "group"] as const;
|
||||
type ShareType = (typeof allowedShareTypes)[number];
|
||||
|
||||
function getShareType(value: FormDataEntryValue | null): ShareType | null {
|
||||
const type = value?.toString();
|
||||
return allowedShareTypes.includes(type as ShareType) ? (type as ShareType) : null;
|
||||
}
|
||||
|
||||
export namespace Rows_Share {
|
||||
export type ActionData = {
|
||||
success?: string;
|
||||
error?: string;
|
||||
errorCode?: import("~/utils/api/.server/RowsApi").RowApiErrorCode;
|
||||
};
|
||||
|
||||
export type LoaderData = {
|
||||
meta: MetaTagsDto;
|
||||
rowData: RowsApi.GetRowData;
|
||||
routes: EntitiesApi.Routes;
|
||||
tenants: Tenant[];
|
||||
users: UserWithDetails[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
const rowData = await RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
throw Error(t("shared.unauthorized"));
|
||||
}
|
||||
let tenants: Tenant[] = [];
|
||||
if (tenantId) {
|
||||
tenants.push((await getTenant(tenantId))!);
|
||||
} else {
|
||||
tenants = await adminGetAllTenants();
|
||||
}
|
||||
const data: LoaderData = {
|
||||
meta: [{ title: `${t("shared.share")} | ${RowHelper.getTextDescription({ entity, item: rowData.item, t })} | ${process.env.APP_NAME}` }],
|
||||
rowData,
|
||||
routes: EntitiesApi.getNoCodeRoutes({ request, params }),
|
||||
tenants,
|
||||
users: (await getUsersByTenant(tenantId)).filter((f) => f.id !== userId),
|
||||
};
|
||||
return Response.json(data);
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t, entity, tenantId, userId, form } = await RowsRequestUtils.getAction({ request, params });
|
||||
const user = await getUser(userId);
|
||||
if (!user) {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
|
||||
const action = form.get("action");
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
const rowData = await RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
throw Error(t("shared.unauthorized"));
|
||||
}
|
||||
|
||||
if (action === "share") {
|
||||
const type = getShareType(form.get("type"));
|
||||
if (!type) {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const access = form.get("access")?.toString() as RowAccess;
|
||||
try {
|
||||
await RowPermissionsApi.share(rowData.item, {
|
||||
type,
|
||||
id,
|
||||
access,
|
||||
}, { currentTenantId: tenantId });
|
||||
await EventsService.create({
|
||||
request,
|
||||
event: "row.shared",
|
||||
tenantId,
|
||||
userId,
|
||||
data: {
|
||||
id: rowData.item.id,
|
||||
title: RowHelper.getTextDescription({ entity, item: rowData.item, t }),
|
||||
entity: { id: entity.id, name: entity.name, slug: entity.slug, title: entity.title },
|
||||
type,
|
||||
to: id,
|
||||
access,
|
||||
user: { id: user.id, email: user.email },
|
||||
} satisfies RowSharedDto,
|
||||
});
|
||||
return Response.json({ success: t("shared.saved") });
|
||||
} catch (error: unknown) {
|
||||
return rowActionError(t, error);
|
||||
}
|
||||
} else if (action === "set-access") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
const access = form.get("access")?.toString() as RowAccess;
|
||||
await RowPermissionsApi.setAccessForRow(rowData.item.id, id, access);
|
||||
return Response.json({ success: t("shared.saved") });
|
||||
} else if (action === "remove") {
|
||||
const id = form.get("id")?.toString() ?? "";
|
||||
await RowPermissionsApi.delForRow(rowData.item.id, id);
|
||||
return Response.json({ success: t("shared.deleted") });
|
||||
} else {
|
||||
return Response.json({ error: t("shared.invalidForm") }, { status: 400 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function rowActionError(t: TFunction, error: unknown) {
|
||||
return Response.json(
|
||||
{ error: translateRowsRouteError(t, error), errorCode: getRowsRouteErrorCode(error) } satisfies Rows_Share.ActionData,
|
||||
{ status: getRowsRouteErrorStatus(error) }
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { EntityTag } from "@toolctrl/db/prisma-browser";
|
||||
import { LoaderFunctionArgs, redirect, ActionFunction } from "react-router";
|
||||
import { MetaTagsDto } from "~/application/dtos/seo/MetaTagsDto";
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
import { EntitiesApi } from "~/utils/api/.server/EntitiesApi";
|
||||
import { RowsApi } from "~/utils/api/.server/RowsApi";
|
||||
import UrlUtils from "~/utils/app/UrlUtils";
|
||||
import { getEntityTags, getEntityTag, createEntityTag, updateEntityTag, deleteEntityTag, getEntityTagById } from "~/utils/db/entities/entityTags.db.server";
|
||||
import { getRowTag, createRowTag, deleteRowTags } from "~/utils/db/entities/rowTags.db.server";
|
||||
import RowHelper from "~/utils/helpers/RowHelper";
|
||||
import RowsRequestUtils from "../utils/RowsRequestUtils";
|
||||
import { CustomError } from "~/application/dtos/shared/CustomError";
|
||||
|
||||
export namespace Rows_Tags {
|
||||
export type LoaderData = {
|
||||
meta: MetaTagsDto;
|
||||
rowData: RowsApi.GetRowData;
|
||||
routes: EntitiesApi.Routes;
|
||||
tags: EntityTag[];
|
||||
};
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { t, userId, tenantId, entity } = await RowsRequestUtils.getLoader({ request, params });
|
||||
if (!entity.isAutogenerated || entity.type === "system") {
|
||||
throw redirect(tenantId ? UrlUtils.currentTenantUrl(params, "404") : "/404");
|
||||
}
|
||||
const rowData = await RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
throw Response.json(new CustomError(t("models.row.permissionMessages.cannotUpdateRecord"), { permissions: rowData.rowPermissions }), { status: 403 });
|
||||
}
|
||||
const data: LoaderData = {
|
||||
meta: [{ title: `${t("shared.tag")} | ${RowHelper.getTextDescription({ entity, item: rowData.item, t })} | ${process.env.APP_NAME}` }],
|
||||
rowData,
|
||||
tags: await getEntityTags(entity.id),
|
||||
routes: EntitiesApi.getNoCodeRoutes({ request, params }),
|
||||
};
|
||||
return Response.json(data);
|
||||
};
|
||||
|
||||
const badRequest = (data: { error: string }) => Response.json(data, { status: 400 });
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const { t, userId, tenantId, entity, form } = await RowsRequestUtils.getAction({ request, params });
|
||||
const rowData = await RowsApi.get(params.id!, {
|
||||
entity,
|
||||
tenantId,
|
||||
userId,
|
||||
});
|
||||
const action = form.get("action");
|
||||
const { item } = rowData;
|
||||
const canMutateTagCatalog = !tenantId;
|
||||
if (!rowData.rowPermissions.canUpdate) {
|
||||
throw Response.json(new CustomError(t("models.row.permissionMessages.cannotUpdateRecord"), { permissions: rowData.rowPermissions }), { status: 403 });
|
||||
}
|
||||
if (action === "new-tag") {
|
||||
const value = form.get("tag-name")?.toString() ?? "";
|
||||
const color = Number(form.get("tag-color") ?? Colors.INDIGO);
|
||||
let tag = await getEntityTag(entity.id, value);
|
||||
if (!tag) {
|
||||
if (!canMutateTagCatalog) {
|
||||
return badRequest({ error: t("shared.notFound") });
|
||||
}
|
||||
tag = await createEntityTag({
|
||||
entityId: entity.id,
|
||||
value,
|
||||
color,
|
||||
});
|
||||
}
|
||||
const existingTag = await getRowTag(item.id, value);
|
||||
if (tag && !existingTag) {
|
||||
await createRowTag({
|
||||
rowId: item.id,
|
||||
tagId: tag.id,
|
||||
});
|
||||
}
|
||||
return Response.json({});
|
||||
} else if (action === "edit-tag") {
|
||||
if (!canMutateTagCatalog) {
|
||||
return Response.json(new CustomError(t("shared.unauthorized")), { status: 403 });
|
||||
}
|
||||
const id = form.get("tag-id")?.toString() ?? "";
|
||||
const value = form.get("tag-name")?.toString() ?? "";
|
||||
const color = Number(form.get("tag-color"));
|
||||
const tag = await getEntityTagById(id, entity.id);
|
||||
if (!tag) {
|
||||
return badRequest({ error: t("shared.notFound") });
|
||||
}
|
||||
await updateEntityTag({ id: tag.id, entityId: entity.id }, {
|
||||
value,
|
||||
color,
|
||||
});
|
||||
return Response.json({});
|
||||
} else if (action === "set-tag") {
|
||||
const id = form.get("tag-id")?.toString() ?? "";
|
||||
const tagAction = form.get("tag-action")?.toString() ?? "";
|
||||
const tag = await getEntityTagById(id, entity.id);
|
||||
if (!tag) {
|
||||
return badRequest({ error: t("shared.notFound") });
|
||||
}
|
||||
if (tagAction === "add") {
|
||||
await createRowTag({
|
||||
rowId: item.id,
|
||||
tagId: tag.id,
|
||||
});
|
||||
} else {
|
||||
await deleteRowTags(item.id, tag.id);
|
||||
}
|
||||
return Response.json({});
|
||||
} else if (action === "delete-tag") {
|
||||
if (!canMutateTagCatalog) {
|
||||
return Response.json(new CustomError(t("shared.unauthorized")), { status: 403 });
|
||||
}
|
||||
const id = form.get("tag-id")?.toString() ?? "";
|
||||
const tag = await getEntityTagById(id, entity.id);
|
||||
if (tag) {
|
||||
await deleteEntityTag({ id: tag.id, entityId: entity.id });
|
||||
}
|
||||
return Response.json({});
|
||||
} else {
|
||||
return badRequest({ error: t("shared.invalidForm") });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const routeFiles = [
|
||||
"Rows_List.server.tsx",
|
||||
"Rows_New.server.ts",
|
||||
"Rows_Import.server.ts",
|
||||
"Rows_Export.server.ts",
|
||||
"Rows_Edit.server.ts",
|
||||
"Rows_Overview.server.ts",
|
||||
];
|
||||
|
||||
describe("rows route meta i18n", () => {
|
||||
it("uses entity translation helpers for persisted autogenerated labels", () => {
|
||||
for (const file of routeFiles) {
|
||||
const source = fs.readFileSync(path.resolve(process.cwd(), "apps/web/app/modules/rows/routes", file), "utf8");
|
||||
|
||||
expect(source).toContain("translateEntityTitle");
|
||||
expect(source).not.toContain("t(entity.titlePlural)");
|
||||
expect(source).not.toContain("t(entity.title)");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps RowListFetcher slide-over titles helper-based", () => {
|
||||
const source = fs.readFileSync(path.resolve(process.cwd(), "apps/web/app/modules/rows/fetchers/RowListFetcher.tsx"), "utf8");
|
||||
|
||||
expect(source).toContain("translateEntityTitle(t, i18n, data.rowsData.entity)");
|
||||
expect(source).not.toContain("t(data?.rowsData?.entity.title");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Generic Rows public search — hot-path migration to Meilisearch.
|
||||
*
|
||||
* Wraps `searchWithFallback({ index: 'rows', tenantId, query,
|
||||
* fallback })` so the rows-engine row-list / row-search code-paths can
|
||||
* adopt the typed client without further refactors. The `entityId`
|
||||
* filter scopes the search to one entity type (e.g. "company", "deal",
|
||||
* any no-code-entity) which mirrors how the legacy SQL path is keyed
|
||||
* to a single entity at a time.
|
||||
*
|
||||
* Fallback semantics: when MEILI_URL is unset, when SEARCH_BACKEND=sql
|
||||
* is forced, or when Meilisearch is unavailable, the helper falls back
|
||||
* to a SQL ILIKE-against-RowValue scan over the entity's rows.
|
||||
*/
|
||||
|
||||
import {
|
||||
createSearchClient,
|
||||
rowsIndex,
|
||||
searchWithFallback,
|
||||
type SearchClient,
|
||||
type SearchFallback,
|
||||
type SearchHits,
|
||||
} from "@toolctrl/search";
|
||||
import { db } from "~/utils/db.server";
|
||||
|
||||
let cachedClient: SearchClient | undefined;
|
||||
let cachedUrl: string | undefined;
|
||||
|
||||
function getSearchClient(): SearchClient | undefined {
|
||||
const url = process.env.MEILI_URL;
|
||||
if (!url || process.env.SEARCH_BACKEND === "sql") return undefined;
|
||||
if (cachedClient && cachedUrl === url) return cachedClient;
|
||||
cachedClient = createSearchClient({ url, apiKey: process.env.MEILI_API_KEY ?? undefined });
|
||||
cachedUrl = url;
|
||||
return cachedClient;
|
||||
}
|
||||
|
||||
export interface SearchRowsInput {
|
||||
/** Tenant scope — REQUIRED for both backends. */
|
||||
readonly tenantId: string;
|
||||
/** Entity row-bucket — REQUIRED. Maps to the `entityId` filter on
|
||||
* the Meilisearch side and to a WHERE-clause on the SQL side. */
|
||||
readonly entityId: string;
|
||||
/** User-supplied query string. Empty string returns the first page. */
|
||||
readonly query: string;
|
||||
/** Optional cap on result count. Defaults to 50. */
|
||||
readonly limit?: number;
|
||||
}
|
||||
|
||||
export interface RowSearchHit {
|
||||
readonly id: string;
|
||||
readonly folio: number;
|
||||
readonly entityName: string;
|
||||
}
|
||||
|
||||
export interface RowSearchResult {
|
||||
readonly hits: readonly RowSearchHit[];
|
||||
readonly total: number;
|
||||
readonly fellBack: boolean;
|
||||
}
|
||||
|
||||
async function sqlFallback(input: SearchRowsInput): Promise<SearchHits> {
|
||||
const limit = input.limit ?? 50;
|
||||
const q = input.query.trim();
|
||||
const where: Record<string, unknown> = {
|
||||
tenantId: input.tenantId,
|
||||
entityId: input.entityId,
|
||||
deletedAt: null,
|
||||
};
|
||||
if (q.length > 0) {
|
||||
where.values = {
|
||||
some: {
|
||||
textValue: { contains: q, mode: "insensitive" },
|
||||
},
|
||||
};
|
||||
}
|
||||
const rows = await db.row.findMany({
|
||||
where: where as never,
|
||||
take: limit,
|
||||
orderBy: [{ order: "desc" }, { createdAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
folio: true,
|
||||
entity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
return {
|
||||
hits: rows.map((row) => ({
|
||||
document: {
|
||||
id: row.id,
|
||||
tenantId: input.tenantId,
|
||||
folio: typeof row.folio === "number" ? row.folio : 0,
|
||||
entityId: input.entityId,
|
||||
entityName: row.entity?.name ?? "",
|
||||
} as Record<string, unknown> & { id: string; tenantId: string },
|
||||
})),
|
||||
estimatedTotalHits: rows.length,
|
||||
processingTimeMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchRows(input: SearchRowsInput): Promise<RowSearchResult> {
|
||||
const fallback: SearchFallback = async () => sqlFallback(input);
|
||||
|
||||
const client = getSearchClient();
|
||||
if (!client) {
|
||||
const sql = await sqlFallback(input);
|
||||
return {
|
||||
hits: sql.hits.map((hit) => toRowHit(hit.document)),
|
||||
total: sql.estimatedTotalHits ?? sql.hits.length,
|
||||
fellBack: true,
|
||||
};
|
||||
}
|
||||
|
||||
let fellBack = false;
|
||||
const hits = await searchWithFallback({
|
||||
client,
|
||||
schema: rowsIndex,
|
||||
tenantId: input.tenantId,
|
||||
query: input.query,
|
||||
fallback,
|
||||
onFallback: () => {
|
||||
fellBack = true;
|
||||
},
|
||||
options: {
|
||||
limit: input.limit ?? 50,
|
||||
extraFilters: `entityId = "${escapeFilterValue(input.entityId)}"`,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hits: hits.hits.map((hit) => toRowHit(hit.document)),
|
||||
total: hits.estimatedTotalHits ?? hits.hits.length,
|
||||
fellBack,
|
||||
};
|
||||
}
|
||||
|
||||
function toRowHit(document: Record<string, unknown>): RowSearchHit {
|
||||
return {
|
||||
id: String(document.id ?? ""),
|
||||
folio: typeof document.folio === "number" ? document.folio : 0,
|
||||
entityName: typeof document.entityName === "string" ? document.entityName : "",
|
||||
};
|
||||
}
|
||||
|
||||
function escapeFilterValue(value: string): string {
|
||||
return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Unit tests for searchRows — covers the SQL fallback contract: tenant
|
||||
* + entity scoping, value-text predicate, limit, and total accounting.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("~/utils/db.server", () => ({
|
||||
db: {
|
||||
row: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { db } from "~/utils/db.server";
|
||||
import { searchRows } from "./rowSearch.server";
|
||||
|
||||
const dbMock = db as unknown as {
|
||||
row: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const originalMeili = process.env.MEILI_URL;
|
||||
const originalBackend = process.env.SEARCH_BACKEND;
|
||||
|
||||
afterAll(() => {
|
||||
if (originalMeili === undefined) delete process.env.MEILI_URL;
|
||||
else process.env.MEILI_URL = originalMeili;
|
||||
if (originalBackend === undefined) delete process.env.SEARCH_BACKEND;
|
||||
else process.env.SEARCH_BACKEND = originalBackend;
|
||||
});
|
||||
|
||||
describe("searchRows (sql fallback)", () => {
|
||||
beforeEach(() => {
|
||||
delete process.env.MEILI_URL;
|
||||
delete process.env.SEARCH_BACKEND;
|
||||
dbMock.row.findMany.mockReset();
|
||||
});
|
||||
|
||||
it("scopes by tenantId + entityId and maps results", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([
|
||||
{ id: "row-1", folio: 12, entity: { name: "deal" } },
|
||||
{ id: "row-2", folio: 7, entity: { name: "deal" } },
|
||||
]);
|
||||
|
||||
const result = await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "acme" });
|
||||
|
||||
expect(dbMock.row.findMany).toHaveBeenCalledTimes(1);
|
||||
const where = dbMock.row.findMany.mock.calls[0][0].where as Record<string, unknown>;
|
||||
expect(where.tenantId).toBe("tenant-1");
|
||||
expect(where.entityId).toBe("entity-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
expect(where.values).toBeDefined();
|
||||
|
||||
expect(result.fellBack).toBe(true);
|
||||
expect(result.hits).toEqual([
|
||||
{ id: "row-1", folio: 12, entityName: "deal" },
|
||||
{ id: "row-2", folio: 7, entityName: "deal" },
|
||||
]);
|
||||
expect(result.total).toBe(2);
|
||||
});
|
||||
|
||||
it("omits value-text predicate when the query is empty", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([]);
|
||||
await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "" });
|
||||
const where = dbMock.row.findMany.mock.calls[0][0].where as Record<string, unknown>;
|
||||
expect(where.values).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honours the limit option", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([]);
|
||||
await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "x", limit: 25 });
|
||||
expect(dbMock.row.findMany.mock.calls[0][0].take).toBe(25);
|
||||
});
|
||||
|
||||
it("defaults folio to 0 when missing on the row", async () => {
|
||||
dbMock.row.findMany.mockResolvedValueOnce([{ id: "row-x", folio: null, entity: null }]);
|
||||
const result = await searchRows({ tenantId: "tenant-1", entityId: "entity-1", query: "x" });
|
||||
expect(result.hits[0]).toEqual({ id: "row-x", folio: 0, entityName: "" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Outbox-driven Meilisearch indexing for the rows-engine.
|
||||
*
|
||||
* `RowsApi.create / update / del` invokes one of `emitRowSearchUpsert` /
|
||||
* `emitRowSearchDelete` AFTER the canonical DB write so the
|
||||
* `search.index.*.upsert | .delete` outbox event is published as a
|
||||
* post-write projection update. The outbox event-router (#48) maps
|
||||
* `search.index.*` to the `outbox.search-index` BullMQ queue where the
|
||||
* search-indexer worker (apps/worker/src/jobs/search-indexer.ts) picks
|
||||
* the event up and upserts/deletes the Meilisearch document.
|
||||
*
|
||||
* CRM contacts are stored as rows with `entityName = "contact"` —
|
||||
* routing here therefore covers both the CRM-contacts and the generic
|
||||
* Rows hot-paths in a single producer location.
|
||||
*
|
||||
* Resilience: outbox publish failures are caught and logged rather
|
||||
* than re-thrown. A row CUD must NOT roll back because the downstream
|
||||
* indexing event could not be queued — the failure is observable in
|
||||
* logs and can be corrected with a targeted reindex. Moving these emits
|
||||
* into the surrounding row mutation transactions is a separate hardening
|
||||
* slice.
|
||||
*/
|
||||
|
||||
import {
|
||||
crmContactsIndex,
|
||||
publishSearchDeleteEvent,
|
||||
publishSearchUpsertEvent,
|
||||
rowsIndex,
|
||||
type SearchIndexSchema,
|
||||
} from "@toolctrl/search";
|
||||
import type { Prisma, Row } from "@toolctrl/db/prisma-browser";
|
||||
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@toolctrl/db/outbox";
|
||||
import { db } from "~/utils/db.server";
|
||||
import RowValueHelper from "~/utils/helpers/RowValueHelper";
|
||||
import { PropertyType } from "~/application/enums/entities/PropertyType";
|
||||
import type { EntityWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
import type { RowValueWithDetails } from "~/utils/db/entities/rows.db.server";
|
||||
|
||||
/**
|
||||
* Minimum shape the search-outbox emitters need from a row. Both
|
||||
* `RowWithDetails` (apps/web) and `LegacyRowWithDetails` (@toolctrl/db)
|
||||
* conform to it — we widen here so the helper can be called from either
|
||||
* code-path without re-shaping the row.
|
||||
*/
|
||||
export type RowForSearch = Row & {
|
||||
values: RowValueWithDetails[];
|
||||
};
|
||||
|
||||
export interface RowSearchLogger {
|
||||
warn?: (record: Record<string, unknown>, message?: string) => void;
|
||||
}
|
||||
|
||||
export interface EmitRowSearchUpsertInput {
|
||||
tenantId: string | null | undefined;
|
||||
entity: EntityWithDetails;
|
||||
row: RowForSearch;
|
||||
logger?: RowSearchLogger;
|
||||
}
|
||||
|
||||
export interface EmitRowSearchDeleteInput {
|
||||
tenantId: string | null | undefined;
|
||||
entity: { name: string };
|
||||
rowId: string;
|
||||
logger?: RowSearchLogger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the search-index schemas that should receive a search event
|
||||
* for the given entity. `contact` rows publish to BOTH `crm-contacts`
|
||||
* (typed schema with structured fields) and `rows` (generic searchable
|
||||
* blob) so either route can serve the contact list. All other entities
|
||||
* publish only to `rows`.
|
||||
*/
|
||||
function indexSchemasForEntity(entityName: string): readonly SearchIndexSchema[] {
|
||||
if (entityName === "contact") return [crmContactsIndex, rowsIndex];
|
||||
return [rowsIndex];
|
||||
}
|
||||
|
||||
function buildSearchableBlob(entity: EntityWithDetails, row: RowForSearch): string {
|
||||
const parts: string[] = [];
|
||||
for (const property of entity.properties) {
|
||||
if (property.type !== PropertyType.TEXT && property.type !== PropertyType.SELECT) continue;
|
||||
try {
|
||||
const text = RowValueHelper.getText({ entity, row, name: property.name });
|
||||
if (typeof text === "string" && text.trim().length > 0) parts.push(text);
|
||||
} catch {
|
||||
// ignore — defensive, the row may not carry every property value
|
||||
}
|
||||
}
|
||||
return parts.join(" \n ");
|
||||
}
|
||||
|
||||
function rowToContactDocument(
|
||||
entity: EntityWithDetails,
|
||||
row: RowForSearch,
|
||||
tenantId: string
|
||||
): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
const get = (name: string): string => {
|
||||
try {
|
||||
return RowValueHelper.getText({ entity, row, name }) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId,
|
||||
firstName: get("firstName"),
|
||||
lastName: get("lastName"),
|
||||
email: get("email"),
|
||||
company: get("company"),
|
||||
jobTitle: get("jobTitle"),
|
||||
status: get("status"),
|
||||
ownerId: row.createdByUserId ?? "",
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function rowToRowsDocument(
|
||||
entity: EntityWithDetails,
|
||||
row: RowForSearch,
|
||||
tenantId: string
|
||||
): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
return {
|
||||
id: row.id,
|
||||
tenantId,
|
||||
entityId: row.entityId,
|
||||
entityName: entity.name,
|
||||
folio: typeof row.folio === "number" ? row.folio : 0,
|
||||
searchable: buildSearchableBlob(entity, row),
|
||||
createdById: row.createdByUserId ?? "",
|
||||
createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
|
||||
updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDocumentForSchema(
|
||||
schema: SearchIndexSchema,
|
||||
entity: EntityWithDetails,
|
||||
row: RowForSearch,
|
||||
tenantId: string
|
||||
): Record<string, unknown> & { id: string; tenantId: string } {
|
||||
if (schema.name === "crm-contacts") return rowToContactDocument(entity, row, tenantId);
|
||||
return rowToRowsDocument(entity, row, tenantId);
|
||||
}
|
||||
|
||||
export async function emitRowSearchUpsert(input: EmitRowSearchUpsertInput): Promise<void> {
|
||||
if (!input.tenantId) return; // non-tenant rows are not indexed
|
||||
const schemas = indexSchemasForEntity(input.entity.name);
|
||||
try {
|
||||
await db.$transaction(async (tx: unknown) => {
|
||||
for (const schema of schemas) {
|
||||
const document = buildDocumentForSchema(schema, input.entity, input.row, input.tenantId!);
|
||||
await publishSearchUpsertEvent({
|
||||
publish: async (event) =>
|
||||
publishOutboxEvent(tx as OutboxPublisherTransaction, {
|
||||
tenantId: event.tenantId,
|
||||
type: event.type,
|
||||
payload: event.payload as Prisma.InputJsonValue,
|
||||
dispatchKey: event.dispatchKey ?? undefined,
|
||||
}),
|
||||
schema,
|
||||
tenantId: input.tenantId!,
|
||||
entityName: schema.name === "crm-contacts" ? "crmcontact" : "row",
|
||||
document,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
input.logger?.warn?.(
|
||||
{
|
||||
component: "row-search-outbox",
|
||||
rowId: input.row.id,
|
||||
entityName: input.entity.name,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
"row search upsert outbox publish failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function emitRowSearchDelete(input: EmitRowSearchDeleteInput): Promise<void> {
|
||||
if (!input.tenantId) return;
|
||||
const schemas = indexSchemasForEntity(input.entity.name);
|
||||
try {
|
||||
await db.$transaction(async (tx: unknown) => {
|
||||
for (const schema of schemas) {
|
||||
await publishSearchDeleteEvent({
|
||||
publish: async (event) =>
|
||||
publishOutboxEvent(tx as OutboxPublisherTransaction, {
|
||||
tenantId: event.tenantId,
|
||||
type: event.type,
|
||||
payload: event.payload as Prisma.InputJsonValue,
|
||||
dispatchKey: event.dispatchKey ?? undefined,
|
||||
}),
|
||||
schema,
|
||||
tenantId: input.tenantId!,
|
||||
entityName: schema.name === "crm-contacts" ? "crmcontact" : "row",
|
||||
documentId: input.rowId,
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
input.logger?.warn?.(
|
||||
{
|
||||
component: "row-search-outbox",
|
||||
rowId: input.rowId,
|
||||
entityName: input.entity.name,
|
||||
error: error instanceof Error ? error.message : "unknown",
|
||||
},
|
||||
"row search delete outbox publish failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { EntityWithDetails, getEntityBySlug } from "~/utils/db/entities/entities.db.server";
|
||||
import { promiseHash } from "~/utils/promises/promiseHash";
|
||||
import { getTenantIdOrNull } from "~/utils/services/.server/urlService";
|
||||
import { getUserInfo, validateCSRFToken } from "~/utils/session.server";
|
||||
import { Params } from "react-router";
|
||||
import { requireAuth } from "~/utils/loaders.middleware";
|
||||
|
||||
type RowsLoaderData = {
|
||||
t: TFunction;
|
||||
userId: string;
|
||||
tenantId: string | null;
|
||||
entity: EntityWithDetails;
|
||||
};
|
||||
async function getLoader({ request, params }: { request: Request; params: Params }): Promise<RowsLoaderData> {
|
||||
await requireAuth({ request, params });
|
||||
const tenantId = await getTenantIdOrNull({ request, params });
|
||||
const unparsedData = await promiseHash({
|
||||
i18n: getTranslations(request),
|
||||
userInfo: getUserInfo(request),
|
||||
tenantId: getTenantIdOrNull({ request, params }),
|
||||
entity: getEntityBySlug({ tenantId, slug: params.entity!, activeOnly: true }),
|
||||
});
|
||||
|
||||
const data: RowsLoaderData = {
|
||||
t: unparsedData.i18n.t,
|
||||
userId: unparsedData.userInfo.userId,
|
||||
tenantId: unparsedData.tenantId,
|
||||
entity: unparsedData.entity,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
type RowsActionData = RowsLoaderData & {
|
||||
form: FormData;
|
||||
};
|
||||
async function getAction({ request, params }: { request: Request; params: Params }): Promise<RowsActionData> {
|
||||
const loader = await getLoader({ request, params });
|
||||
await validateCSRFToken(request);
|
||||
const form = await request.formData();
|
||||
|
||||
return {
|
||||
...loader,
|
||||
form,
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
getLoader,
|
||||
getAction,
|
||||
};
|
||||
Reference in new issue
Block a user