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