Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
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");
});
});
@@ -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") + " "');
});
});
@@ -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,
};