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