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,116 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { createMemoryRouter, RouterProvider } from "react-router";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { FeedbackWithDetails } from "../db/feedback.db.server";
|
||||
import { feedbackRedactionMarkers } from "../services/FeedbackRedaction.server";
|
||||
import FeedbackTable from "./FeedbackTable";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "de", exists: () => true } }),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/buttons/ButtonSecondary", () => ({
|
||||
default: ({ children, isLoading, destructive, ...props }: React.ComponentProps<"button"> & { isLoading?: boolean; destructive?: boolean }) => (
|
||||
<button type="button" aria-busy={isLoading} data-destructive={destructive ? "true" : undefined} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/icons/RefreshIcon", () => ({
|
||||
default: ({ className }: { className?: string }) => <span className={className}>refresh</span>,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/input/InputFilters", () => ({
|
||||
default: () => <div data-testid="feedback-filters" />,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/input/InputSearchWithURL", () => ({
|
||||
default: () => <input aria-label="search" />,
|
||||
}));
|
||||
|
||||
vi.mock("~/components/ui/links/FilterableValueLink", () => ({
|
||||
FilterableValueLink: ({ value }: { value?: string | null }) => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
const rawEmail = "legacy.user@example.com";
|
||||
const rawBearerToken = "raw-admin-token-123";
|
||||
const rawHexSecret = "abcdef0123456789abcdef0123456789";
|
||||
|
||||
const feedbackRow: FeedbackWithDetails = {
|
||||
id: "feedback_1",
|
||||
createdAt: new Date("2026-01-01T10:00:00.000Z"),
|
||||
tenantId: "tenant_1",
|
||||
userId: "user_1",
|
||||
message: `Login fails for ${rawEmail} with Bearer ${rawBearerToken} and trace ${rawHexSecret}`,
|
||||
fromUrl: "/acme?token=raw-url-token",
|
||||
tenant: {
|
||||
id: "tenant_1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
icon: null,
|
||||
logo: null,
|
||||
deactivatedReason: null,
|
||||
types: [],
|
||||
active: true,
|
||||
theme: null,
|
||||
themeConfig: null,
|
||||
},
|
||||
user: {
|
||||
id: "user_1",
|
||||
email: "admin@example.com",
|
||||
firstName: "Ada",
|
||||
lastName: "Admin",
|
||||
phone: null,
|
||||
locale: "en",
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
},
|
||||
};
|
||||
|
||||
describe("FeedbackTable", () => {
|
||||
it("redacts sensitive legacy feedback message values before rendering the admin table", async () => {
|
||||
const router = createMemoryRouter([
|
||||
{
|
||||
path: "/feedback",
|
||||
element: (
|
||||
<FeedbackTable
|
||||
data={{
|
||||
items: [feedbackRow],
|
||||
filterableProperties: [],
|
||||
pagination: { page: 1, pageSize: 10, totalItems: 1, totalPages: 1 },
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
], { initialEntries: ["/feedback"] });
|
||||
|
||||
const { container } = render(<RouterProvider router={router} />);
|
||||
|
||||
await waitFor(() => expect(container).toHaveTextContent(feedbackRedactionMarkers.email));
|
||||
expect(container).toHaveTextContent(`Bearer ${feedbackRedactionMarkers.token}`);
|
||||
expect(container).toHaveTextContent(feedbackRedactionMarkers.secret);
|
||||
expect(container).not.toHaveTextContent(rawEmail);
|
||||
expect(container).not.toHaveTextContent(rawBearerToken);
|
||||
expect(container).not.toHaveTextContent(rawHexSecret);
|
||||
});
|
||||
|
||||
it("keeps help desk dates and counts on locale-aware formatters", () => {
|
||||
const repoRoot = process.cwd();
|
||||
const source = [
|
||||
"apps/web/app/modules/helpDesk/components/FeedbackTable.tsx",
|
||||
"apps/web/app/routes/admin/help-desk/index.tsx",
|
||||
"apps/web/app/routes/admin/help-desk/tickets.tsx",
|
||||
]
|
||||
.map((file) => fs.readFileSync(path.join(repoRoot, file), "utf8"))
|
||||
.join("\n");
|
||||
|
||||
expect(source).toContain("useLocaleFormatters");
|
||||
expect(source).toContain("formatters.dateTime");
|
||||
expect(source).toContain("formatters.number");
|
||||
expect(source).not.toContain("DateUtils");
|
||||
expect(source).not.toContain("NumberUtils");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useSubmit, useSearchParams, useNavigation, useParams } from "react-router";
|
||||
import { useState } from "react";
|
||||
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 { useTranslation } from "react-i18next";
|
||||
import type { FeedbackWithDetails } from "../db/feedback.db.server";
|
||||
import { redactFeedbackText } from "../services/FeedbackRedaction";
|
||||
import { useLocaleFormatters } from "~/locale/format";
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
items: FeedbackWithDetails[];
|
||||
filterableProperties: FilterablePropertyDto[];
|
||||
pagination: PaginationDto;
|
||||
};
|
||||
}
|
||||
export default function FeedbackTable({ data }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const formatters = useLocaleFormatters();
|
||||
const submit = useSubmit();
|
||||
const navigation = useNavigation();
|
||||
const params = useParams();
|
||||
|
||||
const canDelete = !params.tenant;
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<FeedbackWithDetails[]>([]);
|
||||
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="flex-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("helpDesk.feedback.fields.tenant"),
|
||||
value: (item) => <FilterableValueLink name="tenantId" value={item?.tenant?.name} param={item?.tenant?.id} />,
|
||||
hidden: !!params.tenant,
|
||||
},
|
||||
{
|
||||
name: "user",
|
||||
title: t("models.user.object"),
|
||||
value: (item) => <FilterableValueLink name="userId" value={item.user?.email} param={item.user?.id} />,
|
||||
},
|
||||
{
|
||||
name: "message",
|
||||
title: t("feedback.message"),
|
||||
value: (item) => <div>{redactFeedbackText(item.message)}</div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user