Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ describe("ActivitiesExportForm", () => {
"creator",
);

await openAttributeGroup(user, /audit & time/i);
expect(
screen.getByRole("checkbox", { name: "Creator" }),
).toBeInTheDocument();
Expand Down
318 changes: 318 additions & 0 deletions src/features/exports/components/ExportForm/ExportForm.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
import { INPUT_DATE_FORMAT } from "@/constants";
import { renderWithProviders } from "@/tests/render";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import moment from "moment";
import { describe, expect, it, vi } from "vitest";
import ExportForm from "./ExportForm";
import type {
ExportFieldGroup,
ExportFormValues,
} from "../../types/ExportForm";

const FIELD_GROUPS: readonly ExportFieldGroup[] = [
{
title: "Primary Identity",
key: "primary-identity",
fields: [
{ id: "hostname", label: "Hostname" },
{ id: "title", label: "Instance name" },
],
},
{
title: "Compliance",
key: "compliance",
fields: [
{ id: "securely_patched", label: "Securely patched" },
{ id: "time_to_patch_days", label: "Time to patch (days)" },
],
},
];

const INITIAL_VALUES: ExportFormValues = {
name: "",
selectedFieldIds: [],
retainUntil: moment().add(3, "years").format(INPUT_DATE_FORMAT),
};

const renderForm = (
overrides: Partial<{
initialValues: ExportFormValues;
onGenerate: (args: {
values: ExportFormValues;
fieldsToExport: { id: string; label: string; groupTitle?: string }[];
}) => Promise<void>;
}> = {},
) => {
const onGenerate =
overrides.onGenerate ?? vi.fn().mockResolvedValue(undefined);

renderWithProviders(
<ExportForm
fieldGroups={FIELD_GROUPS}
initialValues={overrides.initialValues ?? INITIAL_VALUES}
isSubmitting={false}
onGenerate={onGenerate}
/>,
);

return { onGenerate };
};

const openAttributeGroup = async (
user: ReturnType<typeof userEvent.setup>,
name: RegExp,
) => {
await user.click(screen.getByRole("tab", { name }));
};

describe("ExportForm", () => {
it("keeps Next disabled until an export name and at least one attribute are selected", async () => {
const user = userEvent.setup();
renderForm();

const nextButton = screen.getByRole("button", { name: "Next" });

expect(nextButton).toHaveAttribute("aria-disabled", "true");

await user.type(
screen.getByRole("textbox", { name: "Export name" }),
"My export",
);

expect(nextButton).toHaveAttribute("aria-disabled", "true");

await openAttributeGroup(user, /primary identity/i);
await user.click(screen.getByRole("checkbox", { name: "Hostname" }));

expect(nextButton).not.toHaveAttribute("aria-disabled", "true");
});

it("filters attributes by search text and shows the empty state when nothing matches", async () => {
const user = userEvent.setup();
renderForm();

const searchInput = screen.getByRole("searchbox", {
name: "Search attributes",
});

await user.type(searchInput, "securely");

expect(
screen.getByRole("tab", { name: /compliance/i }),
).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: /primary identity/i }),
).not.toBeInTheDocument();

await user.clear(searchInput);
await user.type(searchInput, "does not exist");

expect(
screen.getByText("No attributes match your search."),
).toBeInTheDocument();
});

it("auto-expands matching groups so fields are visible without clicking the group header", async () => {
const user = userEvent.setup();
renderForm();

expect(
screen.queryByRole("checkbox", { name: "Hostname" }),
).not.toBeInTheDocument();

await user.type(
screen.getByRole("searchbox", { name: "Search attributes" }),
"host",
);

expect(
screen.getByRole("checkbox", { name: "Hostname" }),
).toBeInTheDocument();
});

it("auto-expands groups matched by group title so all their fields are visible", async () => {
const user = userEvent.setup();
renderForm();

await user.type(
screen.getByRole("searchbox", { name: "Search attributes" }),
"compliance",
);

expect(
screen.getByRole("checkbox", { name: "Securely patched" }),
).toBeInTheDocument();
expect(
screen.getByRole("checkbox", { name: "Time to patch (days)" }),
).toBeInTheDocument();
});

it("selects and deselects all fields in a group from the group checkbox", async () => {
const user = userEvent.setup();
const onGenerate = vi.fn().mockResolvedValue(undefined);

renderForm({ onGenerate });

const exportName = screen.getByRole("textbox", { name: "Export name" });
const nextButton = screen.getByRole("button", { name: "Next" });
const groupSelectAll = screen.getByRole("checkbox", {
name: "Primary Identity",
});

await user.type(exportName, "Group selection export");
await user.click(groupSelectAll);

expect(nextButton).not.toHaveAttribute("aria-disabled", "true");

await user.click(nextButton);
await user.click(screen.getByRole("button", { name: "Generate TSV" }));

await waitFor(() => {
expect(onGenerate).toHaveBeenCalledWith({
values: expect.objectContaining({
selectedFieldIds: ["hostname", "title"],
}),
fieldsToExport: [
{ id: "hostname", label: "Hostname", groupTitle: "Primary Identity" },
{
id: "title",
label: "Instance name",
groupTitle: "Primary Identity",
},
],
});
});

await user.click(screen.getByRole("button", { name: "Back" }));
await user.click(
screen.getByRole("checkbox", { name: "Primary Identity" }),
);

expect(nextButton).toHaveAttribute("aria-disabled", "true");
});

it("moves to the reorder step on the first submit and returns to step 0 with Back", async () => {
const user = userEvent.setup();
const { onGenerate } = renderForm();

await user.type(
screen.getByRole("textbox", { name: "Export name" }),
"Ordered export",
);
await openAttributeGroup(user, /primary identity/i);
await user.click(screen.getByRole("checkbox", { name: "Hostname" }));
await user.click(screen.getByRole("button", { name: "Next" }));

expect(onGenerate).not.toHaveBeenCalled();
expect(
screen.getByRole("button", { name: "Generate TSV" }),
).toBeInTheDocument();

await user.click(screen.getByRole("button", { name: "Back" }));

expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
expect(
screen.getByRole("textbox", { name: "Export name" }),
).toBeInTheDocument();
});

it("submits selected fields and form values on the second step", async () => {
const user = userEvent.setup();
const onGenerate = vi.fn().mockResolvedValue(undefined);

renderForm({ onGenerate });

await user.type(
screen.getByRole("textbox", { name: "Export name" }),
"Compliance export",
);
await openAttributeGroup(user, /primary identity/i);
await user.click(screen.getByRole("checkbox", { name: "Hostname" }));
await openAttributeGroup(user, /compliance/i);
await user.click(
screen.getByRole("checkbox", { name: "Securely patched" }),
);

await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Generate TSV" }));

await waitFor(() => {
expect(onGenerate).toHaveBeenCalledWith({
values: expect.objectContaining({
name: "Compliance export",
selectedFieldIds: ["hostname", "securely_patched"],
retainUntil: INITIAL_VALUES.retainUntil,
}),
fieldsToExport: [
{ id: "hostname", label: "Hostname", groupTitle: "Primary Identity" },
{
id: "securely_patched",
label: "Securely patched",
groupTitle: "Compliance",
},
],
});
});
});

it("shows group badges and reset button on step 2", async () => {
const user = userEvent.setup();
renderForm();

await user.type(
screen.getByRole("textbox", { name: "Export name" }),
"Badge test",
);
await openAttributeGroup(user, /primary identity/i);
await user.click(screen.getByRole("checkbox", { name: "Hostname" }));
await openAttributeGroup(user, /compliance/i);
await user.click(
screen.getByRole("checkbox", { name: "Securely patched" }),
);
await user.click(screen.getByRole("button", { name: "Next" }));

expect(screen.getByText("Primary Identity")).toBeInTheDocument();
expect(screen.getByText("Compliance")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /reset to default order/i }),
).toBeInTheDocument();
});

it("reset to default order restores group-declaration sequence after manual reorder", async () => {
const user = userEvent.setup();
const onGenerate = vi.fn().mockResolvedValue(undefined);

renderForm({ onGenerate });

await user.type(
screen.getByRole("textbox", { name: "Export name" }),
"Reset test",
);
await openAttributeGroup(user, /primary identity/i);
await user.click(screen.getByRole("checkbox", { name: "Hostname" }));
await user.click(screen.getByRole("checkbox", { name: "Instance name" }));
await user.click(screen.getByRole("button", { name: "Next" }));

await user.click(
screen.getByRole("button", { name: /move hostname down/i }),
);

await user.click(
screen.getByRole("button", { name: /reset to default order/i }),
);

await user.click(screen.getByRole("button", { name: "Generate TSV" }));

await waitFor(() => {
expect(onGenerate).toHaveBeenCalledWith(
expect.objectContaining({
fieldsToExport: [
expect.objectContaining({ id: "hostname" }),
expect.objectContaining({ id: "title" }),
],
}),
);
});
});
});
37 changes: 29 additions & 8 deletions src/features/exports/components/ExportForm/ExportForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ const ExportForm: FC<ExportFormProps> = ({
validationSchema: VALIDATION_SCHEMA,
onSubmit: async (values) => {
const selectedFields = fieldGroups
.flatMap((group) => group.fields)
.flatMap((group) =>
group.fields.map((field) => ({ ...field, groupTitle: group.title })),
)
.filter((field) => values.selectedFieldIds.includes(field.id));

if (step === 0) {
Expand Down Expand Up @@ -165,6 +167,31 @@ const ExportForm: FC<ExportFormProps> = ({

const selectedFieldIdsError = getFormikError(formik, "selectedFieldIds");

const renderFieldGroups = () => {
if (!filteredFieldGroups.length) {
return (
<p className={classes.emptyState}>No attributes match your search.</p>
);
}

if (attributeSearch.trim()) {
return filteredFieldGroups.map((group) => {
const section = accordionSections.find((s) => s.key === group.key);
if (!section) return null;
return (
<Accordion
key={group.key}
expanded={group.key}
sections={[section]}
titleElement="h5"
/>
);
});
}

return <Accordion sections={accordionSections} titleElement="h5" />;
};

const stepContent =
step === 0 ? (
<>
Expand Down Expand Up @@ -206,13 +233,7 @@ const ExportForm: FC<ExportFormProps> = ({
{selectedFieldIdsError}
</p>
)}
{filteredFieldGroups.length ? (
<Accordion sections={accordionSections} titleElement="h5" />
) : (
<p className={classes.emptyState}>
No attributes match your search.
</p>
)}
{renderFieldGroups()}
</div>
</>
) : (
Expand Down
Loading