diff --git a/src/features/activities/components/ActivitiesExportForm/ActivitiesExportForm.test.tsx b/src/features/activities/components/ActivitiesExportForm/ActivitiesExportForm.test.tsx
index 48c0cac2d3..948c21f16b 100644
--- a/src/features/activities/components/ActivitiesExportForm/ActivitiesExportForm.test.tsx
+++ b/src/features/activities/components/ActivitiesExportForm/ActivitiesExportForm.test.tsx
@@ -74,7 +74,6 @@ describe("ActivitiesExportForm", () => {
"creator",
);
- await openAttributeGroup(user, /audit & time/i);
expect(
screen.getByRole("checkbox", { name: "Creator" }),
).toBeInTheDocument();
diff --git a/src/features/exports/components/ExportForm/ExportForm.test.tsx b/src/features/exports/components/ExportForm/ExportForm.test.tsx
new file mode 100644
index 0000000000..1c91a370b8
--- /dev/null
+++ b/src/features/exports/components/ExportForm/ExportForm.test.tsx
@@ -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;
+ }> = {},
+) => {
+ const onGenerate =
+ overrides.onGenerate ?? vi.fn().mockResolvedValue(undefined);
+
+ renderWithProviders(
+ ,
+ );
+
+ return { onGenerate };
+};
+
+const openAttributeGroup = async (
+ user: ReturnType,
+ 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" }),
+ ],
+ }),
+ );
+ });
+ });
+});
diff --git a/src/features/exports/components/ExportForm/ExportForm.tsx b/src/features/exports/components/ExportForm/ExportForm.tsx
index a3ce1de0d1..7b638be9b1 100644
--- a/src/features/exports/components/ExportForm/ExportForm.tsx
+++ b/src/features/exports/components/ExportForm/ExportForm.tsx
@@ -52,7 +52,9 @@ const ExportForm: FC = ({
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) {
@@ -165,6 +167,31 @@ const ExportForm: FC = ({
const selectedFieldIdsError = getFormikError(formik, "selectedFieldIds");
+ const renderFieldGroups = () => {
+ if (!filteredFieldGroups.length) {
+ return (
+ No attributes match your search.
+ );
+ }
+
+ if (attributeSearch.trim()) {
+ return filteredFieldGroups.map((group) => {
+ const section = accordionSections.find((s) => s.key === group.key);
+ if (!section) return null;
+ return (
+
+ );
+ });
+ }
+
+ return ;
+ };
+
const stepContent =
step === 0 ? (
<>
@@ -206,13 +233,7 @@ const ExportForm: FC = ({
{selectedFieldIdsError}
)}
- {filteredFieldGroups.length ? (
-
- ) : (
-
- No attributes match your search.
-
- )}
+ {renderFieldGroups()}
>
) : (
diff --git a/src/features/exports/components/SortableFieldList/SortableFieldList.module.scss b/src/features/exports/components/SortableFieldList/SortableFieldList.module.scss
index bc47fc53d8..5cc4d50b66 100644
--- a/src/features/exports/components/SortableFieldList/SortableFieldList.module.scss
+++ b/src/features/exports/components/SortableFieldList/SortableFieldList.module.scss
@@ -5,10 +5,24 @@
padding-top: $spv--small;
}
-.selectedColumnsIntro {
+.selectedColumnsHeader {
+ align-items: baseline;
+ display: flex;
+ gap: $sph--large;
+ justify-content: space-between;
margin-bottom: $spv--medium;
}
+.selectedColumnsIntro {
+ flex: 1;
+ margin-bottom: 0;
+}
+
+.resetButton {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
.nameCell {
align-items: center;
display: flex;
diff --git a/src/features/exports/components/SortableFieldList/SortableFieldList.test.tsx b/src/features/exports/components/SortableFieldList/SortableFieldList.test.tsx
new file mode 100644
index 0000000000..261f5bf253
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/SortableFieldList.test.tsx
@@ -0,0 +1,239 @@
+import { renderWithProviders } from "@/tests/render";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { useState } from "react";
+import { describe, expect, it, vi } from "vitest";
+import SortableFieldList from "./SortableFieldList";
+import type { ExportField } from "../../types/ExportForm";
+
+const FIELDS: ExportField[] = [
+ { id: "hostname", label: "Hostname", groupTitle: "Primary Identity" },
+ { id: "status", label: "Status", groupTitle: "Primary Identity" },
+ {
+ id: "securely_patched",
+ label: "Securely patched",
+ groupTitle: "Compliance",
+ },
+];
+
+describe("SortableFieldList", () => {
+ it("renders all field labels", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("Hostname")).toBeInTheDocument();
+ expect(screen.getByText("Status")).toBeInTheDocument();
+ expect(screen.getByText("Securely patched")).toBeInTheDocument();
+ });
+
+ it("renders group badges for each field", () => {
+ renderWithProviders(
+ ,
+ );
+
+ const primaryBadges = screen.getAllByText("Primary Identity");
+ expect(primaryBadges).toHaveLength(2);
+ expect(screen.getByText("Compliance")).toBeInTheDocument();
+ });
+
+ it("does not render a group badge when groupTitle is absent", () => {
+ const fields: ExportField[] = [{ id: "hostname", label: "Hostname" }];
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.queryByText("Primary Identity")).not.toBeInTheDocument();
+ });
+
+ it("shows the Reset to default order button", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: /reset to default order/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("calls onOrderChange with original fields when Reset is clicked", async () => {
+ const user = userEvent.setup();
+ const onOrderChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /move hostname down/i }),
+ );
+ onOrderChange.mockClear();
+
+ await user.click(
+ screen.getByRole("button", { name: /reset to default order/i }),
+ );
+
+ expect(onOrderChange).toHaveBeenCalledWith(FIELDS);
+ });
+
+ it("restores the displayed row order after Reset is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /move hostname down/i }),
+ );
+
+ expect(
+ screen.getByRole("spinbutton", { name: /order for status/i }),
+ ).toHaveValue(1);
+ expect(
+ screen.getByRole("spinbutton", { name: /order for hostname/i }),
+ ).toHaveValue(2);
+
+ await user.click(
+ screen.getByRole("button", { name: /reset to default order/i }),
+ );
+
+ expect(
+ screen.getByRole("spinbutton", { name: /order for hostname/i }),
+ ).toHaveValue(1);
+ expect(
+ screen.getByRole("spinbutton", { name: /order for status/i }),
+ ).toHaveValue(2);
+ });
+
+ it("moves a field up using the arrow button", async () => {
+ const user = userEvent.setup();
+ const onOrderChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /move status up/i }));
+
+ expect(onOrderChange).toHaveBeenCalledWith([
+ expect.objectContaining({ id: "status" }),
+ expect.objectContaining({ id: "hostname" }),
+ expect.objectContaining({ id: "securely_patched" }),
+ ]);
+ });
+
+ it("moves a field down using the arrow button", async () => {
+ const user = userEvent.setup();
+ const onOrderChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(
+ screen.getByRole("button", { name: /move hostname down/i }),
+ );
+
+ expect(onOrderChange).toHaveBeenCalledWith([
+ expect.objectContaining({ id: "status" }),
+ expect.objectContaining({ id: "hostname" }),
+ expect.objectContaining({ id: "securely_patched" }),
+ ]);
+ });
+
+ it("disables the Up button for the first row and the Down button for the last row", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByRole("button", { name: /move hostname up/i }),
+ ).toHaveAttribute("aria-disabled", "true");
+ expect(
+ screen.getByRole("button", { name: /move securely patched down/i }),
+ ).toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("reset restores original order even when parent state is kept in sync with each move", async () => {
+ const user = userEvent.setup();
+
+ // Mirror ExportForm's usage: parent updates fields on every onOrderChange call
+ const ControlledWrapper = () => {
+ const [fields, setFields] = useState(FIELDS);
+ return ;
+ };
+
+ renderWithProviders();
+
+ await user.click(
+ screen.getByRole("button", { name: /move hostname down/i }),
+ );
+
+ expect(
+ screen.getByRole("spinbutton", { name: /order for status/i }),
+ ).toHaveValue(1);
+
+ await user.click(
+ screen.getByRole("button", { name: /reset to default order/i }),
+ );
+
+ expect(
+ screen.getByRole("spinbutton", { name: /order for hostname/i }),
+ ).toHaveValue(1);
+ expect(
+ screen.getByRole("spinbutton", { name: /order for status/i }),
+ ).toHaveValue(2);
+ });
+
+ it("renders the reorder instructions", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText(/review and reorder the columns for your export/i),
+ ).toBeInTheDocument();
+ });
+
+ it("moves a field to a specific position via the order input", async () => {
+ const user = userEvent.setup();
+ const onOrderChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const hostnameInput = screen.getByRole("spinbutton", {
+ name: /order for hostname/i,
+ });
+ await user.clear(hostnameInput);
+ await user.type(hostnameInput, "3");
+ await user.keyboard("{Enter}");
+
+ expect(onOrderChange).toHaveBeenCalledWith([
+ expect.objectContaining({ id: "status" }),
+ expect.objectContaining({ id: "securely_patched" }),
+ expect.objectContaining({ id: "hostname" }),
+ ]);
+ });
+
+ it("ignores an out-of-range value entered in the order input", async () => {
+ const user = userEvent.setup();
+ const onOrderChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ const hostnameInput = screen.getByRole("spinbutton", {
+ name: /order for hostname/i,
+ });
+ await user.clear(hostnameInput);
+ await user.type(hostnameInput, "99");
+ await user.keyboard("{Enter}");
+
+ expect(onOrderChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/features/exports/components/SortableFieldList/SortableFieldList.tsx b/src/features/exports/components/SortableFieldList/SortableFieldList.tsx
index 1aee381dcf..b12ecdebc4 100644
--- a/src/features/exports/components/SortableFieldList/SortableFieldList.tsx
+++ b/src/features/exports/components/SortableFieldList/SortableFieldList.tsx
@@ -1,4 +1,10 @@
-import { Button, Icon, Input, ModularTable } from "@canonical/react-components";
+import {
+ Button,
+ Chip,
+ Icon,
+ Input,
+ ModularTable,
+} from "@canonical/react-components";
import classNames from "classnames";
import {
useCallback,
@@ -28,6 +34,7 @@ interface ReorderRowData extends Record {
index: number;
fieldId: string;
label: string;
+ groupTitle: string | undefined;
currentOrder: string;
}
@@ -73,6 +80,10 @@ const SortableFieldList: FC = ({
const orderedFieldsRef = useRef(orderedFields);
const draggingFieldIdRef = useRef(draggingFieldId);
+ // Capture the field list as it exists on first render so Reset can always
+ // return to the group-declaration order, regardless of how many moves the
+ // parent state has accumulated since then.
+ const initialFieldsRef = useRef(fields);
// Stable per-row ref callbacks, cached by fieldId, so the row's ref doesn't
// churn (null then re-set) on every render.
@@ -95,6 +106,12 @@ const SortableFieldList: FC = ({
});
usePendingFieldScroll({ orderedFields, pendingScrollRef, rowRefsMap });
+ const handleResetOrder = useCallback(() => {
+ setOrderedFields([...initialFieldsRef.current]);
+ setOrderDrafts({});
+ onOrderChange([...initialFieldsRef.current]);
+ }, [onOrderChange]);
+
const triggerMoveEffect = useCallback((fieldId: string) => {
if (justMovedTimerRef.current) clearTimeout(justMovedTimerRef.current);
setJustMovedFieldId(fieldId);
@@ -338,11 +355,19 @@ const SortableFieldList: FC = ({
Header: "Attribute name",
accessor: "fieldId",
Cell: ({ row }: CellProps) => {
- const { label } = row.original;
+ const { label, groupTitle } = row.original;
return (
{label}
+ {groupTitle && (
+
+ )}
);
},
@@ -421,6 +446,7 @@ const SortableFieldList: FC = ({
index,
fieldId: field.id,
label: field.label,
+ groupTitle: field.groupTitle,
currentOrder: orderDrafts[index] ?? String(index + 1),
})),
[orderDrafts, orderedFields],
@@ -484,10 +510,20 @@ const SortableFieldList: FC = ({
return (
-
- Review and reorder the columns for your export. Drag rows or use the
- controls to change the order.
-
+
+
+ Review and reorder the columns for your export. Drag rows or use the
+ controls to change the order.
+
+
+