Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,134 @@
|
||||
import { useSubmit, useNavigation, useParams, useSearchParams, Link } from "react-router";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FilterablePropertyDto } from "~/application/dtos/data/FilterablePropertyDto";
|
||||
import { PaginationDto } from "~/application/dtos/data/PaginationDto";
|
||||
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
||||
import RefreshIcon from "~/components/ui/icons/RefreshIcon";
|
||||
import InputFilters from "~/components/ui/input/InputFilters";
|
||||
import InputSearchWithURL from "~/components/ui/input/InputSearchWithURL";
|
||||
import { FilterableValueLink } from "~/components/ui/links/FilterableValueLink";
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
import { CreditWithDetails } from "../db/credits.db.server";
|
||||
import { CreditTypes } from "../dtos/CreditType";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
items: CreditWithDetails[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: PaginationDto;
|
||||
canDelete: boolean;
|
||||
};
|
||||
}
|
||||
export default function CreditsList({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const params = useParams();
|
||||
|
||||
const canDelete = data.canDelete;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<CreditWithDetails[]>([]);
|
||||
function onDelete(ids: string[]) {
|
||||
if (!canDelete) {
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.set("action", "delete");
|
||||
form.set("ids", ids.join(","));
|
||||
submit(form, {
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex w-full items-center space-x-2">
|
||||
<div className="grow">
|
||||
<InputSearchWithURL />
|
||||
</div>
|
||||
{canDelete && selectedRows.length > 0 && (
|
||||
<ButtonSecondary
|
||||
destructive
|
||||
onClick={() => {
|
||||
onDelete(selectedRows.map((x) => x.id));
|
||||
setSelectedRows([]);
|
||||
}}
|
||||
>
|
||||
{t("shared.delete")} {selectedRows.length}
|
||||
</ButtonSecondary>
|
||||
)}
|
||||
<ButtonSecondary onClick={() => setSearchParams(searchParams)} isLoading={navigation.state === "loading"}>
|
||||
<RefreshIcon className="h-4 w-4" />
|
||||
</ButtonSecondary>
|
||||
<InputFilters filters={data.filterableProperties} />
|
||||
</div>
|
||||
<TableSimple
|
||||
selectedRows={canDelete ? selectedRows : undefined}
|
||||
onSelected={canDelete ? setSelectedRows : undefined}
|
||||
items={data.items}
|
||||
pagination={data.pagination}
|
||||
actions={[
|
||||
{
|
||||
title: t("shared.delete"),
|
||||
onClick: (_, item) => onDelete([item.id]),
|
||||
hidden: () => !canDelete,
|
||||
disabled: () => !canDelete,
|
||||
destructive: true,
|
||||
},
|
||||
]}
|
||||
headers={[
|
||||
{
|
||||
name: "createdAt",
|
||||
title: t("shared.createdAt"),
|
||||
value: (item) => formatters.dateTime(item.createdAt, { timeStyle: "medium" }),
|
||||
formattedValue: (item) => (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{item.createdAt && <span>{formatters.dateTime(item.createdAt, { timeStyle: "medium" })}</span>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "tenant",
|
||||
title: t("models.tenant.object"),
|
||||
value: (item) => <FilterableValueLink name="tenantId" value={item?.tenant?.name} param={item?.tenant?.id} />,
|
||||
hidden: !!params.tenant,
|
||||
},
|
||||
|
||||
{
|
||||
name: "type",
|
||||
title: t("shared.type"),
|
||||
value: (item) => {
|
||||
const creditType = CreditTypes.find((type) => type.value === item.type);
|
||||
return <div>{creditType ? t(creditType.name) : t("models.credit.types.unknown", { type: item.type })}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "resource",
|
||||
title: t("models.credit.resource"),
|
||||
className: "w-full",
|
||||
value: (item) => (
|
||||
<div className="max-w-xs truncate">
|
||||
{item.objectId ? (
|
||||
<Link to={item.objectId} className="truncate underline">
|
||||
{item.objectId}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="truncate">{t("shared.notSet")}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
title: t("models.user.object"),
|
||||
value: (item) => <FilterableValueLink name="userId" value={item.user?.email} param={item.user?.id} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Link } from "react-router";
|
||||
import { Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export default function CreditsRemaining({ remaining, redirectTo }: { remaining?: number | "unlimited"; redirectTo: string }) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
return (
|
||||
<Fragment>
|
||||
{!!remaining && (
|
||||
<Link
|
||||
to={redirectTo}
|
||||
className="cursor-pointer select-none rounded-full border border-green-200 bg-green-50 px-2 py-1 text-xs text-green-700 hover:bg-green-100"
|
||||
>
|
||||
{remaining === "unlimited" ? (
|
||||
<span>{t("models.credit.unlimited")}</span>
|
||||
) : remaining <= 0 ? (
|
||||
<span>{t("models.credit.empty")}</span>
|
||||
) : (
|
||||
<span>{t("models.credit.remaining", { 0: formatters.number(remaining) })}</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import TableSimple from "~/components/ui/tables/TableSimple";
|
||||
import { CreditTypes } from "../dtos/CreditType";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
export default function CreditsTableInfo() {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">{t("models.credit.whatIs")}</h3>
|
||||
<p className="text-muted-foreground text-sm">{t("models.credit.info")}</p>
|
||||
</div>
|
||||
<TableSimple
|
||||
items={CreditTypes}
|
||||
headers={[
|
||||
{
|
||||
name: "name",
|
||||
title: t("shared.name"),
|
||||
className: "w-full",
|
||||
value: (item) => (
|
||||
<div className="flex flex-col">
|
||||
<div className="font-bold">{t(item.name)}</div>
|
||||
<div className="text-muted-foreground text-xs">{t(item.description)}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "amount",
|
||||
title: t("models.credit.plural"),
|
||||
align: "right",
|
||||
value: (item) => formatters.number(item.amount),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = process.cwd();
|
||||
|
||||
function readUsageComponent(fileName: string) {
|
||||
return fs.readFileSync(path.join(root, "apps/web/app/modules/usage/components", fileName), "utf8");
|
||||
}
|
||||
|
||||
describe("usage credit formatting i18n", () => {
|
||||
it("uses locale-aware formatters for credit list timestamps", () => {
|
||||
const source = readUsageComponent("CreditsList.tsx");
|
||||
|
||||
expect(source).not.toContain("DateUtils");
|
||||
expect(source).not.toContain('title: "Tenant"');
|
||||
expect(source).toContain("useLocaleFormatters");
|
||||
expect(source).toContain('t("models.tenant.object")');
|
||||
expect(source).toContain('t("shared.notSet")');
|
||||
expect(source).toContain('t(creditType.name)');
|
||||
expect(source).toContain('t("models.credit.types.unknown"');
|
||||
expect(source).toContain("formatters.dateTime");
|
||||
expect(source).not.toContain('t("shared.undefined")');
|
||||
expect(source).not.toContain("<div>{item.type}</div>");
|
||||
});
|
||||
|
||||
it("uses locale-aware formatters for credit amounts", () => {
|
||||
const sources = ["CreditsRemaining.tsx", "CreditsTableInfo.tsx"].map(readUsageComponent).join("\n");
|
||||
|
||||
expect(sources).not.toContain("NumberUtils");
|
||||
expect(sources).toContain("useLocaleFormatters");
|
||||
expect(sources).toContain("formatters.number");
|
||||
expect(sources).toContain("t(item.name)");
|
||||
expect(sources).toContain("t(item.description)");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user