From a0a4e2f7f5eab4a7ac0a21cd8af2d0bc3bd825dd Mon Sep 17 00:00:00 2001
From: Yuriy Vasilyev
Date: Wed, 17 Jun 2026 18:03:57 +0200
Subject: [PATCH 01/41] feat: redesign the reports side panel on the V2
endpoint
Replace the client-side aggregation of four V1 endpoints with a single
useGetComplianceReport call against GET computers/report, and redesign the
side panel to match the Figma: a status bar table, a time-to-patch donut
with a legend table, and clickable counts that deep-link to the filtered
instances list. "60+ days outstanding" is now the headline USN debt metric
(previously mislabelled "60 days").
- Snapshot the selection when the panel opens; warn and offer to
regenerate when the live selection changes.
- Wire the CSV download form's "Report by CVE" and range controls to the
request and fetch on submit; note what the legacy CSV contains.
- Gate the "View report" action behind the instance-reports feature flag
from the features/ endpoint, removing the VITE_REPORT_VIEW_ENABLED
build-time env var.
---
.changeset/reports-side-panel.md | 5 +
.env.local.example | 1 -
.env.production | 1 -
.gitignore | 2 +-
src/constants.ts | 2 -
.../hooks/useInstanceSearchHelpTerms.tsx | 34 +++
.../InstancesPageActions.test.tsx | 23 +-
.../InstancesPageActions.tsx | 5 +-
src/features/instances/index.ts | 5 +
.../instances/selectedInstancesStore.ts | 31 ++
src/features/reports/api/index.ts | 2 +
.../reports/api/useGetComplianceReport.ts | 36 +++
.../MetricBarTable/MetricBarTable.module.scss | 26 ++
.../MetricBarTable/MetricBarTable.test.tsx | 70 +++++
.../MetricBarTable/MetricBarTable.tsx | 67 +++++
.../components/MetricBarTable/index.ts | 2 +
.../ReportDonutChart.module.scss | 101 +++++++
.../ReportDonutChart.test.tsx | 124 ++++++++
.../ReportDonutChart/ReportDonutChart.tsx | 199 ++++++++++++
.../components/ReportDonutChart/index.ts | 2 +
.../ReportForm/ReportForm.module.scss | 0
.../ReportForm/ReportForm.test.tsx | 39 +++
.../components}/ReportForm/ReportForm.tsx | 44 ++-
.../reports/components}/ReportForm/index.ts | 0
.../ReportLegendTable.module.scss | 90 ++++++
.../ReportLegendTable.test.tsx | 138 +++++++++
.../ReportLegendTable/ReportLegendTable.tsx | 130 ++++++++
.../components/ReportLegendTable/index.ts | 2 +
.../ReportView/ReportView.module.scss | 10 +
.../components/ReportView/ReportView.test.tsx | 284 ++++++++++++++++++
.../components/ReportView/ReportView.tsx | 277 +++++++++++++++++
.../reports/components}/ReportView/index.ts | 0
src/features/reports/index.ts | 4 +
src/features/reports/types.ts | 19 ++
.../components/SavedSearchForm/constants.ts | 7 +
.../helpers/searchQueryLanguage.ts | 11 +
.../helpers/searchQueryValidation.ts | 14 +
src/hooks/useReports.ts | 80 +----
.../instances/InstancesPage/InstancesPage.tsx | 24 +-
.../instances/InstancesPage/helpers.test.ts | 1 -
.../ReportView/ReportView.module.scss | 15 -
.../instances/ReportView/ReportView.test.tsx | 52 ----
.../instances/ReportView/ReportView.tsx | 172 -----------
.../ReportWidget/ReportWidget.module.scss | 21 --
.../ReportWidget/ReportWidget.test.tsx | 33 --
.../instances/ReportWidget/ReportWidget.tsx | 38 ---
.../dashboard/instances/ReportWidget/index.ts | 1 -
src/tests/mocks/features.ts | 11 +
src/tests/server/handlers/features.ts | 11 +-
src/tests/server/handlers/instance.ts | 8 +-
src/tests/server/handlers/reports.ts | 85 ++++--
src/types/FeatureKey.ts | 1 +
src/vite-env.d.ts | 1 -
53 files changed, 1893 insertions(+), 468 deletions(-)
create mode 100644 .changeset/reports-side-panel.md
create mode 100644 src/features/instances/selectedInstancesStore.ts
create mode 100644 src/features/reports/api/index.ts
create mode 100644 src/features/reports/api/useGetComplianceReport.ts
create mode 100644 src/features/reports/components/MetricBarTable/MetricBarTable.module.scss
create mode 100644 src/features/reports/components/MetricBarTable/MetricBarTable.test.tsx
create mode 100644 src/features/reports/components/MetricBarTable/MetricBarTable.tsx
create mode 100644 src/features/reports/components/MetricBarTable/index.ts
create mode 100644 src/features/reports/components/ReportDonutChart/ReportDonutChart.module.scss
create mode 100644 src/features/reports/components/ReportDonutChart/ReportDonutChart.test.tsx
create mode 100644 src/features/reports/components/ReportDonutChart/ReportDonutChart.tsx
create mode 100644 src/features/reports/components/ReportDonutChart/index.ts
rename src/{pages/dashboard/instances => features/reports/components}/ReportForm/ReportForm.module.scss (100%)
rename src/{pages/dashboard/instances => features/reports/components}/ReportForm/ReportForm.test.tsx (60%)
rename src/{pages/dashboard/instances => features/reports/components}/ReportForm/ReportForm.tsx (58%)
rename src/{pages/dashboard/instances => features/reports/components}/ReportForm/index.ts (100%)
create mode 100644 src/features/reports/components/ReportLegendTable/ReportLegendTable.module.scss
create mode 100644 src/features/reports/components/ReportLegendTable/ReportLegendTable.test.tsx
create mode 100644 src/features/reports/components/ReportLegendTable/ReportLegendTable.tsx
create mode 100644 src/features/reports/components/ReportLegendTable/index.ts
create mode 100644 src/features/reports/components/ReportView/ReportView.module.scss
create mode 100644 src/features/reports/components/ReportView/ReportView.test.tsx
create mode 100644 src/features/reports/components/ReportView/ReportView.tsx
rename src/{pages/dashboard/instances => features/reports/components}/ReportView/index.ts (100%)
create mode 100644 src/features/reports/index.ts
create mode 100644 src/features/reports/types.ts
delete mode 100644 src/pages/dashboard/instances/ReportView/ReportView.module.scss
delete mode 100644 src/pages/dashboard/instances/ReportView/ReportView.test.tsx
delete mode 100644 src/pages/dashboard/instances/ReportView/ReportView.tsx
delete mode 100644 src/pages/dashboard/instances/ReportWidget/ReportWidget.module.scss
delete mode 100644 src/pages/dashboard/instances/ReportWidget/ReportWidget.test.tsx
delete mode 100644 src/pages/dashboard/instances/ReportWidget/ReportWidget.tsx
delete mode 100644 src/pages/dashboard/instances/ReportWidget/index.ts
diff --git a/.changeset/reports-side-panel.md b/.changeset/reports-side-panel.md
new file mode 100644
index 0000000000..a09c48841a
--- /dev/null
+++ b/.changeset/reports-side-panel.md
@@ -0,0 +1,5 @@
+---
+"landscape-ui": minor
+---
+
+Redesign the instances reports side panel on a single V2 `computers/report` endpoint, with deep-linked counts and a working CSV download. Gated behind the `instance-reports` feature flag (on by default).
diff --git a/.env.local.example b/.env.local.example
index 11da589c46..0a23e7aa1a 100644
--- a/.env.local.example
+++ b/.env.local.example
@@ -3,7 +3,6 @@ VITE_API_URL_OLD=
VITE_API_URL_DEB_ARCHIVE=
VITE_ROOT_PATH=
VITE_SELF_HOSTED_ENV=
-VITE_REPORT_VIEW_ENABLED=
VITE_DETAILED_UPGRADES_VIEW_ENABLED=
VITE_MSW_ENABLED=
VITE_MSW_ENDPOINTS_TO_INTERCEPT=
diff --git a/.env.production b/.env.production
index 6b801982e9..c01e42dca8 100644
--- a/.env.production
+++ b/.env.production
@@ -2,7 +2,6 @@ VITE_API_URL=/api/v2/
VITE_API_URL_OLD=/api/
VITE_API_URL_DEB_ARCHIVE=/debarchive/v1beta1/
VITE_ROOT_PATH=/new_dashboard/
-VITE_REPORT_VIEW_ENABLED=false
VITE_DETAILED_UPGRADES_VIEW_ENABLED=false
VITE_MSW_ENABLED=false
VITE_MSW_ENDPOINTS_TO_INTERCEPT=
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index e030a7e585..cd15c44c85 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,7 +15,7 @@ dist-ssr
.venv
.vite-node
coverage
-reports
+/reports
src/**/*.module.scss.d.ts
# Cache
diff --git a/src/constants.ts b/src/constants.ts
index 708151a593..106672e3a9 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -16,8 +16,6 @@ export const NOT_AVAILABLE = "N/A";
export const APP_VERSION = import.meta.env.VITE_APP_VERSION;
export const APP_COMMIT = import.meta.env.VITE_APP_COMMIT;
export const FEEDBACK_LINK = "https://bugs.launchpad.net/landscape";
-export const REPORT_VIEW_ENABLED =
- import.meta.env.VITE_REPORT_VIEW_ENABLED === "true";
export const CONTACT_SUPPORT_TEAM_MESSAGE =
"Something went wrong. Please try again or contact our support team.";
export const DETAILED_UPGRADES_VIEW_ENABLED =
diff --git a/src/features/instances/components/InstancesHeader/hooks/useInstanceSearchHelpTerms.tsx b/src/features/instances/components/InstancesHeader/hooks/useInstanceSearchHelpTerms.tsx
index 0e0f719c69..ecfa308468 100644
--- a/src/features/instances/components/InstancesHeader/hooks/useInstanceSearchHelpTerms.tsx
+++ b/src/features/instances/components/InstancesHeader/hooks/useInstanceSearchHelpTerms.tsx
@@ -347,6 +347,40 @@ const useInstanceSearchHelpTerms = () => {
),
},
+ {
+ term: "last-ping-minutes:",
+ description: (
+
+ Instances that were active in the last{" "}
+ <nr-of-minutes> minutes, where{" "}
+ <nr-of-minutes> is a value between 1 and 525600.
+
+ ),
+ },
+ {
+ term: "usn-outstanding:",
+ description: (
+
+ Instances with unresolved USNs released more than{" "}
+ <nr-of-days> days ago.
+
+ ),
+ },
+ {
+ term: "usn-applied-in:",
+ description: (
+
+ Instances which applied all recently released USNs within{" "}
+ <nr-of-days> days.
+
+ ),
+ },
+ {
+ term: "has:upgrade-profile",
+ description: (
+ Instances covered by at least one upgrade profile.
+ ),
+ },
{
term: "has-pro-management:
- )}
-
- >
- );
-};
-
-export default ReportView;
diff --git a/src/pages/dashboard/instances/ReportWidget/ReportWidget.module.scss b/src/pages/dashboard/instances/ReportWidget/ReportWidget.module.scss
deleted file mode 100644
index db4ab5a4f8..0000000000
--- a/src/pages/dashboard/instances/ReportWidget/ReportWidget.module.scss
+++ /dev/null
@@ -1,21 +0,0 @@
-@import "vanilla-framework/scss/settings_colors";
-@import "vanilla-framework/scss/settings_spacing";
-
-.header {
- display: flex;
- font-weight: 550;
- justify-content: space-between;
- margin-bottom: $spv--medium;
-}
-
-.lineContainer {
- background-color: $colors--theme--background-default;
- border: 1px solid $colors--theme--border-default;
- border-radius: 5px;
- margin-bottom: $spv--medium;
-}
-
-.line {
- background-color: $color-link;
- height: $spv--small;
-}
diff --git a/src/pages/dashboard/instances/ReportWidget/ReportWidget.test.tsx b/src/pages/dashboard/instances/ReportWidget/ReportWidget.test.tsx
deleted file mode 100644
index e37c5e8623..0000000000
--- a/src/pages/dashboard/instances/ReportWidget/ReportWidget.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import ReportWidget from "./ReportWidget";
-import { describe, expect, it } from "vitest";
-
-describe("ReportWidget", () => {
- const defaultProps = {
- currentCount: 50,
- negativeDescription: "Negative description",
- positiveDescription: "Positive description",
- title: "Test Title",
- totalCount: 100,
- };
-
- it("renders the title", () => {
- render();
- expect(screen.getByText("Test Title")).toBeInTheDocument();
- });
-
- it("renders the positive description", () => {
- render();
- expect(screen.getByText("Positive description")).toBeInTheDocument();
- });
-
- it("renders the negative description", () => {
- render();
- expect(screen.getByText("Negative description")).toBeInTheDocument();
- });
-
- it("calculates and displays the correct percentage", () => {
- render();
- expect(screen.getByText("50%")).toBeInTheDocument();
- });
-});
diff --git a/src/pages/dashboard/instances/ReportWidget/ReportWidget.tsx b/src/pages/dashboard/instances/ReportWidget/ReportWidget.tsx
deleted file mode 100644
index 72c015f20d..0000000000
--- a/src/pages/dashboard/instances/ReportWidget/ReportWidget.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import type { FC } from "react";
-import classes from "./ReportWidget.module.scss";
-
-interface ReportWidgetProps {
- readonly currentCount: number;
- readonly negativeDescription: string;
- readonly positiveDescription: string;
- readonly title: string;
- readonly totalCount: number;
-}
-
-const ReportWidget: FC = ({
- currentCount,
- negativeDescription,
- positiveDescription,
- title,
- totalCount,
-}) => {
- const percentage = ((100 * currentCount) / totalCount).toFixed();
-
- return (
- <>
-
- {title}
- {`${percentage}%`}
-
-
- {positiveDescription}
-
- {negativeDescription}
-
- >
- );
-};
-
-export default ReportWidget;
diff --git a/src/pages/dashboard/instances/ReportWidget/index.ts b/src/pages/dashboard/instances/ReportWidget/index.ts
deleted file mode 100644
index 9f3330efc1..0000000000
--- a/src/pages/dashboard/instances/ReportWidget/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./ReportWidget";
diff --git a/src/tests/mocks/features.ts b/src/tests/mocks/features.ts
index 936af19087..1eff910b8d 100644
--- a/src/tests/mocks/features.ts
+++ b/src/tests/mocks/features.ts
@@ -102,4 +102,15 @@ export const features: Feature[] = [
account: true,
},
},
+ {
+ name: "Instance Reports",
+ description:
+ "View a compliance and security report for a selection of instances from the instances list.",
+ key: "instance-reports",
+ database_key: 15,
+ enabled: true,
+ details: {
+ configuration: true,
+ },
+ },
];
diff --git a/src/tests/server/handlers/features.ts b/src/tests/server/handlers/features.ts
index 5c3d13c68e..687a93bacf 100644
--- a/src/tests/server/handlers/features.ts
+++ b/src/tests/server/handlers/features.ts
@@ -9,6 +9,15 @@ import { createEndpointStatusNetworkError } from "./_constants";
const matchesFeaturesPath = (endpointPath?: string) =>
!endpointPath || endpointPath.includes("features");
+// The reports side panel ships on by default in production, so the MSW mock
+// always reports `instance-reports` as enabled — even when the features
+// endpoint is otherwise emptied — so "View report" is available whenever
+// developing or testing against MSW. To test the disabled state, override the
+// features endpoint with `server.use(...)` returning the feature as disabled.
+const alwaysEnabledFeatures = features.filter(
+ (feature) => feature.key === "instance-reports",
+);
+
export default [
http.get(`${API_URL}features`, () => {
const endpointStatus = getEndpointStatus();
@@ -19,7 +28,7 @@ export default [
) {
return HttpResponse.json(
generatePaginatedResponse({
- data: [],
+ data: alwaysEnabledFeatures,
offset: 0,
limit: 20,
}),
diff --git a/src/tests/server/handlers/instance.ts b/src/tests/server/handlers/instance.ts
index 6f0afbd652..e5f5fcfd38 100644
--- a/src/tests/server/handlers/instance.ts
+++ b/src/tests/server/handlers/instance.ts
@@ -362,7 +362,13 @@ export default [
return HttpResponse.json(activities[0]);
}),
- http.get(`${API_URL}computers/:computerId`, async ({ request }) => {
+ http.get(`${API_URL}computers/:computerId`, async ({ params, request }) => {
+ // The real route only matches integer ids (`/computers/`),
+ // letting static paths like `computers/report` reach their own handlers.
+ if (!/^\d+$/.test(String(params.computerId))) {
+ return;
+ }
+
if (shouldApplyEndpointStatus("computers/:computerId")) {
const { status } = getEndpointStatus();
if (status === "error") {
diff --git a/src/tests/server/handlers/reports.ts b/src/tests/server/handlers/reports.ts
index 76e0f465d5..081b6607d9 100644
--- a/src/tests/server/handlers/reports.ts
+++ b/src/tests/server/handlers/reports.ts
@@ -1,54 +1,75 @@
-import { API_URL_OLD } from "@/constants";
+import { API_URL, API_URL_OLD } from "@/constants";
+import type { ComplianceReport } from "@/features/reports";
import { getEndpointStatus } from "@/tests/controllers/controller";
import { http, HttpResponse } from "msw";
+import { createEndpointStatusNetworkError } from "./_constants";
import { isAction, shouldApplyEndpointStatus } from "./_helpers";
-const usnTimeToFix = {
- "2": [],
- "14": [],
- "30": [],
- "60": [],
- pending: [],
+export const complianceReport: ComplianceReport = {
+ generated_at: "2026-06-11T10:38:00Z",
+ total: 11,
+ securely_patched: { count: 6, computer_ids: [1, 2, 3, 4, 5, 6] },
+ not_securely_patched: { count: 5, computer_ids: [7, 8, 9, 10, 16] },
+ covered_by_upgrade_profiles: { count: 1, computer_ids: [1] },
+ contacted_recently: { count: 0, computer_ids: [] },
+ usn_fixed_in: {
+ "2": { count: 4, computer_ids: [1, 2, 3, 4] },
+ "14": { count: 6, computer_ids: [1, 2, 3, 4, 5, 6] },
+ "30": { count: 8, computer_ids: [1, 2, 3, 4, 5, 6, 7, 8] },
+ "60": { count: 8, computer_ids: [1, 2, 3, 4, 5, 6, 7, 8] },
+ },
+ usn_pending_over_60_days: { count: 5, computer_ids: [7, 8, 9, 10, 16] },
};
-export default [
- http.get(API_URL_OLD, ({ request }) => {
- if (!isAction(request, "GetCSVComplianceData")) {
- return;
- }
+// A report for a selection the server accounted for but with nothing to show,
+// so the "empty" endpoint status can be simulated for this endpoint too.
+const emptyBucket = { count: 0, computer_ids: [] };
+export const emptyComplianceReport: ComplianceReport = {
+ generated_at: complianceReport.generated_at,
+ total: 0,
+ securely_patched: emptyBucket,
+ not_securely_patched: emptyBucket,
+ covered_by_upgrade_profiles: emptyBucket,
+ contacted_recently: emptyBucket,
+ usn_fixed_in: {
+ "2": emptyBucket,
+ "14": emptyBucket,
+ "30": emptyBucket,
+ "60": emptyBucket,
+ },
+ usn_pending_over_60_days: emptyBucket,
+};
- if (shouldApplyEndpointStatus("reports")) {
- const endpointStatus = getEndpointStatus();
+export default [
+ http.get(`${API_URL}computers/report`, () => {
+ if (shouldApplyEndpointStatus("computers/report")) {
+ const { status } = getEndpointStatus();
- if (endpointStatus.status === "empty") {
- return HttpResponse.json("");
+ if (status === "error") {
+ throw createEndpointStatusNetworkError();
}
- }
-
- return HttpResponse.json("name,status\ninstance-1,ok");
- }),
- http.get(API_URL_OLD, ({ request }) => {
- if (!isAction(request, "GetComputersNotUpgraded")) {
- return;
+ if (status === "empty") {
+ return HttpResponse.json(emptyComplianceReport);
+ }
}
- return HttpResponse.json([]);
+ return HttpResponse.json(complianceReport);
}),
-
http.get(API_URL_OLD, ({ request }) => {
- if (!isAction(request, "GetNotPingingComputers")) {
+ if (!isAction(request, "GetCSVComplianceData")) {
return;
}
- return HttpResponse.json([]);
- }),
+ const endpointStatus = getEndpointStatus();
- http.get(API_URL_OLD, ({ request }) => {
- if (!isAction(request, "GetUSNTimeToFix")) {
- return;
+ if (
+ endpointStatus.status === "empty" &&
+ (!endpointStatus.path || endpointStatus.path === "reports")
+ ) {
+ return HttpResponse.json("");
}
- return HttpResponse.json(usnTimeToFix);
+ return HttpResponse.json("name,status\ninstance-1,ok");
}),
];
diff --git a/src/types/FeatureKey.ts b/src/types/FeatureKey.ts
index 0e3cad041d..79998f4443 100644
--- a/src/types/FeatureKey.ts
+++ b/src/types/FeatureKey.ts
@@ -1,6 +1,7 @@
export type FeatureKey =
| "computer-soft-deletion"
| "employee-management"
+ | "instance-reports"
| "oidc-configuration"
| "script-profiles"
| "spa-dashboard"
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 392356083b..9df250111e 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -7,7 +7,6 @@ interface ImportMetaEnv {
readonly VITE_API_URL_DEB_ARCHIVE: string;
readonly VITE_ROOT_PATH: string;
readonly VITE_SELF_HOSTED_ENV: string | undefined;
- readonly VITE_REPORT_VIEW_ENABLED: string;
readonly VITE_DETAILED_UPGRADES_VIEW_ENABLED: string;
readonly VITE_MSW_ENABLED: string;
readonly VITE_MSW_ENDPOINTS_TO_INTERCEPT: string;
From 4c615d2b825ee218c29918b29380153fcec7cef2 Mon Sep 17 00:00:00 2001
From: Rubin Aga
Date: Tue, 9 Jun 2026 13:59:48 +0200
Subject: [PATCH 02/41] initial tsv exports
---
src/features/instances/api/index.ts | 6 +
.../api/instancesExportJobsShared.ts | 78 +++
.../api/useCancelInstancesExportJob.ts | 46 ++
.../api/useDiscardInstancesExportJob.ts | 38 ++
.../useDownloadInstancesExportJob.test.tsx | 126 ++++
.../api/useDownloadInstancesExportJob.ts | 84 +++
.../instances/api/useExportInstancesCsv.ts | 45 ++
.../api/useGetExportAnnotationFields.ts | 65 ++
.../api/useGetInstancesExportJobs.ts | 44 ++
.../InstancesExportDetailsPanel.module.scss | 7 +
.../InstancesExportDetailsPanel.test.tsx | 161 +++++
.../InstancesExportDetailsPanel.tsx | 284 +++++++++
.../ExportProgressBar.module.scss | 57 ++
.../ExportProgressBar.test.tsx | 63 ++
.../ExportProgressBar/ExportProgressBar.tsx | 66 +++
.../components/ExportProgressBar/index.ts | 1 +
.../InstancesExportDetailsPanel/index.ts | 1 +
.../InstancesExportForm.module.scss | 38 ++
.../InstancesExportForm.test.tsx | 295 +++++++++
.../InstancesExportForm.tsx | 271 +++++++++
...nstancesExportAnnotationFields.module.scss | 5 +
.../InstancesExportAnnotationFields.tsx | 78 +++
.../InstancesExportAnnotationFields/index.ts | 1 +
.../SortableFieldList.module.scss | 118 ++++
.../SortableFieldList/SortableFieldList.tsx | 558 ++++++++++++++++++
.../components/SortableFieldList/index.ts | 1 +
.../InstancesExportForm/constants.ts | 143 +++++
.../InstancesExportForm/helpers.test.ts | 30 +
.../components/InstancesExportForm/helpers.ts | 201 +++++++
.../components/InstancesExportForm/index.ts | 1 +
.../components/InstancesExportForm/types.ts | 18 +
.../InstancesExportNotification.module.scss | 3 +
.../InstancesExportNotification.test.tsx | 69 +++
.../InstancesExportNotification.tsx | 71 +++
.../InstancesExportNotification/index.ts | 1 +
.../InstancesHeader.module.scss | 7 +
.../InstancesHeader/InstancesHeader.test.tsx | 6 +-
.../InstancesHeader/InstancesHeader.tsx | 6 +-
.../InstancesPageActions.test.tsx | 230 ++++----
.../InstancesPageActions.tsx | 59 +-
src/features/instances/helpers.test.ts | 32 +
src/features/instances/helpers.ts | 139 +++++
src/features/instances/index.ts | 4 +
.../instances/types/InstancesExportJob.ts | 20 +
.../InstancesContainer.test.tsx | 2 +-
.../instances/InstancesPage/InstancesPage.tsx | 20 +-
.../instances/InstancesPage/helpers.test.ts | 24 +-
.../instances/InstancesPage/helpers.ts | 68 +--
src/styles/partials/icons.scss | 2 +
src/tests/mocks/instancesExport.ts | 41 ++
src/tests/render.tsx | 4 +-
src/tests/server/handlers/index.ts | 2 +
src/tests/server/handlers/instancesExport.ts | 104 ++++
53 files changed, 3652 insertions(+), 192 deletions(-)
create mode 100644 src/features/instances/api/instancesExportJobsShared.ts
create mode 100644 src/features/instances/api/useCancelInstancesExportJob.ts
create mode 100644 src/features/instances/api/useDiscardInstancesExportJob.ts
create mode 100644 src/features/instances/api/useDownloadInstancesExportJob.test.tsx
create mode 100644 src/features/instances/api/useDownloadInstancesExportJob.ts
create mode 100644 src/features/instances/api/useExportInstancesCsv.ts
create mode 100644 src/features/instances/api/useGetExportAnnotationFields.ts
create mode 100644 src/features/instances/api/useGetInstancesExportJobs.ts
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.module.scss
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.test.tsx
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.tsx
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.module.scss
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.test.tsx
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.tsx
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/index.ts
create mode 100644 src/features/instances/components/InstancesExportDetailsPanel/index.ts
create mode 100644 src/features/instances/components/InstancesExportForm/InstancesExportForm.module.scss
create mode 100644 src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx
create mode 100644 src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx
create mode 100644 src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.module.scss
create mode 100644 src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.tsx
create mode 100644 src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/index.ts
create mode 100644 src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.module.scss
create mode 100644 src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.tsx
create mode 100644 src/features/instances/components/InstancesExportForm/components/SortableFieldList/index.ts
create mode 100644 src/features/instances/components/InstancesExportForm/constants.ts
create mode 100644 src/features/instances/components/InstancesExportForm/helpers.test.ts
create mode 100644 src/features/instances/components/InstancesExportForm/helpers.ts
create mode 100644 src/features/instances/components/InstancesExportForm/index.ts
create mode 100644 src/features/instances/components/InstancesExportForm/types.ts
create mode 100644 src/features/instances/components/InstancesExportNotification/InstancesExportNotification.module.scss
create mode 100644 src/features/instances/components/InstancesExportNotification/InstancesExportNotification.test.tsx
create mode 100644 src/features/instances/components/InstancesExportNotification/InstancesExportNotification.tsx
create mode 100644 src/features/instances/components/InstancesExportNotification/index.ts
create mode 100644 src/features/instances/helpers.test.ts
create mode 100644 src/features/instances/types/InstancesExportJob.ts
create mode 100644 src/tests/mocks/instancesExport.ts
create mode 100644 src/tests/server/handlers/instancesExport.ts
diff --git a/src/features/instances/api/index.ts b/src/features/instances/api/index.ts
index e4edf417e3..305a1c66d5 100644
--- a/src/features/instances/api/index.ts
+++ b/src/features/instances/api/index.ts
@@ -2,14 +2,20 @@ export * from "./useAcceptPendingInstances";
export * from "./useAddTagsToInstances";
export * from "./useCreateDistributionUpgrades";
export * from "./useEditInstance";
+export * from "./useCancelInstancesExportJob";
+export * from "./useDiscardInstancesExportJob";
+export * from "./useDownloadInstancesExportJob";
+export * from "./useExportInstancesCsv";
export * from "./useGenerateRecoveryKey";
export * from "./useGetAvailabilityZones";
+export * from "./useGetInstancesExportJobs";
export * from "./useGetRecoveryKey";
export * from "./useGetInstance";
export * from "./useGetInstanceChildren";
export * from "./useGetInstances";
export * from "./useGetDistributionUpgradeTargets";
export * from "./useGetPendingInstances";
+export * from "./useGetExportAnnotationFields";
export * from "./useRejectPendingInstances";
export * from "./useRemoveInstancesFromLandscape";
export * from "./useRestartInstance";
diff --git a/src/features/instances/api/instancesExportJobsShared.ts b/src/features/instances/api/instancesExportJobsShared.ts
new file mode 100644
index 0000000000..1dc896c9cf
--- /dev/null
+++ b/src/features/instances/api/instancesExportJobsShared.ts
@@ -0,0 +1,78 @@
+import type { InstanceListParams } from "../helpers";
+import type { InstancesExportJob } from "../types/InstancesExportJob";
+import type { AxiosResponse, InternalAxiosRequestConfig } from "axios";
+
+export interface CreateInstancesExportJobParams extends InstanceListParams {
+ readonly name: string;
+ readonly selected_field_ids: string[];
+}
+
+export interface InstancesExportJobsResponse {
+ readonly count: number;
+ readonly results: InstancesExportJob[];
+}
+
+export const EXPORT_JOBS_QUERY_KEY = ["instances-export-jobs"];
+export const EXPORT_JOBS_POLL_INTERVAL_MS = 5000;
+const HTTP_STATUS_OK = 200;
+
+const EXPORT_JOB_STATUS_ORDER: Record = {
+ processing: 0,
+ completed: 1,
+ failed: 2,
+ canceled: 3,
+};
+
+const isVisibleExportJob = (job: InstancesExportJob) =>
+ job.status === "processing" ||
+ job.status === "completed" ||
+ job.status === "failed";
+
+export const hasProcessingExportJobs = (jobs: InstancesExportJob[]) =>
+ jobs.some((job) => job.status === "processing");
+
+export const getSortedExportJobs = (jobs: InstancesExportJob[]) =>
+ [...jobs]
+ .filter(isVisibleExportJob)
+ .sort((left, right) => {
+ if (left.status !== right.status) {
+ return EXPORT_JOB_STATUS_ORDER[left.status] -
+ EXPORT_JOB_STATUS_ORDER[right.status];
+ }
+
+ return (
+ new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()
+ );
+ });
+
+export const setExportJobsCache = (
+ current: InstancesExportJobsResponse | undefined,
+ jobs: InstancesExportJob[],
+): InstancesExportJobsResponse => {
+ const sortedJobs = getSortedExportJobs(jobs);
+
+ return {
+ ...(current ?? {}),
+ count: sortedJobs.length,
+ results: sortedJobs,
+ };
+};
+
+export const getExportJobsFromResponse = (
+ response: AxiosResponse | undefined,
+) => response?.data.results ?? [];
+
+export const setExportJobsResponseCache = (
+ current: AxiosResponse | undefined,
+ jobs: InstancesExportJob[],
+): AxiosResponse => ({
+ data: setExportJobsCache(current?.data, jobs),
+ status: current?.status ?? HTTP_STATUS_OK,
+ statusText: current?.statusText ?? "OK",
+ headers: current?.headers ?? {},
+ config:
+ current?.config ??
+ ({
+ headers: {},
+ } as InternalAxiosRequestConfig),
+});
diff --git a/src/features/instances/api/useCancelInstancesExportJob.ts b/src/features/instances/api/useCancelInstancesExportJob.ts
new file mode 100644
index 0000000000..2d3fe46ea9
--- /dev/null
+++ b/src/features/instances/api/useCancelInstancesExportJob.ts
@@ -0,0 +1,46 @@
+import useFetch from "@/hooks/useFetch";
+import type { ApiError } from "@/types/api/ApiError";
+import type { InstancesExportJob } from "../types/InstancesExportJob";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { AxiosError, AxiosResponse } from "axios";
+import {
+ EXPORT_JOBS_QUERY_KEY,
+ getExportJobsFromResponse,
+ setExportJobsResponseCache,
+ type InstancesExportJobsResponse,
+} from "./instancesExportJobsShared";
+
+export const useCancelInstancesExportJob = () => {
+ const authFetch = useFetch();
+ const queryClient = useQueryClient();
+
+ const { mutateAsync } = useMutation<
+ InstancesExportJob,
+ AxiosError,
+ string
+ >({
+ mutationFn: async (jobId) =>
+ (
+ await authFetch.post(
+ `computers/exports/${jobId}/cancel`,
+ )
+ ).data,
+ onSuccess: (_job, jobId) => {
+ queryClient.setQueryData<
+ AxiosResponse | undefined
+ >(
+ EXPORT_JOBS_QUERY_KEY,
+ (current) =>
+ setExportJobsResponseCache(
+ current,
+ getExportJobsFromResponse(current).filter((job) => job.id !== jobId),
+ ),
+ );
+ void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
+ },
+ });
+
+ return {
+ cancelInstancesExportJob: mutateAsync,
+ };
+};
diff --git a/src/features/instances/api/useDiscardInstancesExportJob.ts b/src/features/instances/api/useDiscardInstancesExportJob.ts
new file mode 100644
index 0000000000..be152c3b2e
--- /dev/null
+++ b/src/features/instances/api/useDiscardInstancesExportJob.ts
@@ -0,0 +1,38 @@
+import useFetch from "@/hooks/useFetch";
+import type { ApiError } from "@/types/api/ApiError";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { AxiosError, AxiosResponse } from "axios";
+import {
+ EXPORT_JOBS_QUERY_KEY,
+ getExportJobsFromResponse,
+ setExportJobsResponseCache,
+ type InstancesExportJobsResponse,
+} from "./instancesExportJobsShared";
+
+export const useDiscardInstancesExportJob = () => {
+ const authFetch = useFetch();
+ const queryClient = useQueryClient();
+
+ const { mutateAsync } = useMutation, string>({
+ mutationFn: async (jobId) => {
+ await authFetch.delete(`computers/exports/${jobId}`);
+ },
+ onSuccess: (_result, jobId) => {
+ queryClient.setQueryData<
+ AxiosResponse | undefined
+ >(
+ EXPORT_JOBS_QUERY_KEY,
+ (current) =>
+ setExportJobsResponseCache(
+ current,
+ getExportJobsFromResponse(current).filter((job) => job.id !== jobId),
+ ),
+ );
+ void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
+ },
+ });
+
+ return {
+ discardInstancesExportJob: mutateAsync,
+ };
+};
diff --git a/src/features/instances/api/useDownloadInstancesExportJob.test.tsx b/src/features/instances/api/useDownloadInstancesExportJob.test.tsx
new file mode 100644
index 0000000000..fbfc88b41f
--- /dev/null
+++ b/src/features/instances/api/useDownloadInstancesExportJob.test.tsx
@@ -0,0 +1,126 @@
+import { renderHook } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import server from "@/tests/server";
+import { renderHookWithProviders } from "@/tests/render";
+import { API_URL } from "@/constants";
+import type { InstancesExportJob } from "../types/InstancesExportJob";
+import { useDownloadInstancesExportJob } from "./useDownloadInstancesExportJob";
+import { http, HttpResponse } from "msw";
+import { setEndpointStatus } from "@/tests/controllers/controller";
+
+const job = {
+ id: "42",
+ filename: "instances-export-2026-06-08-120000.tsv",
+ name: "My export",
+ status: "completed",
+ progress: 100,
+ downloadReady: true,
+} as unknown as InstancesExportJob;
+
+const removeSaveFilePicker = () => {
+ delete (window as unknown as Record).showSaveFilePicker;
+};
+
+const renderDownloadHook = () =>
+ renderHook(() => useDownloadInstancesExportJob(), {
+ wrapper: renderHookWithProviders(),
+ }).result;
+
+describe("useDownloadInstancesExportJob", () => {
+ beforeEach(() => {
+ vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
+ vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ removeSaveFilePicker();
+ });
+
+ it("saves to file via showSaveFilePicker when the File System Access API is available", async () => {
+ const write = vi.fn().mockResolvedValue(undefined);
+ const close = vi.fn().mockResolvedValue(undefined);
+ const createWritable = vi.fn().mockResolvedValue({ write, close });
+ const showSaveFilePicker = vi.fn().mockResolvedValue({ createWritable });
+ (window as unknown as Record).showSaveFilePicker =
+ showSaveFilePicker;
+
+ const result = renderDownloadHook();
+ await result.current.downloadInstancesExportJob(job);
+
+ expect(showSaveFilePicker).toHaveBeenCalledWith({
+ suggestedName: job.filename,
+ });
+ expect(write).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+ expect(URL.createObjectURL).not.toHaveBeenCalled();
+ });
+
+ it("falls back to a blob download when the File System Access API is unavailable", async () => {
+ removeSaveFilePicker();
+
+ const result = renderDownloadHook();
+ await result.current.downloadInstancesExportJob(job);
+
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
+ });
+
+ it("rejects without downloading when the save dialog is cancelled", async () => {
+ const showSaveFilePicker = vi
+ .fn()
+ .mockRejectedValue(new DOMException("The user aborted", "AbortError"));
+ (window as unknown as Record).showSaveFilePicker =
+ showSaveFilePicker;
+
+ const result = renderDownloadHook();
+
+ await expect(
+ result.current.downloadInstancesExportJob(job),
+ ).rejects.toMatchObject({ name: "AbortError" });
+ expect(URL.createObjectURL).not.toHaveBeenCalled();
+ });
+
+ it("throws when the server request fails", async () => {
+ setEndpointStatus({
+ status: "error",
+ path: "computers/exports/:jobId/download",
+ });
+
+ const result = renderDownloadHook();
+
+ await expect(
+ result.current.downloadInstancesExportJob(job),
+ ).rejects.toThrow();
+ expect(URL.createObjectURL).not.toHaveBeenCalled();
+ });
+
+ it("uses the job filename as the suggested save name", async () => {
+ const showSaveFilePicker = vi.fn().mockResolvedValue({
+ createWritable: vi.fn().mockResolvedValue({
+ write: vi.fn().mockResolvedValue(undefined),
+ close: vi.fn().mockResolvedValue(undefined),
+ }),
+ });
+ (window as unknown as Record).showSaveFilePicker =
+ showSaveFilePicker;
+
+ server.use(
+ http.get(`${API_URL}computers/exports/:jobId/download`, () =>
+ new HttpResponse("data", {
+ headers: {
+ "Content-Disposition": `attachment; filename="${job.filename}"`,
+ "Content-Type": "text/tab-separated-values",
+ },
+ }),
+ ),
+ );
+
+ const result = renderDownloadHook();
+ await result.current.downloadInstancesExportJob(job);
+
+ expect(showSaveFilePicker).toHaveBeenCalledWith({
+ suggestedName: job.filename,
+ });
+ });
+});
diff --git a/src/features/instances/api/useDownloadInstancesExportJob.ts b/src/features/instances/api/useDownloadInstancesExportJob.ts
new file mode 100644
index 0000000000..4c35c03cb8
--- /dev/null
+++ b/src/features/instances/api/useDownloadInstancesExportJob.ts
@@ -0,0 +1,84 @@
+import { downloadInstancesCsv } from "../helpers";
+import type { InstancesExportJob } from "../types/InstancesExportJob";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import useFetch from "@/hooks/useFetch";
+import {
+ EXPORT_JOBS_QUERY_KEY,
+ getExportJobsFromResponse,
+ setExportJobsResponseCache,
+ type InstancesExportJobsResponse,
+} from "./instancesExportJobsShared";
+import type { AxiosResponse } from "axios";
+
+const DEFAULT_EXPORT_FILENAME = "instances-export.tsv";
+
+interface SaveFilePickerHandle {
+ createWritable: () => Promise<{
+ write: (data: Blob) => Promise;
+ close: () => Promise;
+ }>;
+}
+
+interface SaveFilePickerWindow {
+ showSaveFilePicker: (options?: {
+ suggestedName?: string;
+ }) => Promise;
+}
+
+const supportsNativeSave = (
+ candidate: typeof window,
+): candidate is typeof window & SaveFilePickerWindow =>
+ "showSaveFilePicker" in candidate;
+
+export const useDownloadInstancesExportJob = () => {
+ const authFetch = useFetch();
+ const queryClient = useQueryClient();
+
+ const { mutateAsync } = useMutation<
+ InstancesExportJob,
+ Error,
+ InstancesExportJob
+ >({
+ mutationFn: async (job) => {
+ const filename = job.filename || DEFAULT_EXPORT_FILENAME;
+
+ const response = await authFetch.get(
+ `computers/exports/${job.id}/download`,
+ { responseType: "blob" },
+ );
+ const blob = response.data;
+
+ if (supportsNativeSave(window)) {
+ const handle = await window.showSaveFilePicker({
+ suggestedName: filename,
+ });
+ const writable = await handle.createWritable();
+ await writable.write(blob);
+ await writable.close();
+ return job;
+ }
+
+ downloadInstancesCsv({ blob, filename });
+ return job;
+ },
+ onSuccess: (_data, job) => {
+ queryClient.setQueryData<
+ AxiosResponse | undefined
+ >(
+ EXPORT_JOBS_QUERY_KEY,
+ (current) =>
+ setExportJobsResponseCache(
+ current,
+ getExportJobsFromResponse(current).filter(
+ (item) => item.id !== job.id,
+ ),
+ ),
+ );
+ void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
+ },
+ });
+
+ return {
+ downloadInstancesExportJob: mutateAsync,
+ };
+};
diff --git a/src/features/instances/api/useExportInstancesCsv.ts b/src/features/instances/api/useExportInstancesCsv.ts
new file mode 100644
index 0000000000..5a34ff8224
--- /dev/null
+++ b/src/features/instances/api/useExportInstancesCsv.ts
@@ -0,0 +1,45 @@
+import useFetch from "@/hooks/useFetch";
+import type { ApiError } from "@/types/api/ApiError";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { AxiosError, AxiosResponse } from "axios";
+import type { InstancesExportJob } from "../types/InstancesExportJob";
+import {
+ EXPORT_JOBS_QUERY_KEY,
+ getExportJobsFromResponse,
+ setExportJobsResponseCache,
+ type CreateInstancesExportJobParams,
+ type InstancesExportJobsResponse,
+} from "./instancesExportJobsShared";
+
+export const useExportInstancesCsv = () => {
+ const authFetch = useFetch();
+ const queryClient = useQueryClient();
+
+ const { isPending, mutateAsync } = useMutation<
+ InstancesExportJob,
+ AxiosError,
+ CreateInstancesExportJobParams
+ >({
+ mutationFn: async (params) =>
+ (await authFetch.post("computers/export/csv", params))
+ .data,
+ onSuccess: (job) => {
+ queryClient.setQueryData<
+ AxiosResponse | undefined
+ >(
+ EXPORT_JOBS_QUERY_KEY,
+ (current) =>
+ setExportJobsResponseCache(current, [
+ job,
+ ...getExportJobsFromResponse(current),
+ ]),
+ );
+ void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
+ },
+ });
+
+ return {
+ exportInstancesCsv: mutateAsync,
+ isExportInstancesCsvLoading: isPending,
+ };
+};
diff --git a/src/features/instances/api/useGetExportAnnotationFields.ts b/src/features/instances/api/useGetExportAnnotationFields.ts
new file mode 100644
index 0000000000..338925cf15
--- /dev/null
+++ b/src/features/instances/api/useGetExportAnnotationFields.ts
@@ -0,0 +1,65 @@
+import useFetch from "@/hooks/useFetch";
+import type { ApiError } from "@/types/api/ApiError";
+import { useQuery } from "@tanstack/react-query";
+import type { AxiosError, AxiosResponse } from "axios";
+import type { InstanceListParams } from "../helpers";
+
+export interface ExportAnnotationFieldOption {
+ readonly id: string;
+ readonly label: string;
+ readonly annotation_key: string;
+}
+
+const buildAnnotationFieldParams = ({
+ params,
+}: {
+ params: InstanceListParams;
+}) => {
+ const searchParams = new URLSearchParams();
+
+ if (params.query) {
+ searchParams.set("query", params.query);
+ }
+ if (params.archived_only !== undefined) {
+ searchParams.set("archived_only", String(params.archived_only));
+ }
+ if (params.wsl_children !== undefined) {
+ searchParams.set("wsl_children", String(params.wsl_children));
+ }
+ if (params.wsl_parents !== undefined) {
+ searchParams.set("wsl_parents", String(params.wsl_parents));
+ }
+
+ return searchParams;
+};
+
+interface ExportAnnotationFieldResponse {
+ readonly results: ExportAnnotationFieldOption[];
+}
+
+export const useGetExportAnnotationFields = (params: InstanceListParams) => {
+ const authFetch = useFetch();
+
+ const { data: response, error, isError, isPending } = useQuery<
+ AxiosResponse,
+ AxiosError
+ >({
+ queryKey: ["instance-export-annotation-fields", params],
+ queryFn: ({ signal }) =>
+ authFetch.get(
+ "computers/export/annotations",
+ {
+ params,
+ paramsSerializer: () => buildAnnotationFieldParams({ params }).toString(),
+ signal,
+ },
+ ),
+ });
+
+ return {
+ exportAnnotationFields: response?.data.results ?? [],
+ exportAnnotationFieldsError: error,
+ isErrorExportAnnotationFields: isError,
+ isGettingExportAnnotationFields: isPending,
+ };
+};
diff --git a/src/features/instances/api/useGetInstancesExportJobs.ts b/src/features/instances/api/useGetInstancesExportJobs.ts
new file mode 100644
index 0000000000..a161e55ce4
--- /dev/null
+++ b/src/features/instances/api/useGetInstancesExportJobs.ts
@@ -0,0 +1,44 @@
+import useFetch from "@/hooks/useFetch";
+import type { ApiError } from "@/types/api/ApiError";
+import { useQuery } from "@tanstack/react-query";
+import type { AxiosError, AxiosResponse } from "axios";
+import {
+ EXPORT_JOBS_POLL_INTERVAL_MS,
+ EXPORT_JOBS_QUERY_KEY,
+ getExportJobsFromResponse,
+ getSortedExportJobs,
+ hasProcessingExportJobs,
+ type InstancesExportJobsResponse,
+} from "./instancesExportJobsShared";
+
+export const useGetInstancesExportJobs = () => {
+ const authFetch = useFetch();
+
+ const { data: response, dataUpdatedAt } = useQuery<
+ AxiosResponse,
+ AxiosError
+ >({
+ queryKey: EXPORT_JOBS_QUERY_KEY,
+ queryFn: async () =>
+ authFetch.get("computers/exports"),
+ refetchInterval: (query) =>
+ hasProcessingExportJobs(getExportJobsFromResponse(query.state.data))
+ ? EXPORT_JOBS_POLL_INTERVAL_MS
+ : false,
+ refetchIntervalInBackground: true,
+ });
+
+ const exportJobs = getSortedExportJobs(getExportJobsFromResponse(response));
+
+ return {
+ exportJobs,
+ // Timestamp (ms) of the last successful fetch — used as the anchor for the
+ // client-side ETA countdown between polls.
+ dataUpdatedAt,
+ processingExportJobsCount: exportJobs.filter(
+ (job) => job.status === "processing",
+ ).length,
+ readyExportJobsCount: exportJobs.filter((job) => job.status === "completed")
+ .length,
+ };
+};
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.module.scss b/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.module.scss
new file mode 100644
index 0000000000..9c4f63c51d
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.module.scss
@@ -0,0 +1,7 @@
+.table {
+ margin-top: 1rem;
+}
+
+.emptyState {
+ margin-top: 1rem;
+}
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.test.tsx b/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.test.tsx
new file mode 100644
index 0000000000..845cb6d7ef
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.test.tsx
@@ -0,0 +1,161 @@
+import server from "@/tests/server";
+import { renderWithProviders } from "@/tests/render";
+import { API_URL } from "@/constants";
+import {
+ completedExportJob,
+ failedExportJob,
+ processingExportJob,
+} from "@/tests/mocks/instancesExport";
+import { screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { http, HttpResponse } from "msw";
+import InstancesExportDetailsPanel from "./InstancesExportDetailsPanel";
+
+describe("InstancesExportDetailsPanel", () => {
+ it("renders an empty state when there are no export jobs", async () => {
+ renderWithProviders();
+
+ expect(
+ await screen.findByText(/no tsv exports in progress/i),
+ ).toBeInTheDocument();
+ });
+
+ it("renders tracked processing and completed exports", async () => {
+ server.use(
+ http.get(`${API_URL}computers/exports`, () =>
+ HttpResponse.json({
+ count: 2,
+ results: [processingExportJob, completedExportJob],
+ }),
+ ),
+ );
+
+ renderWithProviders();
+
+ expect(
+ await screen.findByText(processingExportJob.name),
+ ).toBeInTheDocument();
+ expect(screen.getByText(completedExportJob.name)).toBeInTheDocument();
+ expect(screen.getByRole("progressbar")).toBeInTheDocument();
+ expect(screen.getByText("35%")).toBeInTheDocument();
+ expect(screen.getByText("Estimating...")).toBeInTheDocument();
+ expect(screen.getByText(/^ready$/i)).toBeInTheDocument();
+ });
+
+ it("renders failed exports and hides canceled exports", async () => {
+ server.use(
+ http.get(`${API_URL}computers/exports`, () =>
+ HttpResponse.json({ count: 1, results: [failedExportJob] }),
+ ),
+ );
+
+ renderWithProviders();
+
+ expect(await screen.findByText(failedExportJob.name)).toBeInTheDocument();
+ expect(screen.getByText(/^failed$/i)).toBeInTheDocument();
+ expect(
+ screen.queryByText(/generating \(100%\)/i),
+ ).not.toBeInTheDocument();
+ });
+
+ it("discards a failed export and shows a confirmation notification", async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.get(`${API_URL}computers/exports`, () =>
+ HttpResponse.json({ count: 1, results: [failedExportJob] }),
+ ),
+ );
+
+ renderWithProviders();
+
+ await user.click(
+ await screen.findByRole("button", {
+ name: new RegExp(`actions for ${failedExportJob.name}`, "i"),
+ }),
+ );
+ await user.click(screen.getByRole("menuitem", { name: /discard/i }));
+
+ expect(
+ await screen.findByText(/tsv discarded/i),
+ ).toBeInTheDocument();
+ });
+
+ it("cancels an in-progress export and shows a confirmation notification", async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.get(`${API_URL}computers/exports`, () =>
+ HttpResponse.json({ count: 1, results: [processingExportJob] }),
+ ),
+ );
+
+ renderWithProviders();
+
+ await user.click(
+ await screen.findByRole("button", {
+ name: new RegExp(`actions for ${processingExportJob.name}`, "i"),
+ }),
+ );
+ await user.click(screen.getByRole("menuitem", { name: /cancel/i }));
+
+ expect(
+ await screen.findByText(/tsv generation cancelled/i),
+ ).toBeInTheDocument();
+ });
+
+ it("downloads a completed export and shows a confirmation notification", async () => {
+ const user = userEvent.setup();
+ const write = vi.fn().mockResolvedValue(undefined);
+ const close = vi.fn().mockResolvedValue(undefined);
+ const showSaveFilePicker = vi
+ .fn()
+ .mockResolvedValue({ createWritable: vi.fn().mockResolvedValue({ write, close }) });
+ (window as unknown as Record).showSaveFilePicker =
+ showSaveFilePicker;
+
+ server.use(
+ http.get(`${API_URL}computers/exports`, () =>
+ HttpResponse.json({ count: 1, results: [completedExportJob] }),
+ ),
+ );
+
+ renderWithProviders();
+
+ await user.click(
+ await screen.findByRole("button", {
+ name: new RegExp(`actions for ${completedExportJob.name}`, "i"),
+ }),
+ );
+ await user.click(screen.getByRole("menuitem", { name: /download/i }));
+
+ await waitFor(() => {
+ expect(write).toHaveBeenCalledTimes(1);
+ });
+ expect(
+ await screen.findByText(/tsv download started/i),
+ ).toBeInTheDocument();
+
+ delete (window as unknown as Record).showSaveFilePicker;
+ });
+
+ it("discards a completed export without downloading it", async () => {
+ const user = userEvent.setup();
+ server.use(
+ http.get(`${API_URL}computers/exports`, () =>
+ HttpResponse.json({ count: 1, results: [completedExportJob] }),
+ ),
+ );
+
+ renderWithProviders();
+
+ await user.click(
+ await screen.findByRole("button", {
+ name: new RegExp(`actions for ${completedExportJob.name}`, "i"),
+ }),
+ );
+ await user.click(screen.getByRole("menuitem", { name: /discard/i }));
+
+ expect(
+ await screen.findByText(/tsv discarded/i),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.tsx b/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.tsx
new file mode 100644
index 0000000000..8e4ecd792e
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.tsx
@@ -0,0 +1,284 @@
+import {
+ useCancelInstancesExportJob,
+ useDiscardInstancesExportJob,
+ useDownloadInstancesExportJob,
+ useGetInstancesExportJobs,
+} from "../../api";
+import ListActions, {
+ LIST_ACTIONS_COLUMN_PROPS,
+} from "@/components/layout/ListActions";
+import TruncatedCell from "@/components/layout/TruncatedCell";
+import { useExpandableRow } from "@/hooks/useExpandableRow";
+import useNotify from "@/hooks/useNotify";
+import { ModularTable, Notification } from "@canonical/react-components";
+import { useEffect, useMemo, useState, type FC } from "react";
+import type { CellProps, Column } from "react-table";
+import { createTablePropGetters } from "@/utils/table";
+import type { InstancesExportJob } from "../../types/InstancesExportJob";
+import ExportProgressBar from "./components/ExportProgressBar";
+import classes from "./InstancesExportDetailsPanel.module.scss";
+
+const MS_PER_SECOND = 1000;
+
+interface RowData extends Record {
+ readonly job: InstancesExportJob;
+ readonly title: string;
+ readonly name: string;
+ readonly statusLabel: string;
+ readonly secondsRemaining: number | null;
+ readonly instanceCount: number;
+ readonly attributes: string[];
+}
+
+const { getCellProps, getRowProps } = createTablePropGetters({
+ itemTypeName: "export row",
+ headerColumnId: "name",
+});
+
+const getExportJobStatusLabel = (job: InstancesExportJob) => {
+ switch (job.status) {
+ case "completed":
+ return "Ready";
+ case "failed":
+ return "Failed";
+ case "canceled":
+ return "Canceled";
+ case "processing":
+ default:
+ return `Generating (${job.progress}%)`;
+ }
+};
+
+const getExportJobStatusIcon = (job: InstancesExportJob): string | false => {
+ switch (job.status) {
+ case "completed":
+ return "status-succeeded-small";
+ case "failed":
+ return "status-failed-small";
+ case "canceled":
+ return "status-queued-small";
+ case "processing":
+ default:
+ // Processing rows render a progress bar instead of an icon + label.
+ return false;
+ }
+};
+
+const InstancesExportDetailsPanel: FC = () => {
+ const { notify } = useNotify();
+ const { expandedColumnId, expandedRowIndex, getTableRowsRef, handleExpand } =
+ useExpandableRow();
+ const { exportJobs, dataUpdatedAt, processingExportJobsCount } =
+ useGetInstancesExportJobs();
+ const { cancelInstancesExportJob } = useCancelInstancesExportJob();
+ const { discardInstancesExportJob } = useDiscardInstancesExportJob();
+ const { downloadInstancesExportJob } = useDownloadInstancesExportJob();
+
+ // Tick once a second so the ETA counts down between the 5s server polls.
+ // Anchored to dataUpdatedAt (last fetch) to stay correct without relying on
+ // server/client clock alignment. Only runs while something is processing.
+ const [now, setNow] = useState(() => Date.now());
+ useEffect(() => {
+ if (processingExportJobsCount === 0) {
+ return;
+ }
+ const intervalId = setInterval(() => {
+ setNow(Date.now());
+ }, MS_PER_SECOND);
+ return () => {
+ clearInterval(intervalId);
+ };
+ }, [processingExportJobsCount]);
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ Header: "Name",
+ accessor: "name",
+ },
+ {
+ Header: "Status",
+ accessor: "statusLabel",
+ Cell: ({ row }: CellProps) => {
+ const { job, statusLabel, secondsRemaining } = row.original;
+ if (job.status === "processing") {
+ return (
+
+ );
+ }
+ return statusLabel;
+ },
+ getCellIcon: ({ row }: CellProps) =>
+ getExportJobStatusIcon(row.original.job),
+ },
+ {
+ Header: "Attributes",
+ accessor: "attributes",
+ meta: {
+ ariaLabel: "Attributes",
+ isExpandable: true,
+ },
+ Cell: ({ row }: CellProps) => (
+ (
+
+ {attribute}
+
+ ))}
+ isExpanded={
+ row.index === expandedRowIndex &&
+ expandedColumnId === "attributes"
+ }
+ onExpand={() => {
+ handleExpand(row.index, "attributes");
+ }}
+ showCount
+ />
+ ),
+ },
+ {
+ Header: "Instances",
+ accessor: "instanceCount",
+ },
+ {
+ ...LIST_ACTIONS_COLUMN_PROPS,
+ accessor: "job.id",
+ Cell: ({ row }: CellProps) => {
+ const { job } = row.original;
+ const actions =
+ job.status === "completed"
+ ? [
+ {
+ icon: "begin-downloading",
+ label: "Download",
+ onClick: async () => {
+ try {
+ await downloadInstancesExportJob(job);
+ } catch (error) {
+ if (
+ error instanceof DOMException &&
+ error.name === "AbortError"
+ ) {
+ // The user cancelled the save dialog; nothing was downloaded.
+ return;
+ }
+ throw error;
+ }
+
+ notify.success({
+ title: "TSV download started",
+ message: `${job.name} has been downloaded and removed from the export list.`,
+ });
+ },
+ },
+ ]
+ : [];
+ const destructiveActions =
+ job.status === "processing"
+ ? [
+ {
+ icon: "close",
+ label: "Cancel",
+ onClick: async () => {
+ await cancelInstancesExportJob(job.id);
+ notify.info({
+ title: "TSV generation cancelled",
+ message: `${job.name} has been cancelled and removed from the export list.`,
+ });
+ },
+ },
+ ]
+ : [
+ {
+ icon: "delete",
+ label: "Discard",
+ onClick: async () => {
+ await discardInstancesExportJob(job.id);
+ notify.info({
+ title: "TSV discarded",
+ message: `${job.name} has been discarded and removed from the export list.`,
+ });
+ },
+ },
+ ];
+
+ return (
+
+ );
+ },
+ },
+ ],
+ [
+ cancelInstancesExportJob,
+ discardInstancesExportJob,
+ downloadInstancesExportJob,
+ expandedColumnId,
+ expandedRowIndex,
+ handleExpand,
+ notify,
+ ],
+ );
+
+ const data = useMemo(
+ () =>
+ exportJobs.map((job) => {
+ // Count the ETA down locally from the value captured at the last fetch.
+ const secondsRemaining =
+ job.estimatedSecondsRemaining == null
+ ? null
+ : Math.max(
+ 0,
+ job.estimatedSecondsRemaining -
+ (now - dataUpdatedAt) / MS_PER_SECOND,
+ );
+
+ return {
+ job,
+ title: job.name,
+ name: job.name,
+ statusLabel: getExportJobStatusLabel(job),
+ secondsRemaining,
+ instanceCount: job.instanceCount,
+ attributes: job.attributeLabels,
+ };
+ }),
+ [exportJobs, now, dataUpdatedAt],
+ );
+
+ return (
+ <>
+
+ Once you download a TSV, it is discarded and cannot be downloaded again.
+
+ {exportJobs.length ? (
+
+
+
+ ) : (
+
+ You have no TSV exports in progress or waiting to be downloaded.
+
+ )}
+ >
+ );
+};
+
+export default InstancesExportDetailsPanel;
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.module.scss b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.module.scss
new file mode 100644
index 0000000000..1d716d776f
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.module.scss
@@ -0,0 +1,57 @@
+@import "vanilla-framework/scss/settings_colors";
+@import "vanilla-framework/scss/settings_spacing";
+
+.wrapper {
+ align-items: center;
+ display: flex;
+ gap: $sph--small;
+ min-width: 16rem;
+ width: 100%;
+}
+
+// Track for the unfilled remainder: a light base with subtle diagonal hatching.
+.bar {
+ background-color: #f2f3f5;
+ background-image: repeating-linear-gradient(
+ 45deg,
+ transparent,
+ transparent 6px,
+ rgba(0, 0, 0, 0.05) 6px,
+ rgba(0, 0, 0, 0.05) 12px
+ );
+ border-radius: 4px;
+ flex: 1 1 auto;
+ height: 1.5rem;
+ overflow: hidden;
+ position: relative;
+}
+
+// Filled portion. Width is set inline from the progress percentage.
+.fill {
+ align-items: center;
+ background-color: $color-positive;
+ border-radius: 4px;
+ display: flex;
+ height: 100%;
+ justify-content: flex-end;
+ min-width: 2.5rem;
+ transition: width 0.3s ease;
+}
+
+// The percentage label, rendered as a lighter rounded "thumb" at the fill edge.
+.percentage {
+ background-color: rgba(255, 255, 255, 0.25);
+ border-radius: 3px;
+ color: #fff;
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1;
+ margin-right: 0.25rem;
+ padding: 0.2rem 0.4rem;
+}
+
+.eta {
+ color: $color-mid-dark;
+ font-size: 0.875rem;
+ white-space: nowrap;
+}
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.test.tsx b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.test.tsx
new file mode 100644
index 0000000000..c49e82ec31
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen } from "@testing-library/react";
+import ExportProgressBar, { formatSecondsRemaining } from "./ExportProgressBar";
+
+describe("ExportProgressBar", () => {
+ it("renders the percentage inside the bar and reflects it on the progressbar role", () => {
+ render();
+
+ expect(screen.getByText("35%")).toBeInTheDocument();
+ const bar = screen.getByRole("progressbar");
+ expect(bar).toHaveAttribute("aria-valuenow", "35");
+ expect(bar).toHaveAttribute("aria-valuemin", "0");
+ expect(bar).toHaveAttribute("aria-valuemax", "100");
+ });
+
+ it("clamps out-of-range progress values", () => {
+ const { rerender } = render(
+ ,
+ );
+ expect(screen.getByText("0%")).toBeInTheDocument();
+
+ rerender();
+ expect(screen.getByText("100%")).toBeInTheDocument();
+ });
+
+ it("shows 'Estimating...' when there is no estimate yet", () => {
+ render();
+ expect(screen.getByText("Estimating...")).toBeInTheDocument();
+ });
+
+ it("shows 'Almost done' for very small estimates", () => {
+ render();
+ expect(screen.getByText("Almost done")).toBeInTheDocument();
+ });
+
+ it("formats the remaining time to the right of the bar", () => {
+ render();
+ expect(screen.getByText("2m 30s left")).toBeInTheDocument();
+ });
+});
+
+const FORTY_FIVE_SECONDS = 45;
+const TWO_MINUTES = 120;
+const TWO_AND_A_HALF_MINUTES = 150;
+const NEGATIVE_DURATION = -30;
+
+describe("formatSecondsRemaining", () => {
+ it("formats sub-minute durations in seconds", () => {
+ expect(formatSecondsRemaining(0)).toBe("0s left");
+ expect(formatSecondsRemaining(FORTY_FIVE_SECONDS)).toBe("45s left");
+ });
+
+ it("formats whole minutes without trailing seconds", () => {
+ expect(formatSecondsRemaining(TWO_MINUTES)).toBe("2m left");
+ });
+
+ it("formats minutes with remaining seconds", () => {
+ expect(formatSecondsRemaining(TWO_AND_A_HALF_MINUTES)).toBe("2m 30s left");
+ });
+
+ it("never returns a negative duration", () => {
+ expect(formatSecondsRemaining(NEGATIVE_DURATION)).toBe("0s left");
+ });
+});
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.tsx b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.tsx
new file mode 100644
index 0000000000..23d0c13609
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.tsx
@@ -0,0 +1,66 @@
+import type { FC } from "react";
+import classes from "./ExportProgressBar.module.scss";
+
+const SECONDS_PER_MINUTE = 60;
+// Below this, a precise countdown reads as noise — show "Almost done" instead.
+const ALMOST_DONE_THRESHOLD_SECONDS = 5;
+const MAX_PROGRESS = 100;
+
+export const formatSecondsRemaining = (seconds: number): string => {
+ const safe = Math.max(0, Math.round(seconds));
+
+ if (safe < SECONDS_PER_MINUTE) {
+ return `${safe}s left`;
+ }
+
+ const minutes = Math.floor(safe / SECONDS_PER_MINUTE);
+ const remainderSeconds = safe % SECONDS_PER_MINUTE;
+
+ return remainderSeconds
+ ? `${minutes}m ${remainderSeconds}s left`
+ : `${minutes}m left`;
+};
+
+const getEtaLabel = (secondsRemaining: number | null): string => {
+ if (secondsRemaining === null) {
+ return "Estimating...";
+ }
+
+ if (secondsRemaining <= ALMOST_DONE_THRESHOLD_SECONDS) {
+ return "Almost done";
+ }
+
+ return formatSecondsRemaining(secondsRemaining);
+};
+
+interface ExportProgressBarProps {
+ readonly progress: number;
+ readonly secondsRemaining: number | null;
+}
+
+const ExportProgressBar: FC = ({
+ progress,
+ secondsRemaining,
+}) => {
+ const clampedProgress = Math.min(MAX_PROGRESS, Math.max(0, Math.round(progress)));
+
+ return (
+
+
+
+ {clampedProgress}%
+
+
+
{getEtaLabel(secondsRemaining)}
+
+ );
+};
+
+export default ExportProgressBar;
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/index.ts b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/index.ts
new file mode 100644
index 0000000000..d5f2ddaf0a
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/index.ts
@@ -0,0 +1 @@
+export { default } from "./ExportProgressBar";
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/index.ts b/src/features/instances/components/InstancesExportDetailsPanel/index.ts
new file mode 100644
index 0000000000..20d402ae51
--- /dev/null
+++ b/src/features/instances/components/InstancesExportDetailsPanel/index.ts
@@ -0,0 +1 @@
+export { default } from "./InstancesExportDetailsPanel";
diff --git a/src/features/instances/components/InstancesExportForm/InstancesExportForm.module.scss b/src/features/instances/components/InstancesExportForm/InstancesExportForm.module.scss
new file mode 100644
index 0000000000..77b9e12dfd
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/InstancesExportForm.module.scss
@@ -0,0 +1,38 @@
+@import "vanilla-framework/scss/settings_colors";
+@import "vanilla-framework/scss/settings_spacing";
+
+.description {
+ margin-bottom: $spv--large;
+}
+
+.stepOneFields {
+ display: grid;
+ gap: $spv--medium;
+ margin-bottom: $spv--large;
+}
+
+.optionList {
+ display: grid;
+ gap: $sph--small;
+}
+
+.emptyState {
+ color: $colors--theme--text-muted;
+ margin-top: $spv--small;
+}
+
+:global(.p-accordion__tab) {
+ padding-right: $sph--x-large !important;
+}
+
+:global(.p-accordion__tab)::before {
+ margin-right: 0 !important;
+ position: absolute !important;
+ right: 1.5rem !important;
+ top: 50% !important;
+ transform: translateY(-50%) !important;
+}
+
+:global(.p-accordion__tab[aria-expanded="false"])::before {
+ transform: translateY(-50%) rotate(-90deg) !important;
+}
diff --git a/src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx b/src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx
new file mode 100644
index 0000000000..97518966b3
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx
@@ -0,0 +1,295 @@
+import server from "@/tests/server";
+import useSidePanel from "@/hooks/useSidePanel";
+import useNotify from "@/hooks/useNotify";
+import { API_URL } from "@/constants";
+import { renderWithProviders } from "@/tests/render";
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { http, HttpResponse } from "msw";
+import InstancesExportForm from "./InstancesExportForm";
+
+const closeSidePanel = vi.fn();
+const notify = {
+ clear: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ notification: null,
+};
+
+vi.mock("@/hooks/useSidePanel");
+vi.mock("@/hooks/useNotify");
+
+describe("InstancesExportForm", () => {
+ beforeEach(() => {
+ closeSidePanel.mockReset();
+ vi.mocked(useSidePanel, { partial: true }).mockReturnValue({
+ closeSidePanel,
+ });
+ vi.mocked(useNotify).mockReturnValue({
+ notify,
+ sidePanel: {
+ open: false,
+ setOpen: vi.fn(),
+ },
+ });
+ notify.info.mockReset();
+ notify.success.mockReset();
+ notify.error.mockReset();
+ notify.clear.mockReset();
+ });
+
+ it("shows step 1 with attribute groups in accordions", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(
+ screen.getByText(/select the attributes you want to include/i),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Export name")).toBeInTheDocument();
+ expect(screen.getByLabelText("Search attributes")).toBeInTheDocument();
+ expect(
+ screen.getByRole("tab", { name: /primary identity/i }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("tab", {
+ name: /granular metadata & deep diagnostics/i,
+ }),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Annotations")).toBeInTheDocument();
+ });
+
+ it("disables Next button when no fields are selected", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: "Next" })).toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
+
+ expect(screen.getByLabelText("Export name")).toHaveValue("");
+ });
+
+ it("filters attributes in step 1", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Search attributes"), "host");
+
+ expect(screen.getByLabelText("Hostname")).toBeInTheDocument();
+ expect(screen.queryByLabelText("Instance name")).not.toBeInTheDocument();
+ });
+
+ it("does not match parent group titles when filtering attributes", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Search attributes"), "primary");
+
+ expect(
+ screen.getByText("No attributes match your search."),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("tab", { name: /primary identity/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("shows attribute validation with error styling", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Export name"), "Weekly export");
+ await user.click(screen.getByLabelText("Instance name"));
+ await user.click(screen.getByLabelText("Instance name"));
+
+ expect(screen.getByText("Select at least one attribute")).toHaveClass(
+ "p-form-validation__message",
+ );
+ });
+
+ it("moves to step 2 showing reorder table when Next is clicked", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Export name"), "Weekly export");
+ await user.click(screen.getByLabelText("Instance name"));
+ await user.click(screen.getByRole("button", { name: "Next" }));
+
+ expect(
+ screen.getByText(/review and reorder the columns/i),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Order for Instance name")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
+ });
+
+ it("allows going back from step 2 to step 1", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Export name"), "Weekly export");
+ await user.click(screen.getByLabelText("Instance name"));
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await user.click(screen.getByRole("button", { name: "Back" }));
+
+ expect(
+ screen.getByRole("button", { name: "Next" }),
+ ).toBeInTheDocument();
+ });
+
+ it("keeps the order input focused while typing", async () => {
+ const user = userEvent.setup();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Export name"), "Weekly export");
+ await user.click(screen.getByLabelText("Instance name"));
+ await user.click(screen.getByLabelText("Hostname"));
+ await user.click(screen.getByRole("button", { name: "Next" }));
+
+ const orderInput = screen.getByLabelText("Order for Hostname");
+
+ await user.click(orderInput);
+ await user.keyboard("{Backspace}1");
+
+ expect(orderInput).toHaveFocus();
+ expect(orderInput).toHaveValue(1);
+ });
+
+ it("queues an export, closes the side panel, and shows a toast", async () => {
+ const user = userEvent.setup();
+ let capturedBody: unknown = null;
+ server.use(
+ http.post(`${API_URL}computers/export/csv`, async ({ request }) => {
+ capturedBody = await request.json();
+ return HttpResponse.json(
+ {
+ id: "job-1",
+ name: "Weekly export",
+ filename: "instances-export.tsv",
+ instanceCount: 8,
+ attributeLabels: ["Instance name"],
+ selectedFieldIds: ["title"],
+ createdAt: "2026-06-03T12:10:00.000Z",
+ status: "processing",
+ progress: 0,
+ downloadReady: false,
+ },
+ { status: 201 },
+ );
+ }),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.type(screen.getByLabelText("Export name"), "Weekly export");
+ await user.click(screen.getByLabelText("Instance name"));
+ await user.click(screen.getByLabelText("Annotations"));
+ await user.click(screen.getByRole("button", { name: "Next" }));
+ await user.click(screen.getByRole("button", { name: "Generate TSV" }));
+
+ expect(closeSidePanel).toHaveBeenCalled();
+ expect(notify.info).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "TSV export in progress" }),
+ );
+ expect(capturedBody).toMatchObject({
+ name: "Weekly export",
+ query: "name:prod",
+ selected_field_ids: ["title", "annotations"],
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx b/src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx
new file mode 100644
index 0000000000..19021e22a7
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx
@@ -0,0 +1,271 @@
+import SidePanelFormButtons from "@/components/form/SidePanelFormButtons";
+import useDebug from "@/hooks/useDebug";
+import useNotify from "@/hooks/useNotify";
+import useSidePanel from "@/hooks/useSidePanel";
+import { getFormikError } from "@/utils/formikErrors";
+import {
+ Accordion,
+ CheckboxInput,
+ Form,
+ Input,
+} from "@canonical/react-components";
+import { useFormik } from "formik";
+import {
+ useCallback,
+ useMemo,
+ useState,
+ type FC,
+} from "react";
+import { useExportInstancesCsv } from "../../api/useExportInstancesCsv";
+import type { InstanceListParams } from "../../helpers";
+import classes from "./InstancesExportForm.module.scss";
+import { INITIAL_VALUES, VALIDATION_SCHEMA } from "./constants";
+import { EXPORT_FIELD_GROUPS } from "./constants";
+import { buildExportQuery } from "./helpers";
+import type {
+ ExportField,
+ InstancesExportFormValues,
+ StepIndex,
+} from "./types";
+import classNames from "classnames";
+import SortableFieldList from "./components/SortableFieldList";
+
+interface InstancesExportFormProps {
+ readonly exportParams: InstanceListParams;
+ readonly instanceCount: number | undefined;
+ readonly selectedInstanceCount?: number;
+ readonly selectedInstanceIds?: number[];
+}
+
+const InstancesExportForm: FC = ({
+ exportParams,
+ instanceCount: _instanceCount,
+ selectedInstanceCount: _selectedInstanceCount,
+ selectedInstanceIds,
+}) => {
+ const { closeSidePanel } = useSidePanel();
+ const { notify } = useNotify();
+ const debug = useDebug();
+ const { exportInstancesCsv, isExportInstancesCsvLoading } =
+ useExportInstancesCsv();
+ const [step, setStep] = useState(0);
+ const [attributeSearch, setAttributeSearch] = useState("");
+ const [orderedFields, setOrderedFields] = useState([]);
+
+ const formik = useFormik({
+ initialValues: INITIAL_VALUES,
+ validationSchema: VALIDATION_SCHEMA,
+ onSubmit: async (values) => {
+ const selectedFields = EXPORT_FIELD_GROUPS.flatMap(
+ (group) => group.fields,
+ ).filter((field) => values.selectedFieldIds.includes(field.id));
+
+ if (step === 0) {
+ setOrderedFields(selectedFields);
+ setStep(1);
+ return;
+ }
+
+ const fieldsToExport = orderedFields.length
+ ? orderedFields
+ : selectedFields;
+ const query = buildExportQuery({
+ query: exportParams.query,
+ selectedInstanceIds,
+ });
+
+ try {
+ await exportInstancesCsv({
+ name: values.name.trim(),
+ query,
+ archived_only: exportParams.archived_only,
+ wsl_children: exportParams.wsl_children,
+ wsl_parents: exportParams.wsl_parents,
+ selected_field_ids: fieldsToExport.map((field) => field.id),
+ });
+
+ closeSidePanel();
+ notify.info({
+ title: "TSV export in progress",
+ message: `Your instances export "${values.name.trim()}"${exportParams.query ? ` for "${exportParams.query}"` : ""} is being generated. You can track it from the instances page.`,
+ });
+ } catch (error) {
+ debug(error);
+ }
+ },
+ });
+
+ const toggleField = useCallback(
+ (fieldId: string) => {
+ const nextSelectedFieldIds = formik.values.selectedFieldIds.includes(
+ fieldId,
+ )
+ ? formik.values.selectedFieldIds.filter((id) => id !== fieldId)
+ : [...formik.values.selectedFieldIds, fieldId];
+
+ void formik.setFieldTouched("selectedFieldIds", true, false);
+ void formik.setFieldValue("selectedFieldIds", nextSelectedFieldIds);
+ },
+ [formik],
+ );
+
+ const toggleGroupSelect = useCallback(
+ (groupFields: readonly ExportField[]) => {
+ const groupIds = groupFields.map((field) => field.id);
+ const allSelected = groupIds.every((id) =>
+ formik.values.selectedFieldIds.includes(id),
+ );
+
+ const nextSelectedFieldIds = allSelected
+ ? formik.values.selectedFieldIds.filter((id) => !groupIds.includes(id))
+ : [...new Set([...formik.values.selectedFieldIds, ...groupIds])];
+
+ void formik.setFieldTouched("selectedFieldIds", true, false);
+ void formik.setFieldValue("selectedFieldIds", nextSelectedFieldIds);
+ },
+ [formik],
+ );
+
+ const filteredFieldGroups = useMemo(() => {
+ const normalizedSearch = attributeSearch.trim().toLowerCase();
+
+ if (!normalizedSearch) {
+ return EXPORT_FIELD_GROUPS;
+ }
+
+ return EXPORT_FIELD_GROUPS.flatMap((group) => {
+ const matchingFields = group.fields.filter((field) =>
+ field.label.toLowerCase().includes(normalizedSearch),
+ );
+
+ if (!matchingFields.length) {
+ return [];
+ }
+
+ return [{ ...group, fields: matchingFields }];
+ });
+ }, [attributeSearch]);
+
+ const accordionSections = filteredFieldGroups.map((group) => {
+ const groupIds = group.fields.map((field) => field.id);
+ const allSelected = groupIds.every((id) =>
+ formik.values.selectedFieldIds.includes(id),
+ );
+ const someSelected =
+ !allSelected &&
+ groupIds.some((id) => formik.values.selectedFieldIds.includes(id));
+
+ return {
+ key: group.key,
+ title: (
+ {
+ toggleGroupSelect(group.fields);
+ }}
+ />
+ ),
+ content: (
+
+ {group.fields.map((field) => (
+ {
+ toggleField(field.id);
+ }}
+ />
+ ))}
+
+ ),
+ };
+ });
+
+ const selectedFieldIdsError = getFormikError(formik, "selectedFieldIds");
+
+ const stepContent =
+ step === 0 ? (
+ <>
+
+
+ {
+ setAttributeSearch(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ }
+ }}
+ />
+
+
+ {!!selectedFieldIdsError && (
+
+ {selectedFieldIdsError}
+
+ )}
+ {filteredFieldGroups.length ? (
+
+ ) : (
+
+ No attributes match your search.
+
+ )}
+
+ >
+ ) : (
+
+ );
+
+ return (
+
+ );
+};
+
+export default InstancesExportForm;
diff --git a/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.module.scss b/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.module.scss
new file mode 100644
index 0000000000..808a93d9a9
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.module.scss
@@ -0,0 +1,5 @@
+@import "vanilla-framework/scss/settings_spacing";
+
+.helperText {
+ margin-top: $spv--small;
+}
diff --git a/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.tsx b/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.tsx
new file mode 100644
index 0000000000..4ddd1fb827
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/InstancesExportAnnotationFields.tsx
@@ -0,0 +1,78 @@
+import { TableFilter } from "@/components/filter";
+import LoadingState from "@/components/layout/LoadingState";
+import { useGetExportAnnotationFields } from "../../../../api";
+import type { FC } from "react";
+import { useState } from "react";
+import type { InstanceListParams } from "../../../../helpers";
+import classes from "./InstancesExportAnnotationFields.module.scss";
+
+interface InstancesExportAnnotationFieldsProps {
+ readonly exportParams: InstanceListParams;
+ readonly selectedAnnotationFieldIds: string[];
+ readonly onSelectedAnnotationFieldIdsChange: (
+ nextAnnotationFieldIds: string[],
+ ) => void;
+}
+
+const InstancesExportAnnotationFields: FC<
+ InstancesExportAnnotationFieldsProps
+> = ({
+ exportParams,
+ selectedAnnotationFieldIds,
+ onSelectedAnnotationFieldIdsChange,
+}) => {
+ const [searchText, setSearchText] = useState("");
+ const {
+ exportAnnotationFields,
+ isErrorExportAnnotationFields,
+ isGettingExportAnnotationFields,
+ } = useGetExportAnnotationFields(exportParams);
+
+ const filteredAnnotationFields = exportAnnotationFields.filter(({ label }) =>
+ label.toLowerCase().includes(searchText.trim().toLowerCase()),
+ );
+
+ if (isGettingExportAnnotationFields && exportAnnotationFields.length === 0) {
+ return (
+
+ Loading available annotation columns…
+
+ );
+ }
+
+ if (isErrorExportAnnotationFields) {
+ return (
+
+ We couldn’t load annotation columns. You can still export the built-in
+ instance attributes.
+
+ );
+ }
+
+ if (exportAnnotationFields.length === 0) {
+ return (
+
+ No annotations were found for the current filtered instances.
+
+ );
+ }
+
+ return (
+ ({
+ label: field.label,
+ value: field.id,
+ }))}
+ selectedItems={selectedAnnotationFieldIds}
+ onItemsSelect={onSelectedAnnotationFieldIdsChange}
+ onSearch={setSearchText}
+ showSelectedItemCount
+ />
+ );
+};
+
+export default InstancesExportAnnotationFields;
diff --git a/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/index.ts b/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/index.ts
new file mode 100644
index 0000000000..1fc65c0588
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/components/InstancesExportAnnotationFields/index.ts
@@ -0,0 +1 @@
+export { default } from "./InstancesExportAnnotationFields";
diff --git a/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.module.scss b/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.module.scss
new file mode 100644
index 0000000000..050b5a7bbe
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.module.scss
@@ -0,0 +1,118 @@
+@import "vanilla-framework/scss/settings_colors";
+@import "vanilla-framework/scss/settings_spacing";
+
+.selectedColumns {
+ padding-top: $spv--small;
+}
+
+.selectedColumnsIntro {
+ margin-bottom: $spv--medium;
+}
+
+.nameCell {
+ align-items: center;
+ display: flex;
+ gap: $sph--small;
+}
+
+.dragHandle {
+ cursor: grab;
+ flex-shrink: 0;
+}
+
+.orderColumn {
+ text-align: right;
+ white-space: nowrap;
+ width: 12rem;
+}
+
+.orderControls {
+ align-items: center;
+ display: flex;
+ justify-content: flex-end;
+ gap: $sph--small;
+}
+
+.orderInput {
+ width: 3.5rem;
+ text-align: center;
+ min-width: 0;
+}
+
+.orderInputWrapper {
+ margin-bottom: 0 !important;
+}
+
+.dragging {
+ opacity: 0.6;
+
+ > :global(td) {
+ background-color: rgba(0, 102, 204, 0.06);
+ box-shadow:
+ inset 0 2px 0 0 #0066cc,
+ inset 0 -2px 0 0 #0066cc;
+ }
+}
+
+// Smooth opacity transitions when dragging starts/ends or rows are dimmed.
+.selectedColumns :global(tr) {
+ transition: opacity 0.15s ease;
+}
+
+// Smooth the blue outline appearing/disappearing on the dragged row's cells.
+// background-color is intentionally excluded — including it conflicts with the
+// flashBlue @keyframes animation on .justMoved > td, causing a double-flash.
+.selectedColumns :global(td) {
+ transition: box-shadow 0.15s ease;
+}
+
+// Fully-opaque card overlay that follows the cursor during drag.
+// Anchored at the top-left; its `transform` is set imperatively per rAF frame
+// (see positionOverlay) to follow the cursor without re-rendering React.
+// Rendered via React portal on document.body to escape any parent transform/overflow.
+.dragOverlay {
+ align-items: center;
+ background: #fff;
+ border: 2px solid #0066cc;
+ border-radius: 4px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
+ display: inline-flex;
+ font-size: 0.875rem;
+ gap: $sph--small;
+ left: 0;
+ padding: $spv--small $sph--large;
+ pointer-events: none;
+ position: fixed;
+ top: 0;
+ white-space: nowrap;
+ z-index: 9999;
+}
+
+.dragOverlayBadge {
+ background: #f0f6ff;
+ border: 1px solid #b3d1f5;
+ border-radius: 2px;
+ color: #0066cc;
+ font-size: 0.75rem;
+ font-weight: 600;
+ min-width: 1.25rem;
+ padding: 0 $sph--x-small;
+ text-align: center;
+}
+
+// Brief blue flash on cells after a row is moved via arrows or number input.
+@keyframes flashBlue {
+ 0% {
+ background-color: rgba(0, 102, 204, 0.2);
+ }
+ 30% {
+ background-color: rgba(0, 102, 204, 0.2);
+ }
+ 100% {
+ background-color: transparent;
+ }
+}
+
+.justMoved > :global(td) {
+ animation: flashBlue 1.5s ease-out forwards;
+}
diff --git a/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.tsx b/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.tsx
new file mode 100644
index 0000000000..90696f1272
--- /dev/null
+++ b/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.tsx
@@ -0,0 +1,558 @@
+import { Button, Icon, Input, ModularTable } from "@canonical/react-components";
+import classNames from "classnames";
+import {
+ useCallback,
+ useEffect,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type FC,
+ type HTMLProps,
+} from "react";
+import { createPortal } from "react-dom";
+import type { CellProps, Column, Row, TableRowProps } from "react-table";
+import classes from "./SortableFieldList.module.scss";
+import type { ExportField } from "../../types";
+
+interface ReorderRowData extends Record {
+ index: number;
+ fieldId: string;
+ label: string;
+ currentOrder: string;
+}
+
+// Horizontal gap between the cursor and the drag overlay card, in pixels.
+const OVERLAY_CURSOR_OFFSET_PX = 12;
+const JUST_MOVED_HIGHLIGHT_MS = 1500;
+const FLIP_TRANSITION_CLEANUP_MS = 300;
+
+interface SortableFieldListProps {
+ readonly fields: ExportField[];
+ readonly onOrderChange: (fields: ExportField[]) => void;
+}
+
+const SortableFieldList: FC = ({
+ fields,
+ onOrderChange,
+}) => {
+ const [orderedFields, setOrderedFields] = useState(
+ () => fields,
+ );
+ const [draggingFieldId, setDraggingFieldId] = useState(null);
+ const originalOrderRef = useRef([]);
+ const dropSucceededRef = useRef(false);
+ const [orderDrafts, setOrderDrafts] = useState>({});
+ const [justMovedFieldId, setJustMovedFieldId] = useState(null);
+ const justMovedTimerRef = useRef | null>(null);
+ const rowRefsMap = useRef
+ }
+ />
+ );
+ }
+
+ return (
+ <>
+
+ }
+ />
+ {isLoading ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+ >
+ );
+};
+
+export default ExportsContainer;
diff --git a/src/features/exports/components/ExportsContainer/constants.ts b/src/features/exports/components/ExportsContainer/constants.ts
new file mode 100644
index 0000000000..bc7075af9d
--- /dev/null
+++ b/src/features/exports/components/ExportsContainer/constants.ts
@@ -0,0 +1,5 @@
+export const EXPORT_TYPE_OPTIONS = [
+ { label: "All", value: "" },
+ { label: "Instances", value: "instance" },
+ { label: "Activities", value: "activity" },
+];
diff --git a/src/features/exports/components/ExportsContainer/index.ts b/src/features/exports/components/ExportsContainer/index.ts
new file mode 100644
index 0000000000..ff6b5fe379
--- /dev/null
+++ b/src/features/exports/components/ExportsContainer/index.ts
@@ -0,0 +1 @@
+export { default } from "./ExportsContainer";
diff --git a/src/features/exports/components/ExportsList/ExportsList.tsx b/src/features/exports/components/ExportsList/ExportsList.tsx
new file mode 100644
index 0000000000..ab477e4db3
--- /dev/null
+++ b/src/features/exports/components/ExportsList/ExportsList.tsx
@@ -0,0 +1,234 @@
+import { LIST_ACTIONS_COLUMN_PROPS } from "@/components/layout/ListActions";
+import ListActions from "@/components/layout/ListActions";
+import ResponsiveTable from "@/components/layout/ResponsiveTable";
+import { DISPLAY_DATE_TIME_FORMAT } from "@/constants";
+import useNotify from "@/hooks/useNotify";
+import usePageParams from "@/hooks/usePageParams";
+import { Button, ConfirmationModal } from "@canonical/react-components";
+import moment from "moment";
+import type { FC } from "react";
+import { useMemo, useState } from "react";
+import type { CellProps, Column } from "react-table";
+import ExportProgressBar from "../ExportProgressBar";
+import {
+ getStatusIcon,
+ getStatusLabel,
+ getTypeLabel,
+} from "../../api/exportJobsShared";
+import { useCancelExportJob } from "../../api/useCancelExportJob";
+import { useDiscardExportJob } from "../../api/useDiscardExportJob";
+import { useDownloadExportJob } from "../../api/useDownloadExportJob";
+import type { ExportJob } from "../../types/ExportJob";
+import type { ExportRowData } from "./types";
+import useDebug from "@/hooks/useDebug";
+
+interface ExportsListProps {
+ readonly exportJobs: ExportJob[];
+}
+
+const ExportsList: FC = ({ exportJobs }) => {
+ const { notify } = useNotify();
+ const debug = useDebug();
+ const { createPageParamsSetter } = usePageParams();
+ const { cancelExportJob: onCancel } = useCancelExportJob();
+ const { discardExportJob: onDiscard } = useDiscardExportJob();
+ const { downloadExportJob: onDownload } = useDownloadExportJob();
+ const [jobToDiscard, setJobToDiscard] = useState(null);
+
+ async function handleDownload(job: ExportJob) {
+ const result = await onDownload(job);
+ if (result) {
+ notify.success({
+ title: "TSV download started",
+ message: `${job.name} has been downloaded and removed from the export list.`,
+ });
+ }
+ }
+
+ async function handleCancel(job: ExportJob) {
+ try {
+ await onCancel(job.id);
+
+ notify.success({
+ title: "TSV generation cancelled",
+ message: `${job.name} has been cancelled.`,
+ });
+ } catch (error) {
+ debug(error);
+ }
+ }
+
+ function handleDiscardClick(job: ExportJob) {
+ setJobToDiscard(job);
+ }
+
+ function handleCloseDiscard() {
+ setJobToDiscard(null);
+ }
+
+ async function handleConfirmDiscard() {
+ if (!jobToDiscard) return;
+
+ await onDiscard(jobToDiscard.id);
+ setJobToDiscard(null);
+ notify.success({
+ title: "TSV discarded",
+ message: `${jobToDiscard.name} has been discarded.`,
+ });
+ }
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ Header: "Name",
+ accessor: "job",
+ id: "name",
+ Cell: ({ row }: CellProps) => (
+
+ ),
+ },
+ {
+ Header: "Type",
+ accessor: "job",
+ id: "type",
+ Cell: ({ row }: CellProps) => (
+ <>{getTypeLabel(row.original.job)}>
+ ),
+ },
+ {
+ Header: "Status",
+ accessor: "job",
+ id: "status",
+ Cell: ({ row }: CellProps) => {
+ const { job } = row.original;
+ if (job.status === "processing") {
+ return (
+
+ );
+ }
+ return (
+
+ {" "}
+ {getStatusLabel(job)}
+
+ );
+ },
+ },
+ {
+ Header: "Created",
+ accessor: "job",
+ id: "createdAt",
+ Cell: ({ row }: CellProps) => (
+ <>
+ {moment(row.original.job.createdAt).format(
+ DISPLAY_DATE_TIME_FORMAT,
+ )}
+ >
+ ),
+ },
+ {
+ Header: "Expires",
+ accessor: "job",
+ id: "retainUntil",
+ Cell: ({ row }: CellProps) => (
+ <>
+ {row.original.job.retainUntil
+ ? moment(row.original.job.retainUntil).format(
+ DISPLAY_DATE_TIME_FORMAT,
+ )
+ : "—"}
+ >
+ ),
+ },
+ {
+ ...LIST_ACTIONS_COLUMN_PROPS,
+ Cell: ({ row }: CellProps) => {
+ const { job } = row.original;
+ const downloadActions =
+ job.status === "completed"
+ ? [
+ {
+ icon: "begin-downloading",
+ label: "Download",
+ onClick: handleDownload.bind(null, job),
+ },
+ ]
+ : [];
+ const destructiveActions =
+ job.status === "processing"
+ ? [
+ {
+ icon: "close",
+ label: "Cancel",
+ onClick: handleCancel.bind(null, job),
+ },
+ ]
+ : [
+ {
+ icon: "delete",
+ label: "Discard",
+ onClick: handleDiscardClick.bind(null, job),
+ },
+ ];
+ return (
+
+ );
+ },
+ },
+ ],
+ [createPageParamsSetter, notify, onCancel, onDownload],
+ );
+
+ const data = useMemo(
+ () => exportJobs.map((job) => ({ job })),
+ [exportJobs],
+ );
+
+ return (
+ <>
+
+ {jobToDiscard && (
+
+
+ The export "{jobToDiscard.name}" will be permanently
+ deleted.
+
+
+ This action is irreversible.
+
+
+ )}
+ >
+ );
+};
+
+export default ExportsList;
diff --git a/src/features/exports/components/ExportsList/index.ts b/src/features/exports/components/ExportsList/index.ts
new file mode 100644
index 0000000000..00753a80be
--- /dev/null
+++ b/src/features/exports/components/ExportsList/index.ts
@@ -0,0 +1 @@
+export { default } from "./ExportsList";
diff --git a/src/features/exports/components/ExportsList/types.ts b/src/features/exports/components/ExportsList/types.ts
new file mode 100644
index 0000000000..3576d06bd9
--- /dev/null
+++ b/src/features/exports/components/ExportsList/types.ts
@@ -0,0 +1,5 @@
+import type { ExportJob } from "../../types/ExportJob";
+
+export interface ExportRowData extends Record {
+ readonly job: ExportJob;
+}
diff --git a/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.module.scss b/src/features/exports/components/SortableFieldList/SortableFieldList.module.scss
similarity index 100%
rename from src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.module.scss
rename to src/features/exports/components/SortableFieldList/SortableFieldList.module.scss
diff --git a/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.tsx b/src/features/exports/components/SortableFieldList/SortableFieldList.tsx
similarity index 72%
rename from src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.tsx
rename to src/features/exports/components/SortableFieldList/SortableFieldList.tsx
index 90696f1272..fc5958888b 100644
--- a/src/features/instances/components/InstancesExportForm/components/SortableFieldList/SortableFieldList.tsx
+++ b/src/features/exports/components/SortableFieldList/SortableFieldList.tsx
@@ -2,8 +2,6 @@ import { Button, Icon, Input, ModularTable } from "@canonical/react-components";
import classNames from "classnames";
import {
useCallback,
- useEffect,
- useLayoutEffect,
useMemo,
useRef,
useState,
@@ -13,7 +11,16 @@ import {
import { createPortal } from "react-dom";
import type { CellProps, Column, Row, TableRowProps } from "react-table";
import classes from "./SortableFieldList.module.scss";
-import type { ExportField } from "../../types";
+import type { ExportField } from "../../types/ExportForm";
+import { JUST_MOVED_HIGHLIGHT_MS, OVERLAY_CURSOR_OFFSET_PX } from "./constants";
+import {
+ useDragOverlayPosition,
+ useFlipAnimation,
+ usePendingFieldScroll,
+ useSortableFieldCleanup,
+ useSortableFieldRefs,
+ useSyncSortableFields,
+} from "./hooks";
interface ReorderRowData extends Record {
index: number;
@@ -22,11 +29,6 @@ interface ReorderRowData extends Record {
currentOrder: string;
}
-// Horizontal gap between the cursor and the drag overlay card, in pixels.
-const OVERLAY_CURSOR_OFFSET_PX = 12;
-const JUST_MOVED_HIGHLIGHT_MS = 1500;
-const FLIP_TRANSITION_CLEANUP_MS = 300;
-
interface SortableFieldListProps {
readonly fields: ExportField[];
readonly onOrderChange: (fields: ExportField[]) => void;
@@ -59,17 +61,8 @@ const SortableFieldList: FC = ({
const dragCursorPosRef = useRef<{ x: number; y: number } | null>(null);
const dragOverRafRef = useRef(null);
- // Live mirrors of state read inside stable drag handlers, so those handlers
- // (and therefore getReorderRowProps) keep a constant identity across renders.
- // The passive effect is a backstop for the general case; the drag path keeps
- // orderedFieldsRef current synchronously inside the state updater (see
- // handleDragEnter) so handleDrop never depends on passive-effect flush timing.
const orderedFieldsRef = useRef(orderedFields);
const draggingFieldIdRef = useRef(draggingFieldId);
- useEffect(() => {
- orderedFieldsRef.current = orderedFields;
- draggingFieldIdRef.current = draggingFieldId;
- });
// Stable per-row ref callbacks, cached by fieldId, so the row's ref doesn't
// churn (null then re-set) on every render.
@@ -77,57 +70,28 @@ const SortableFieldList: FC = ({
Map void>
>(new Map());
- // Keep internal order in sync with the fields prop. Seeding state once with
- // useState(() => fields) ignores later prop changes; this re-syncs when the
- // parent updates the selection. Skip while a drag is active: an unrelated
- // parent re-render mid-drag must not clobber the live handleDragEnter preview
- // with the (stale) prop order. (draggingFieldIdRef is synced by the effect
- // above, which is declared — and therefore runs — before this one.)
- useEffect(() => {
- if (draggingFieldIdRef.current) return;
- // Intentional prop->state sync: setState in this effect is the deliberate
- // mechanism, not an accident. Reading draggingFieldIdRef during render (to
- // avoid the effect) would instead trip react-hooks/refs.
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setOrderedFields(fields);
- }, [fields]);
-
- // Cleanup animation resources on unmount.
- useEffect(() => {
- return () => {
- if (justMovedTimerRef.current) clearTimeout(justMovedTimerRef.current);
- if (dragOverRafRef.current !== null) {
- cancelAnimationFrame(dragOverRafRef.current);
- }
- for (const id of flipRafIdsRef.current) cancelAnimationFrame(id);
- for (const id of flipTimerIdsRef.current) clearTimeout(id);
- };
- }, []);
-
- // After orderedFields updates, scroll the pending field into view.
- // Delayed by 270ms so it fires after the FLIP animation (250ms) completes —
- // getBoundingClientRect includes CSS transforms, so calling scrollIntoView
- // while the FLIP translateY is active would measure the wrong position.
- useEffect(() => {
- if (!pendingScrollRef.current) return;
- const fieldId = pendingScrollRef.current;
- pendingScrollRef.current = null;
- const timer = setTimeout(() => {
- rowRefsMap.current
- .get(fieldId)
- ?.scrollIntoView({ behavior: "smooth", block: "center" });
- }, 270);
- return () => { clearTimeout(timer); };
- }, [orderedFields]);
+ useSortableFieldRefs({
+ draggingFieldId,
+ draggingFieldIdRef,
+ orderedFields,
+ orderedFieldsRef,
+ });
+ useSyncSortableFields({ draggingFieldIdRef, fields, setOrderedFields });
+ useSortableFieldCleanup({
+ dragOverRafRef,
+ flipRafIdsRef,
+ flipTimerIdsRef,
+ justMovedTimerRef,
+ });
+ usePendingFieldScroll({ orderedFields, pendingScrollRef, rowRefsMap });
const triggerMoveEffect = useCallback((fieldId: string) => {
if (justMovedTimerRef.current) clearTimeout(justMovedTimerRef.current);
setJustMovedFieldId(fieldId);
pendingScrollRef.current = fieldId;
- justMovedTimerRef.current = setTimeout(
- () => { setJustMovedFieldId(null); },
- JUST_MOVED_HIGHLIGHT_MS,
- );
+ justMovedTimerRef.current = setTimeout(() => {
+ setJustMovedFieldId(null);
+ }, JUST_MOVED_HIGHLIGHT_MS);
}, []);
// Snapshot row y-positions before a reorder so FLIP can animate the move.
@@ -149,39 +113,14 @@ const SortableFieldList: FC = ({
pendingFlipRef.current = true;
}, []);
- // FLIP animation: after orderedFields changes, animate rows from their old
- // viewport positions to their new ones. Only runs when pendingFlipRef is set
- // (i.e. arrow/number moves — NOT drag reorders).
- useLayoutEffect(() => {
- if (!pendingFlipRef.current) return;
- pendingFlipRef.current = false;
-
- const oldPositions = flipPositionsRef.current;
-
- rowRefsMap.current.forEach((el, id) => {
- const oldTop = oldPositions.get(id);
- if (oldTop === undefined) return;
- const newTop = el.getBoundingClientRect().top;
- const delta = oldTop - newTop;
- if (delta === 0) return;
-
- el.style.transform = `translateY(${delta}px)`;
- el.style.transition = "transform 0s";
-
- const raf1 = requestAnimationFrame(() => {
- const raf2 = requestAnimationFrame(() => {
- el.style.transition = "transform 0.25s ease, opacity 0.15s ease";
- el.style.transform = "";
- const timer = setTimeout(() => {
- el.style.transition = "";
- }, FLIP_TRANSITION_CLEANUP_MS);
- flipTimerIdsRef.current.push(timer);
- });
- flipRafIdsRef.current.push(raf2);
- });
- flipRafIdsRef.current.push(raf1);
- });
- }, [orderedFields]);
+ useFlipAnimation({
+ flipPositionsRef,
+ flipRafIdsRef,
+ flipTimerIdsRef,
+ orderedFields,
+ pendingFlipRef,
+ rowRefsMap,
+ });
// Apply the latest cursor position to the overlay imperatively. Called from a
// single rAF per frame so a burst of dragover events does no extra layout.
@@ -195,30 +134,22 @@ const SortableFieldList: FC = ({
el.style.transform = `translate3d(${pos.x + OVERLAY_CURSOR_OFFSET_PX}px, ${pos.y}px, 0) translateY(-50%)`;
}, []);
- // Position the overlay as soon as it mounts, using the cursor position
- // captured at drag start, so it doesn't flash at the top-left before the
- // first dragover fires.
- useLayoutEffect(() => {
- if (draggingFieldId) positionOverlay();
- }, [draggingFieldId, positionOverlay]);
-
- const handleDragStart = useCallback(
- (fieldId: string, e: React.DragEvent) => {
- originalOrderRef.current = [...orderedFieldsRef.current];
- dropSucceededRef.current = false;
- dragCursorPosRef.current = { x: e.clientX, y: e.clientY };
- setDraggingFieldId(fieldId);
- e.dataTransfer.effectAllowed = "move";
-
- // Replace the browser's native (always semi-transparent) drag ghost with a
- // 1×1 transparent canvas. We render our own fully-opaque overlay instead.
- const canvas = document.createElement("canvas");
- canvas.width = 1;
- canvas.height = 1;
- e.dataTransfer.setDragImage(canvas, 0, 0);
- },
- [],
- );
+ useDragOverlayPosition({ draggingFieldId, positionOverlay });
+
+ const handleDragStart = useCallback((fieldId: string, e: React.DragEvent) => {
+ originalOrderRef.current = [...orderedFieldsRef.current];
+ dropSucceededRef.current = false;
+ dragCursorPosRef.current = { x: e.clientX, y: e.clientY };
+ setDraggingFieldId(fieldId);
+ e.dataTransfer.effectAllowed = "move";
+
+ // Replace the browser's native (always semi-transparent) drag ghost with a
+ // 1×1 transparent canvas. We render our own fully-opaque overlay instead.
+ const canvas = document.createElement("canvas");
+ canvas.width = 1;
+ canvas.height = 1;
+ e.dataTransfer.setDragImage(canvas, 0, 0);
+ }, []);
const handleDragEnter = useCallback((index: number) => {
const draggingId = draggingFieldIdRef.current;
@@ -412,7 +343,9 @@ const SortableFieldList: FC = ({
appearance="base"
className="u-no-margin--bottom has-icon u-no-margin--right"
disabled={index === orderedFields.length - 1}
- onClick={() => { moveDown(index); }}
+ onClick={() => {
+ moveDown(index);
+ }}
aria-label={`Move ${label} down`}
>
@@ -426,11 +359,15 @@ const SortableFieldList: FC = ({
min={1}
max={orderedFields.length}
value={currentOrder}
- onChange={(e) => { handleOrderInputChange(index, e.target.value); }}
- onKeyDown={(e: React.KeyboardEvent) =>
- { handleOrderInputKeyDown(e, index); }
- }
- onBlur={() => { handleOrderInputBlur(index); }}
+ onChange={(e) => {
+ handleOrderInputChange(index, e.target.value);
+ }}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ handleOrderInputKeyDown(e, index);
+ }}
+ onBlur={() => {
+ handleOrderInputBlur(index);
+ }}
aria-label={`Order for ${label}`}
wrapperClassName={classes.orderInputWrapper}
/>
@@ -439,7 +376,9 @@ const SortableFieldList: FC = ({
appearance="base"
className="u-no-margin--bottom has-icon"
disabled={index === 0}
- onClick={() => { moveUp(index); }}
+ onClick={() => {
+ moveUp(index);
+ }}
aria-label={`Move ${label} up`}
>
diff --git a/src/features/exports/components/SortableFieldList/constants.ts b/src/features/exports/components/SortableFieldList/constants.ts
new file mode 100644
index 0000000000..7062f2c751
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/constants.ts
@@ -0,0 +1,3 @@
+export const OVERLAY_CURSOR_OFFSET_PX = 12;
+export const JUST_MOVED_HIGHLIGHT_MS = 1500;
+export const FLIP_TRANSITION_CLEANUP_MS = 300;
diff --git a/src/features/exports/components/SortableFieldList/hooks/index.ts b/src/features/exports/components/SortableFieldList/hooks/index.ts
new file mode 100644
index 0000000000..37d406692a
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/hooks/index.ts
@@ -0,0 +1,6 @@
+export { useDragOverlayPosition } from "./useDragOverlayPosition";
+export { useFlipAnimation } from "./useFlipAnimation";
+export { usePendingFieldScroll } from "./usePendingFieldScroll";
+export { useSortableFieldCleanup } from "./useSortableFieldCleanup";
+export { useSortableFieldRefs } from "./useSortableFieldRefs";
+export { useSyncSortableFields } from "./useSyncSortableFields";
diff --git a/src/features/exports/components/SortableFieldList/hooks/useDragOverlayPosition.ts b/src/features/exports/components/SortableFieldList/hooks/useDragOverlayPosition.ts
new file mode 100644
index 0000000000..0f4241278c
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/hooks/useDragOverlayPosition.ts
@@ -0,0 +1,16 @@
+import { useLayoutEffect } from "react";
+
+export const useDragOverlayPosition = ({
+ draggingFieldId,
+ positionOverlay,
+}: {
+ readonly draggingFieldId: string | null;
+ readonly positionOverlay: () => void;
+}) => {
+ // Position the overlay as soon as it mounts, using the cursor position
+ // captured at drag start, so it doesn't flash at the top-left before the
+ // first dragover fires.
+ useLayoutEffect(() => {
+ if (draggingFieldId) positionOverlay();
+ }, [draggingFieldId, positionOverlay]);
+};
diff --git a/src/features/exports/components/SortableFieldList/hooks/useFlipAnimation.ts b/src/features/exports/components/SortableFieldList/hooks/useFlipAnimation.ts
new file mode 100644
index 0000000000..3b610829b1
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/hooks/useFlipAnimation.ts
@@ -0,0 +1,60 @@
+import { useLayoutEffect, type RefObject } from "react";
+import type { ExportField } from "../../../types/ExportForm";
+import { FLIP_TRANSITION_CLEANUP_MS } from "../constants";
+
+export const useFlipAnimation = ({
+ flipPositionsRef,
+ flipRafIdsRef,
+ flipTimerIdsRef,
+ orderedFields,
+ pendingFlipRef,
+ rowRefsMap,
+}: {
+ readonly flipPositionsRef: RefObject>;
+ readonly flipRafIdsRef: RefObject;
+ readonly flipTimerIdsRef: RefObject[]>;
+ readonly orderedFields: ExportField[];
+ readonly pendingFlipRef: RefObject;
+ readonly rowRefsMap: RefObject>;
+}) => {
+ // FLIP animation: after orderedFields changes, animate rows from their old
+ // viewport positions to their new ones. Only runs when pendingFlipRef is set
+ // (i.e. arrow/number moves, not drag reorders).
+ useLayoutEffect(() => {
+ if (!pendingFlipRef.current) return;
+ pendingFlipRef.current = false;
+
+ const oldPositions = flipPositionsRef.current;
+
+ rowRefsMap.current.forEach((el, id) => {
+ const oldTop = oldPositions.get(id);
+ if (oldTop === undefined) return;
+ const newTop = el.getBoundingClientRect().top;
+ const delta = oldTop - newTop;
+ if (delta === 0) return;
+
+ el.style.transform = `translateY(${delta}px)`;
+ el.style.transition = "transform 0s";
+
+ const raf1 = requestAnimationFrame(() => {
+ const raf2 = requestAnimationFrame(() => {
+ el.style.transition = "transform 0.25s ease, opacity 0.15s ease";
+ el.style.transform = "";
+ const timer = setTimeout(() => {
+ el.style.transition = "";
+ }, FLIP_TRANSITION_CLEANUP_MS);
+ flipTimerIdsRef.current.push(timer);
+ });
+ flipRafIdsRef.current.push(raf2);
+ });
+ flipRafIdsRef.current.push(raf1);
+ });
+ }, [
+ flipPositionsRef,
+ flipRafIdsRef,
+ flipTimerIdsRef,
+ orderedFields,
+ pendingFlipRef,
+ rowRefsMap,
+ ]);
+};
diff --git a/src/features/exports/components/SortableFieldList/hooks/usePendingFieldScroll.ts b/src/features/exports/components/SortableFieldList/hooks/usePendingFieldScroll.ts
new file mode 100644
index 0000000000..1fb050fd48
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/hooks/usePendingFieldScroll.ts
@@ -0,0 +1,30 @@
+import { useEffect, type RefObject } from "react";
+import type { ExportField } from "../../../types/ExportForm";
+
+export const usePendingFieldScroll = ({
+ orderedFields,
+ pendingScrollRef,
+ rowRefsMap,
+}: {
+ readonly orderedFields: ExportField[];
+ readonly pendingScrollRef: RefObject;
+ readonly rowRefsMap: RefObject>;
+}) => {
+ // After orderedFields updates, scroll the pending field into view.
+ // Delayed by 270ms so it fires after the FLIP animation (250ms) completes.
+ // getBoundingClientRect includes CSS transforms, so calling scrollIntoView
+ // while the FLIP translateY is active would measure the wrong position.
+ useEffect(() => {
+ if (!pendingScrollRef.current) return;
+ const fieldId = pendingScrollRef.current;
+ pendingScrollRef.current = null;
+ const timer = setTimeout(() => {
+ rowRefsMap.current
+ .get(fieldId)
+ ?.scrollIntoView({ behavior: "smooth", block: "center" });
+ }, 270);
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [orderedFields, pendingScrollRef, rowRefsMap]);
+};
diff --git a/src/features/exports/components/SortableFieldList/hooks/useSortableFieldCleanup.ts b/src/features/exports/components/SortableFieldList/hooks/useSortableFieldCleanup.ts
new file mode 100644
index 0000000000..ba536932da
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/hooks/useSortableFieldCleanup.ts
@@ -0,0 +1,24 @@
+import { useEffect, type RefObject } from "react";
+
+export const useSortableFieldCleanup = ({
+ dragOverRafRef,
+ flipRafIdsRef,
+ flipTimerIdsRef,
+ justMovedTimerRef,
+}: {
+ readonly dragOverRafRef: RefObject;
+ readonly flipRafIdsRef: RefObject;
+ readonly flipTimerIdsRef: RefObject[]>;
+ readonly justMovedTimerRef: RefObject | null>;
+}) => {
+ useEffect(() => {
+ return () => {
+ if (justMovedTimerRef.current) clearTimeout(justMovedTimerRef.current);
+ if (dragOverRafRef.current !== null) {
+ cancelAnimationFrame(dragOverRafRef.current);
+ }
+ for (const id of flipRafIdsRef.current) cancelAnimationFrame(id);
+ for (const id of flipTimerIdsRef.current) clearTimeout(id);
+ };
+ }, [dragOverRafRef, flipRafIdsRef, flipTimerIdsRef, justMovedTimerRef]);
+};
diff --git a/src/features/exports/components/SortableFieldList/hooks/useSortableFieldRefs.ts b/src/features/exports/components/SortableFieldList/hooks/useSortableFieldRefs.ts
new file mode 100644
index 0000000000..22d523b63b
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/hooks/useSortableFieldRefs.ts
@@ -0,0 +1,24 @@
+import { useEffect, type RefObject } from "react";
+import type { ExportField } from "../../../types/ExportForm";
+
+export const useSortableFieldRefs = ({
+ draggingFieldId,
+ draggingFieldIdRef,
+ orderedFields,
+ orderedFieldsRef,
+}: {
+ readonly draggingFieldId: string | null;
+ readonly draggingFieldIdRef: RefObject;
+ readonly orderedFields: ExportField[];
+ readonly orderedFieldsRef: RefObject;
+}) => {
+ // Live mirrors of state read inside stable drag handlers, so those handlers
+ // (and therefore getReorderRowProps) keep a constant identity across renders.
+ // The passive effect is a backstop for the general case; the drag path keeps
+ // orderedFieldsRef current synchronously inside the state updater (see
+ // handleDragEnter) so handleDrop never depends on passive-effect flush timing.
+ useEffect(() => {
+ orderedFieldsRef.current = orderedFields;
+ draggingFieldIdRef.current = draggingFieldId;
+ });
+};
diff --git a/src/features/exports/components/SortableFieldList/hooks/useSyncSortableFields.ts b/src/features/exports/components/SortableFieldList/hooks/useSyncSortableFields.ts
new file mode 100644
index 0000000000..a372cb652a
--- /dev/null
+++ b/src/features/exports/components/SortableFieldList/hooks/useSyncSortableFields.ts
@@ -0,0 +1,31 @@
+import {
+ useEffect,
+ type Dispatch,
+ type RefObject,
+ type SetStateAction,
+} from "react";
+import type { ExportField } from "../../../types/ExportForm";
+
+export const useSyncSortableFields = ({
+ draggingFieldIdRef,
+ fields,
+ setOrderedFields,
+}: {
+ readonly draggingFieldIdRef: RefObject;
+ readonly fields: ExportField[];
+ readonly setOrderedFields: Dispatch>;
+}) => {
+ // Keep internal order in sync with the fields prop. Seeding state once with
+ // useState(() => fields) ignores later prop changes; this re-syncs when the
+ // parent updates the selection. Skip while a drag is active: an unrelated
+ // parent re-render mid-drag must not clobber the live handleDragEnter preview
+ // with the (stale) prop order.
+ useEffect(() => {
+ if (draggingFieldIdRef.current) return;
+ // Intentional prop->state sync: setState in this effect is the deliberate
+ // mechanism, not an accident. Reading draggingFieldIdRef during render (to
+ // avoid the effect) would instead trip react-hooks/refs.
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setOrderedFields(fields);
+ }, [draggingFieldIdRef, fields, setOrderedFields]);
+};
diff --git a/src/features/instances/components/InstancesExportForm/components/SortableFieldList/index.ts b/src/features/exports/components/SortableFieldList/index.ts
similarity index 100%
rename from src/features/instances/components/InstancesExportForm/components/SortableFieldList/index.ts
rename to src/features/exports/components/SortableFieldList/index.ts
diff --git a/src/features/exports/constants.ts b/src/features/exports/constants.ts
new file mode 100644
index 0000000000..28e26962d5
--- /dev/null
+++ b/src/features/exports/constants.ts
@@ -0,0 +1,2 @@
+export const EXPORT_JOBS_POLL_INTERVAL_MS = 5000;
+export const MS_PER_SECOND = 1000;
diff --git a/src/features/exports/index.ts b/src/features/exports/index.ts
new file mode 100644
index 0000000000..ad8e370c1a
--- /dev/null
+++ b/src/features/exports/index.ts
@@ -0,0 +1,9 @@
+export { default as ExportsContainer } from "./components/ExportsContainer";
+export { default as ExportDetailsSidePanel } from "./components/ExportDetailsSidePanel";
+export { default as SortableFieldList } from "./components/SortableFieldList";
+export type {
+ ExportJob,
+ ExportJobType,
+ ExportJobStatus,
+} from "./types/ExportJob";
+export type { ExportField, ExportFieldGroup } from "./types/ExportForm";
diff --git a/src/features/exports/types/ExportForm.ts b/src/features/exports/types/ExportForm.ts
new file mode 100644
index 0000000000..ea682e7eac
--- /dev/null
+++ b/src/features/exports/types/ExportForm.ts
@@ -0,0 +1,11 @@
+export interface ExportField {
+ readonly id: string;
+ readonly label: string;
+ readonly defaultSelected?: boolean;
+}
+
+export interface ExportFieldGroup {
+ readonly title: string;
+ readonly key: string;
+ readonly fields: readonly ExportField[];
+}
diff --git a/src/features/exports/types/ExportJob.ts b/src/features/exports/types/ExportJob.ts
new file mode 100644
index 0000000000..13c11c0e9c
--- /dev/null
+++ b/src/features/exports/types/ExportJob.ts
@@ -0,0 +1,23 @@
+export type ExportJobType = "instance" | "activity";
+
+export type ExportJobStatus = "processing" | "completed" | "failed";
+
+export interface ExportJob {
+ readonly id: string;
+ readonly name: string;
+ readonly filename: string;
+ readonly type: ExportJobType;
+ readonly rowCount: number;
+ readonly attributeLabels: string[];
+ readonly selectedFieldIds: string[];
+ readonly createdAt: string;
+ readonly status: ExportJobStatus;
+ readonly progress: number;
+ readonly estimatedSecondsRemaining?: number | null;
+ readonly errorMessage?: string | null;
+ readonly downloadReady?: boolean;
+ readonly retainUntil?: string | null;
+ readonly query?: string | null;
+ readonly displayQuery?: string | null;
+ readonly hasSelection?: boolean;
+}
diff --git a/src/features/instances/api/index.ts b/src/features/instances/api/index.ts
index 305a1c66d5..c836496636 100644
--- a/src/features/instances/api/index.ts
+++ b/src/features/instances/api/index.ts
@@ -2,20 +2,16 @@ export * from "./useAcceptPendingInstances";
export * from "./useAddTagsToInstances";
export * from "./useCreateDistributionUpgrades";
export * from "./useEditInstance";
-export * from "./useCancelInstancesExportJob";
-export * from "./useDiscardInstancesExportJob";
export * from "./useDownloadInstancesExportJob";
export * from "./useExportInstancesCsv";
export * from "./useGenerateRecoveryKey";
export * from "./useGetAvailabilityZones";
-export * from "./useGetInstancesExportJobs";
export * from "./useGetRecoveryKey";
export * from "./useGetInstance";
export * from "./useGetInstanceChildren";
export * from "./useGetInstances";
export * from "./useGetDistributionUpgradeTargets";
export * from "./useGetPendingInstances";
-export * from "./useGetExportAnnotationFields";
export * from "./useRejectPendingInstances";
export * from "./useRemoveInstancesFromLandscape";
export * from "./useRestartInstance";
diff --git a/src/features/instances/api/instancesExportJobsShared.ts b/src/features/instances/api/instancesExportJobsShared.ts
index 1dc896c9cf..3901d5f999 100644
--- a/src/features/instances/api/instancesExportJobsShared.ts
+++ b/src/features/instances/api/instancesExportJobsShared.ts
@@ -1,10 +1,12 @@
import type { InstanceListParams } from "../helpers";
import type { InstancesExportJob } from "../types/InstancesExportJob";
-import type { AxiosResponse, InternalAxiosRequestConfig } from "axios";
export interface CreateInstancesExportJobParams extends InstanceListParams {
readonly name: string;
readonly selected_field_ids: string[];
+ readonly retain_until: string;
+ readonly display_query: string;
+ readonly has_selection: boolean;
}
export interface InstancesExportJobsResponse {
@@ -12,67 +14,17 @@ export interface InstancesExportJobsResponse {
readonly results: InstancesExportJob[];
}
-export const EXPORT_JOBS_QUERY_KEY = ["instances-export-jobs"];
-export const EXPORT_JOBS_POLL_INTERVAL_MS = 5000;
-const HTTP_STATUS_OK = 200;
-
-const EXPORT_JOB_STATUS_ORDER: Record = {
- processing: 0,
- completed: 1,
- failed: 2,
- canceled: 3,
-};
-
-const isVisibleExportJob = (job: InstancesExportJob) =>
- job.status === "processing" ||
- job.status === "completed" ||
- job.status === "failed";
-
export const hasProcessingExportJobs = (jobs: InstancesExportJob[]) =>
jobs.some((job) => job.status === "processing");
-export const getSortedExportJobs = (jobs: InstancesExportJob[]) =>
- [...jobs]
- .filter(isVisibleExportJob)
- .sort((left, right) => {
- if (left.status !== right.status) {
- return EXPORT_JOB_STATUS_ORDER[left.status] -
- EXPORT_JOB_STATUS_ORDER[right.status];
- }
-
- return (
- new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()
- );
- });
-
-export const setExportJobsCache = (
- current: InstancesExportJobsResponse | undefined,
- jobs: InstancesExportJob[],
-): InstancesExportJobsResponse => {
- const sortedJobs = getSortedExportJobs(jobs);
-
- return {
- ...(current ?? {}),
- count: sortedJobs.length,
- results: sortedJobs,
- };
+export const getStatusLabel = (job: InstancesExportJob): string => {
+ switch (job.status) {
+ case "completed":
+ return "Ready";
+ case "failed":
+ return "Failed";
+ case "processing":
+ default:
+ return `Generating (${job.progress}%)`;
+ }
};
-
-export const getExportJobsFromResponse = (
- response: AxiosResponse | undefined,
-) => response?.data.results ?? [];
-
-export const setExportJobsResponseCache = (
- current: AxiosResponse | undefined,
- jobs: InstancesExportJob[],
-): AxiosResponse => ({
- data: setExportJobsCache(current?.data, jobs),
- status: current?.status ?? HTTP_STATUS_OK,
- statusText: current?.statusText ?? "OK",
- headers: current?.headers ?? {},
- config:
- current?.config ??
- ({
- headers: {},
- } as InternalAxiosRequestConfig),
-});
diff --git a/src/features/instances/api/useCancelInstancesExportJob.ts b/src/features/instances/api/useCancelInstancesExportJob.ts
deleted file mode 100644
index 2d3fe46ea9..0000000000
--- a/src/features/instances/api/useCancelInstancesExportJob.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import useFetch from "@/hooks/useFetch";
-import type { ApiError } from "@/types/api/ApiError";
-import type { InstancesExportJob } from "../types/InstancesExportJob";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type { AxiosError, AxiosResponse } from "axios";
-import {
- EXPORT_JOBS_QUERY_KEY,
- getExportJobsFromResponse,
- setExportJobsResponseCache,
- type InstancesExportJobsResponse,
-} from "./instancesExportJobsShared";
-
-export const useCancelInstancesExportJob = () => {
- const authFetch = useFetch();
- const queryClient = useQueryClient();
-
- const { mutateAsync } = useMutation<
- InstancesExportJob,
- AxiosError,
- string
- >({
- mutationFn: async (jobId) =>
- (
- await authFetch.post(
- `computers/exports/${jobId}/cancel`,
- )
- ).data,
- onSuccess: (_job, jobId) => {
- queryClient.setQueryData<
- AxiosResponse | undefined
- >(
- EXPORT_JOBS_QUERY_KEY,
- (current) =>
- setExportJobsResponseCache(
- current,
- getExportJobsFromResponse(current).filter((job) => job.id !== jobId),
- ),
- );
- void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
- },
- });
-
- return {
- cancelInstancesExportJob: mutateAsync,
- };
-};
diff --git a/src/features/instances/api/useDiscardInstancesExportJob.ts b/src/features/instances/api/useDiscardInstancesExportJob.ts
deleted file mode 100644
index be152c3b2e..0000000000
--- a/src/features/instances/api/useDiscardInstancesExportJob.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import useFetch from "@/hooks/useFetch";
-import type { ApiError } from "@/types/api/ApiError";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
-import type { AxiosError, AxiosResponse } from "axios";
-import {
- EXPORT_JOBS_QUERY_KEY,
- getExportJobsFromResponse,
- setExportJobsResponseCache,
- type InstancesExportJobsResponse,
-} from "./instancesExportJobsShared";
-
-export const useDiscardInstancesExportJob = () => {
- const authFetch = useFetch();
- const queryClient = useQueryClient();
-
- const { mutateAsync } = useMutation, string>({
- mutationFn: async (jobId) => {
- await authFetch.delete(`computers/exports/${jobId}`);
- },
- onSuccess: (_result, jobId) => {
- queryClient.setQueryData<
- AxiosResponse | undefined
- >(
- EXPORT_JOBS_QUERY_KEY,
- (current) =>
- setExportJobsResponseCache(
- current,
- getExportJobsFromResponse(current).filter((job) => job.id !== jobId),
- ),
- );
- void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
- },
- });
-
- return {
- discardInstancesExportJob: mutateAsync,
- };
-};
diff --git a/src/features/instances/api/useDownloadInstancesExportJob.test.tsx b/src/features/instances/api/useDownloadInstancesExportJob.test.tsx
deleted file mode 100644
index fbfc88b41f..0000000000
--- a/src/features/instances/api/useDownloadInstancesExportJob.test.tsx
+++ /dev/null
@@ -1,126 +0,0 @@
-import { renderHook } from "@testing-library/react";
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import server from "@/tests/server";
-import { renderHookWithProviders } from "@/tests/render";
-import { API_URL } from "@/constants";
-import type { InstancesExportJob } from "../types/InstancesExportJob";
-import { useDownloadInstancesExportJob } from "./useDownloadInstancesExportJob";
-import { http, HttpResponse } from "msw";
-import { setEndpointStatus } from "@/tests/controllers/controller";
-
-const job = {
- id: "42",
- filename: "instances-export-2026-06-08-120000.tsv",
- name: "My export",
- status: "completed",
- progress: 100,
- downloadReady: true,
-} as unknown as InstancesExportJob;
-
-const removeSaveFilePicker = () => {
- delete (window as unknown as Record).showSaveFilePicker;
-};
-
-const renderDownloadHook = () =>
- renderHook(() => useDownloadInstancesExportJob(), {
- wrapper: renderHookWithProviders(),
- }).result;
-
-describe("useDownloadInstancesExportJob", () => {
- beforeEach(() => {
- vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:mock-url");
- vi.spyOn(URL, "revokeObjectURL").mockReturnValue(undefined);
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- removeSaveFilePicker();
- });
-
- it("saves to file via showSaveFilePicker when the File System Access API is available", async () => {
- const write = vi.fn().mockResolvedValue(undefined);
- const close = vi.fn().mockResolvedValue(undefined);
- const createWritable = vi.fn().mockResolvedValue({ write, close });
- const showSaveFilePicker = vi.fn().mockResolvedValue({ createWritable });
- (window as unknown as Record).showSaveFilePicker =
- showSaveFilePicker;
-
- const result = renderDownloadHook();
- await result.current.downloadInstancesExportJob(job);
-
- expect(showSaveFilePicker).toHaveBeenCalledWith({
- suggestedName: job.filename,
- });
- expect(write).toHaveBeenCalledTimes(1);
- expect(close).toHaveBeenCalledTimes(1);
- expect(URL.createObjectURL).not.toHaveBeenCalled();
- });
-
- it("falls back to a blob download when the File System Access API is unavailable", async () => {
- removeSaveFilePicker();
-
- const result = renderDownloadHook();
- await result.current.downloadInstancesExportJob(job);
-
- expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
- expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
- });
-
- it("rejects without downloading when the save dialog is cancelled", async () => {
- const showSaveFilePicker = vi
- .fn()
- .mockRejectedValue(new DOMException("The user aborted", "AbortError"));
- (window as unknown as Record).showSaveFilePicker =
- showSaveFilePicker;
-
- const result = renderDownloadHook();
-
- await expect(
- result.current.downloadInstancesExportJob(job),
- ).rejects.toMatchObject({ name: "AbortError" });
- expect(URL.createObjectURL).not.toHaveBeenCalled();
- });
-
- it("throws when the server request fails", async () => {
- setEndpointStatus({
- status: "error",
- path: "computers/exports/:jobId/download",
- });
-
- const result = renderDownloadHook();
-
- await expect(
- result.current.downloadInstancesExportJob(job),
- ).rejects.toThrow();
- expect(URL.createObjectURL).not.toHaveBeenCalled();
- });
-
- it("uses the job filename as the suggested save name", async () => {
- const showSaveFilePicker = vi.fn().mockResolvedValue({
- createWritable: vi.fn().mockResolvedValue({
- write: vi.fn().mockResolvedValue(undefined),
- close: vi.fn().mockResolvedValue(undefined),
- }),
- });
- (window as unknown as Record).showSaveFilePicker =
- showSaveFilePicker;
-
- server.use(
- http.get(`${API_URL}computers/exports/:jobId/download`, () =>
- new HttpResponse("data", {
- headers: {
- "Content-Disposition": `attachment; filename="${job.filename}"`,
- "Content-Type": "text/tab-separated-values",
- },
- }),
- ),
- );
-
- const result = renderDownloadHook();
- await result.current.downloadInstancesExportJob(job);
-
- expect(showSaveFilePicker).toHaveBeenCalledWith({
- suggestedName: job.filename,
- });
- });
-});
diff --git a/src/features/instances/api/useDownloadInstancesExportJob.ts b/src/features/instances/api/useDownloadInstancesExportJob.ts
index 4c35c03cb8..6271536ad4 100644
--- a/src/features/instances/api/useDownloadInstancesExportJob.ts
+++ b/src/features/instances/api/useDownloadInstancesExportJob.ts
@@ -1,80 +1,61 @@
-import { downloadInstancesCsv } from "../helpers";
-import type { InstancesExportJob } from "../types/InstancesExportJob";
-import { useMutation, useQueryClient } from "@tanstack/react-query";
import useFetch from "@/hooks/useFetch";
import {
- EXPORT_JOBS_QUERY_KEY,
- getExportJobsFromResponse,
- setExportJobsResponseCache,
- type InstancesExportJobsResponse,
-} from "./instancesExportJobsShared";
-import type { AxiosResponse } from "axios";
+ downloadBlob,
+ supportsNativeSave,
+ type SaveFilePickerHandle,
+} from "@/utils/browserDownload";
+import type { InstancesExportJob } from "../types/InstancesExportJob";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
const DEFAULT_EXPORT_FILENAME = "instances-export.tsv";
-interface SaveFilePickerHandle {
- createWritable: () => Promise<{
- write: (data: Blob) => Promise;
- close: () => Promise;
- }>;
-}
-
-interface SaveFilePickerWindow {
- showSaveFilePicker: (options?: {
- suggestedName?: string;
- }) => Promise;
-}
-
-const supportsNativeSave = (
- candidate: typeof window,
-): candidate is typeof window & SaveFilePickerWindow =>
- "showSaveFilePicker" in candidate;
-
export const useDownloadInstancesExportJob = () => {
const authFetch = useFetch();
const queryClient = useQueryClient();
const { mutateAsync } = useMutation<
- InstancesExportJob,
+ InstancesExportJob | null,
Error,
InstancesExportJob
>({
mutationFn: async (job) => {
const filename = job.filename || DEFAULT_EXPORT_FILENAME;
- const response = await authFetch.get(
- `computers/exports/${job.id}/download`,
- { responseType: "blob" },
- );
- const blob = response.data;
-
if (supportsNativeSave(window)) {
- const handle = await window.showSaveFilePicker({
- suggestedName: filename,
- });
+ let handle: SaveFilePickerHandle;
+ try {
+ handle = await window.showSaveFilePicker({ suggestedName: filename });
+ } catch (error) {
+ if (error instanceof Error && error.name === "AbortError") {
+ return null;
+ }
+ throw error;
+ }
+
+ const response = await authFetch.get(
+ `computers/exports/${job.id}/download`,
+ { responseType: "blob" },
+ );
const writable = await handle.createWritable();
- await writable.write(blob);
+ await writable.write(response.data);
await writable.close();
return job;
}
- downloadInstancesCsv({ blob, filename });
+ const response = await authFetch.get(
+ `computers/exports/${job.id}/download`,
+ { responseType: "blob" },
+ );
+ downloadBlob(response.data, filename);
return job;
},
- onSuccess: (_data, job) => {
- queryClient.setQueryData<
- AxiosResponse | undefined
- >(
- EXPORT_JOBS_QUERY_KEY,
- (current) =>
- setExportJobsResponseCache(
- current,
- getExportJobsFromResponse(current).filter(
- (item) => item.id !== job.id,
- ),
- ),
- );
- void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: ["instances-export-jobs"],
+ });
+ await queryClient.invalidateQueries({
+ queryKey: ["instances-export-jobs-list"],
+ });
},
});
diff --git a/src/features/instances/api/useExportInstancesCsv.ts b/src/features/instances/api/useExportInstancesCsv.ts
index 5a34ff8224..792eb36e0a 100644
--- a/src/features/instances/api/useExportInstancesCsv.ts
+++ b/src/features/instances/api/useExportInstancesCsv.ts
@@ -3,38 +3,23 @@ import type { ApiError } from "@/types/api/ApiError";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError, AxiosResponse } from "axios";
import type { InstancesExportJob } from "../types/InstancesExportJob";
-import {
- EXPORT_JOBS_QUERY_KEY,
- getExportJobsFromResponse,
- setExportJobsResponseCache,
- type CreateInstancesExportJobParams,
- type InstancesExportJobsResponse,
-} from "./instancesExportJobsShared";
+import type { CreateInstancesExportJobParams } from "./instancesExportJobsShared";
export const useExportInstancesCsv = () => {
const authFetch = useFetch();
const queryClient = useQueryClient();
const { isPending, mutateAsync } = useMutation<
- InstancesExportJob,
+ AxiosResponse,
AxiosError,
CreateInstancesExportJobParams
>({
mutationFn: async (params) =>
- (await authFetch.post("computers/export/csv", params))
- .data,
- onSuccess: (job) => {
- queryClient.setQueryData<
- AxiosResponse | undefined
- >(
- EXPORT_JOBS_QUERY_KEY,
- (current) =>
- setExportJobsResponseCache(current, [
- job,
- ...getExportJobsFromResponse(current),
- ]),
- );
- void queryClient.invalidateQueries({ queryKey: EXPORT_JOBS_QUERY_KEY });
+ authFetch.post("computers/export/csv", params),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: ["instances-export-jobs"],
+ });
},
});
diff --git a/src/features/instances/api/useGetExportAnnotationFields.ts b/src/features/instances/api/useGetExportAnnotationFields.ts
deleted file mode 100644
index 338925cf15..0000000000
--- a/src/features/instances/api/useGetExportAnnotationFields.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import useFetch from "@/hooks/useFetch";
-import type { ApiError } from "@/types/api/ApiError";
-import { useQuery } from "@tanstack/react-query";
-import type { AxiosError, AxiosResponse } from "axios";
-import type { InstanceListParams } from "../helpers";
-
-export interface ExportAnnotationFieldOption {
- readonly id: string;
- readonly label: string;
- readonly annotation_key: string;
-}
-
-const buildAnnotationFieldParams = ({
- params,
-}: {
- params: InstanceListParams;
-}) => {
- const searchParams = new URLSearchParams();
-
- if (params.query) {
- searchParams.set("query", params.query);
- }
- if (params.archived_only !== undefined) {
- searchParams.set("archived_only", String(params.archived_only));
- }
- if (params.wsl_children !== undefined) {
- searchParams.set("wsl_children", String(params.wsl_children));
- }
- if (params.wsl_parents !== undefined) {
- searchParams.set("wsl_parents", String(params.wsl_parents));
- }
-
- return searchParams;
-};
-
-interface ExportAnnotationFieldResponse {
- readonly results: ExportAnnotationFieldOption[];
-}
-
-export const useGetExportAnnotationFields = (params: InstanceListParams) => {
- const authFetch = useFetch();
-
- const { data: response, error, isError, isPending } = useQuery<
- AxiosResponse,
- AxiosError
- >({
- queryKey: ["instance-export-annotation-fields", params],
- queryFn: ({ signal }) =>
- authFetch.get(
- "computers/export/annotations",
- {
- params,
- paramsSerializer: () => buildAnnotationFieldParams({ params }).toString(),
- signal,
- },
- ),
- });
-
- return {
- exportAnnotationFields: response?.data.results ?? [],
- exportAnnotationFieldsError: error,
- isErrorExportAnnotationFields: isError,
- isGettingExportAnnotationFields: isPending,
- };
-};
diff --git a/src/features/instances/api/useGetInstancesExportJobs.ts b/src/features/instances/api/useGetInstancesExportJobs.ts
deleted file mode 100644
index a161e55ce4..0000000000
--- a/src/features/instances/api/useGetInstancesExportJobs.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import useFetch from "@/hooks/useFetch";
-import type { ApiError } from "@/types/api/ApiError";
-import { useQuery } from "@tanstack/react-query";
-import type { AxiosError, AxiosResponse } from "axios";
-import {
- EXPORT_JOBS_POLL_INTERVAL_MS,
- EXPORT_JOBS_QUERY_KEY,
- getExportJobsFromResponse,
- getSortedExportJobs,
- hasProcessingExportJobs,
- type InstancesExportJobsResponse,
-} from "./instancesExportJobsShared";
-
-export const useGetInstancesExportJobs = () => {
- const authFetch = useFetch();
-
- const { data: response, dataUpdatedAt } = useQuery<
- AxiosResponse,
- AxiosError
- >({
- queryKey: EXPORT_JOBS_QUERY_KEY,
- queryFn: async () =>
- authFetch.get("computers/exports"),
- refetchInterval: (query) =>
- hasProcessingExportJobs(getExportJobsFromResponse(query.state.data))
- ? EXPORT_JOBS_POLL_INTERVAL_MS
- : false,
- refetchIntervalInBackground: true,
- });
-
- const exportJobs = getSortedExportJobs(getExportJobsFromResponse(response));
-
- return {
- exportJobs,
- // Timestamp (ms) of the last successful fetch — used as the anchor for the
- // client-side ETA countdown between polls.
- dataUpdatedAt,
- processingExportJobsCount: exportJobs.filter(
- (job) => job.status === "processing",
- ).length,
- readyExportJobsCount: exportJobs.filter((job) => job.status === "completed")
- .length,
- };
-};
diff --git a/src/features/instances/components/InstanceList/InstanceList.test.tsx b/src/features/instances/components/InstanceList/InstanceList.test.tsx
index e4b635013a..089442ed90 100644
--- a/src/features/instances/components/InstanceList/InstanceList.test.tsx
+++ b/src/features/instances/components/InstanceList/InstanceList.test.tsx
@@ -14,6 +14,9 @@ const props: ComponentProps = {
setColumnFilterOptions: vi.fn(),
setSelectedInstances: vi.fn(),
instanceCount: instances.length,
+ isAllSelected: false,
+ onSelectAll: vi.fn(),
+ onClearSelection: vi.fn(),
};
describe("InstanceList", () => {
@@ -101,7 +104,7 @@ describe("InstanceList", () => {
});
await userEvent.click(toggleAllCheckbox);
- expect(props.setSelectedInstances).toHaveBeenCalledWith([]);
+ expect(props.onClearSelection).toHaveBeenCalled();
rerender();
const checkedCheckboxes = screen.queryAllByRole("checkbox", {
diff --git a/src/features/instances/components/InstanceList/InstanceList.tsx b/src/features/instances/components/InstanceList/InstanceList.tsx
index bae28eebee..f56adcb684 100644
--- a/src/features/instances/components/InstanceList/InstanceList.tsx
+++ b/src/features/instances/components/InstanceList/InstanceList.tsx
@@ -36,6 +36,9 @@ interface InstanceListProps {
readonly selectedInstances: Instance[];
readonly setColumnFilterOptions: (options: ColumnFilterOption[]) => void;
readonly setSelectedInstances: (instances: Instance[]) => void;
+ readonly isAllSelected: boolean;
+ readonly onSelectAll: () => void;
+ readonly onClearSelection: () => void;
}
const InstanceList = memo(function InstanceList({
@@ -44,6 +47,9 @@ const InstanceList = memo(function InstanceList({
selectedInstances,
setColumnFilterOptions,
setSelectedInstances,
+ isAllSelected,
+ onSelectAll,
+ onClearSelection,
}: InstanceListProps) {
const { disabledColumns, ...filters } = usePageParams();
@@ -64,10 +70,11 @@ const InstanceList = memo(function InstanceList({
const isSelected = useCallback(
(instance: Instance) =>
+ isAllSelected ||
selectedInstances.some(
(selectedInstance) => selectedInstance.id === instance.id,
),
- [selectedInstances],
+ [isAllSelected, selectedInstances],
);
const isNotSelected = useCallback(
@@ -94,17 +101,20 @@ const InstanceList = memo(function InstanceList({
[setSelectedInstances, selectedInstances],
);
- const selectAll = useCallback(() => {
- select(...currentInstances.filter(isNotSelected));
- }, [currentInstances, select, isNotSelected]);
-
const toggleAll = useCallback(() => {
- if (currentInstances.some(isSelected)) {
- deselect(...currentInstances);
+ if (isAllSelected || currentInstances.some(isSelected)) {
+ onClearSelection();
} else {
- selectAll();
+ select(...currentInstances.filter(isNotSelected));
}
- }, [deselect, selectAll, currentInstances, isSelected]);
+ }, [
+ isAllSelected,
+ onClearSelection,
+ select,
+ currentInstances,
+ isSelected,
+ isNotSelected,
+ ]);
const columns = useMemo(
() => [
@@ -144,7 +154,9 @@ const InstanceList = memo(function InstanceList({
labelClassName="u-no-margin--bottom u-no-padding--top"
checked={isSelected(row.original)}
onChange={() => {
- if (isSelected(row.original)) {
+ if (isAllSelected) {
+ onClearSelection();
+ } else if (isSelected(row.original)) {
deselect(row.original);
} else {
select(row.original);
@@ -314,9 +326,11 @@ const InstanceList = memo(function InstanceList({
titleId,
isSelected,
isNotSelected,
+ isAllSelected,
select,
deselect,
toggleAll,
+ onClearSelection,
],
);
@@ -333,30 +347,42 @@ const InstanceList = memo(function InstanceList({
[disabledColumns, columns],
);
- const clearSelection = () => {
- setSelectedInstances([]);
- };
-
- const subhead = !!selectedInstances.length &&
+ const showSubhead =
+ (isAllSelected || !!selectedInstances.length) &&
instanceCount !== undefined &&
- instanceCount > currentInstances.length && (
+ instanceCount > currentInstances.length;
+
+ const subhead = showSubhead && (
+
- {selectedInstances.length} of {instanceCount} instances selected
+ {isAllSelected
+ ? `All ${instanceCount} instances selected`
+ : `${selectedInstances.length} of ${instanceCount} instances selected`}
+ {!isAllSelected && (
+
+ )}
|
- );
+
+ );
return (
{
- it("renders an empty state when there are no export jobs", async () => {
- renderWithProviders();
-
- expect(
- await screen.findByText(/no tsv exports in progress/i),
- ).toBeInTheDocument();
- });
-
- it("renders tracked processing and completed exports", async () => {
- server.use(
- http.get(`${API_URL}computers/exports`, () =>
- HttpResponse.json({
- count: 2,
- results: [processingExportJob, completedExportJob],
- }),
- ),
- );
-
- renderWithProviders();
-
- expect(
- await screen.findByText(processingExportJob.name),
- ).toBeInTheDocument();
- expect(screen.getByText(completedExportJob.name)).toBeInTheDocument();
- expect(screen.getByRole("progressbar")).toBeInTheDocument();
- expect(screen.getByText("35%")).toBeInTheDocument();
- expect(screen.getByText("Estimating...")).toBeInTheDocument();
- expect(screen.getByText(/^ready$/i)).toBeInTheDocument();
- });
-
- it("renders failed exports and hides canceled exports", async () => {
- server.use(
- http.get(`${API_URL}computers/exports`, () =>
- HttpResponse.json({ count: 1, results: [failedExportJob] }),
- ),
- );
-
- renderWithProviders();
-
- expect(await screen.findByText(failedExportJob.name)).toBeInTheDocument();
- expect(screen.getByText(/^failed$/i)).toBeInTheDocument();
- expect(
- screen.queryByText(/generating \(100%\)/i),
- ).not.toBeInTheDocument();
- });
-
- it("discards a failed export and shows a confirmation notification", async () => {
- const user = userEvent.setup();
- server.use(
- http.get(`${API_URL}computers/exports`, () =>
- HttpResponse.json({ count: 1, results: [failedExportJob] }),
- ),
- );
-
- renderWithProviders();
-
- await user.click(
- await screen.findByRole("button", {
- name: new RegExp(`actions for ${failedExportJob.name}`, "i"),
- }),
- );
- await user.click(screen.getByRole("menuitem", { name: /discard/i }));
-
- expect(
- await screen.findByText(/tsv discarded/i),
- ).toBeInTheDocument();
- });
-
- it("cancels an in-progress export and shows a confirmation notification", async () => {
- const user = userEvent.setup();
- server.use(
- http.get(`${API_URL}computers/exports`, () =>
- HttpResponse.json({ count: 1, results: [processingExportJob] }),
- ),
- );
-
- renderWithProviders();
-
- await user.click(
- await screen.findByRole("button", {
- name: new RegExp(`actions for ${processingExportJob.name}`, "i"),
- }),
- );
- await user.click(screen.getByRole("menuitem", { name: /cancel/i }));
-
- expect(
- await screen.findByText(/tsv generation cancelled/i),
- ).toBeInTheDocument();
- });
-
- it("downloads a completed export and shows a confirmation notification", async () => {
- const user = userEvent.setup();
- const write = vi.fn().mockResolvedValue(undefined);
- const close = vi.fn().mockResolvedValue(undefined);
- const showSaveFilePicker = vi
- .fn()
- .mockResolvedValue({ createWritable: vi.fn().mockResolvedValue({ write, close }) });
- (window as unknown as Record).showSaveFilePicker =
- showSaveFilePicker;
-
- server.use(
- http.get(`${API_URL}computers/exports`, () =>
- HttpResponse.json({ count: 1, results: [completedExportJob] }),
- ),
- );
-
- renderWithProviders();
-
- await user.click(
- await screen.findByRole("button", {
- name: new RegExp(`actions for ${completedExportJob.name}`, "i"),
- }),
- );
- await user.click(screen.getByRole("menuitem", { name: /download/i }));
-
- await waitFor(() => {
- expect(write).toHaveBeenCalledTimes(1);
- });
- expect(
- await screen.findByText(/tsv download started/i),
- ).toBeInTheDocument();
-
- delete (window as unknown as Record).showSaveFilePicker;
- });
-
- it("discards a completed export without downloading it", async () => {
- const user = userEvent.setup();
- server.use(
- http.get(`${API_URL}computers/exports`, () =>
- HttpResponse.json({ count: 1, results: [completedExportJob] }),
- ),
- );
-
- renderWithProviders();
-
- await user.click(
- await screen.findByRole("button", {
- name: new RegExp(`actions for ${completedExportJob.name}`, "i"),
- }),
- );
- await user.click(screen.getByRole("menuitem", { name: /discard/i }));
-
- expect(
- await screen.findByText(/tsv discarded/i),
- ).toBeInTheDocument();
- });
-});
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.tsx b/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.tsx
deleted file mode 100644
index 8e4ecd792e..0000000000
--- a/src/features/instances/components/InstancesExportDetailsPanel/InstancesExportDetailsPanel.tsx
+++ /dev/null
@@ -1,284 +0,0 @@
-import {
- useCancelInstancesExportJob,
- useDiscardInstancesExportJob,
- useDownloadInstancesExportJob,
- useGetInstancesExportJobs,
-} from "../../api";
-import ListActions, {
- LIST_ACTIONS_COLUMN_PROPS,
-} from "@/components/layout/ListActions";
-import TruncatedCell from "@/components/layout/TruncatedCell";
-import { useExpandableRow } from "@/hooks/useExpandableRow";
-import useNotify from "@/hooks/useNotify";
-import { ModularTable, Notification } from "@canonical/react-components";
-import { useEffect, useMemo, useState, type FC } from "react";
-import type { CellProps, Column } from "react-table";
-import { createTablePropGetters } from "@/utils/table";
-import type { InstancesExportJob } from "../../types/InstancesExportJob";
-import ExportProgressBar from "./components/ExportProgressBar";
-import classes from "./InstancesExportDetailsPanel.module.scss";
-
-const MS_PER_SECOND = 1000;
-
-interface RowData extends Record {
- readonly job: InstancesExportJob;
- readonly title: string;
- readonly name: string;
- readonly statusLabel: string;
- readonly secondsRemaining: number | null;
- readonly instanceCount: number;
- readonly attributes: string[];
-}
-
-const { getCellProps, getRowProps } = createTablePropGetters({
- itemTypeName: "export row",
- headerColumnId: "name",
-});
-
-const getExportJobStatusLabel = (job: InstancesExportJob) => {
- switch (job.status) {
- case "completed":
- return "Ready";
- case "failed":
- return "Failed";
- case "canceled":
- return "Canceled";
- case "processing":
- default:
- return `Generating (${job.progress}%)`;
- }
-};
-
-const getExportJobStatusIcon = (job: InstancesExportJob): string | false => {
- switch (job.status) {
- case "completed":
- return "status-succeeded-small";
- case "failed":
- return "status-failed-small";
- case "canceled":
- return "status-queued-small";
- case "processing":
- default:
- // Processing rows render a progress bar instead of an icon + label.
- return false;
- }
-};
-
-const InstancesExportDetailsPanel: FC = () => {
- const { notify } = useNotify();
- const { expandedColumnId, expandedRowIndex, getTableRowsRef, handleExpand } =
- useExpandableRow();
- const { exportJobs, dataUpdatedAt, processingExportJobsCount } =
- useGetInstancesExportJobs();
- const { cancelInstancesExportJob } = useCancelInstancesExportJob();
- const { discardInstancesExportJob } = useDiscardInstancesExportJob();
- const { downloadInstancesExportJob } = useDownloadInstancesExportJob();
-
- // Tick once a second so the ETA counts down between the 5s server polls.
- // Anchored to dataUpdatedAt (last fetch) to stay correct without relying on
- // server/client clock alignment. Only runs while something is processing.
- const [now, setNow] = useState(() => Date.now());
- useEffect(() => {
- if (processingExportJobsCount === 0) {
- return;
- }
- const intervalId = setInterval(() => {
- setNow(Date.now());
- }, MS_PER_SECOND);
- return () => {
- clearInterval(intervalId);
- };
- }, [processingExportJobsCount]);
-
- const columns = useMemo[]>(
- () => [
- {
- Header: "Name",
- accessor: "name",
- },
- {
- Header: "Status",
- accessor: "statusLabel",
- Cell: ({ row }: CellProps) => {
- const { job, statusLabel, secondsRemaining } = row.original;
- if (job.status === "processing") {
- return (
-
- );
- }
- return statusLabel;
- },
- getCellIcon: ({ row }: CellProps) =>
- getExportJobStatusIcon(row.original.job),
- },
- {
- Header: "Attributes",
- accessor: "attributes",
- meta: {
- ariaLabel: "Attributes",
- isExpandable: true,
- },
- Cell: ({ row }: CellProps) => (
- (
-
- {attribute}
-
- ))}
- isExpanded={
- row.index === expandedRowIndex &&
- expandedColumnId === "attributes"
- }
- onExpand={() => {
- handleExpand(row.index, "attributes");
- }}
- showCount
- />
- ),
- },
- {
- Header: "Instances",
- accessor: "instanceCount",
- },
- {
- ...LIST_ACTIONS_COLUMN_PROPS,
- accessor: "job.id",
- Cell: ({ row }: CellProps) => {
- const { job } = row.original;
- const actions =
- job.status === "completed"
- ? [
- {
- icon: "begin-downloading",
- label: "Download",
- onClick: async () => {
- try {
- await downloadInstancesExportJob(job);
- } catch (error) {
- if (
- error instanceof DOMException &&
- error.name === "AbortError"
- ) {
- // The user cancelled the save dialog; nothing was downloaded.
- return;
- }
- throw error;
- }
-
- notify.success({
- title: "TSV download started",
- message: `${job.name} has been downloaded and removed from the export list.`,
- });
- },
- },
- ]
- : [];
- const destructiveActions =
- job.status === "processing"
- ? [
- {
- icon: "close",
- label: "Cancel",
- onClick: async () => {
- await cancelInstancesExportJob(job.id);
- notify.info({
- title: "TSV generation cancelled",
- message: `${job.name} has been cancelled and removed from the export list.`,
- });
- },
- },
- ]
- : [
- {
- icon: "delete",
- label: "Discard",
- onClick: async () => {
- await discardInstancesExportJob(job.id);
- notify.info({
- title: "TSV discarded",
- message: `${job.name} has been discarded and removed from the export list.`,
- });
- },
- },
- ];
-
- return (
-
- );
- },
- },
- ],
- [
- cancelInstancesExportJob,
- discardInstancesExportJob,
- downloadInstancesExportJob,
- expandedColumnId,
- expandedRowIndex,
- handleExpand,
- notify,
- ],
- );
-
- const data = useMemo(
- () =>
- exportJobs.map((job) => {
- // Count the ETA down locally from the value captured at the last fetch.
- const secondsRemaining =
- job.estimatedSecondsRemaining == null
- ? null
- : Math.max(
- 0,
- job.estimatedSecondsRemaining -
- (now - dataUpdatedAt) / MS_PER_SECOND,
- );
-
- return {
- job,
- title: job.name,
- name: job.name,
- statusLabel: getExportJobStatusLabel(job),
- secondsRemaining,
- instanceCount: job.instanceCount,
- attributes: job.attributeLabels,
- };
- }),
- [exportJobs, now, dataUpdatedAt],
- );
-
- return (
- <>
-
- Once you download a TSV, it is discarded and cannot be downloaded again.
-
- {exportJobs.length ? (
-
-
-
- ) : (
-
- You have no TSV exports in progress or waiting to be downloaded.
-
- )}
- >
- );
-};
-
-export default InstancesExportDetailsPanel;
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.module.scss b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.module.scss
deleted file mode 100644
index 1d716d776f..0000000000
--- a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.module.scss
+++ /dev/null
@@ -1,57 +0,0 @@
-@import "vanilla-framework/scss/settings_colors";
-@import "vanilla-framework/scss/settings_spacing";
-
-.wrapper {
- align-items: center;
- display: flex;
- gap: $sph--small;
- min-width: 16rem;
- width: 100%;
-}
-
-// Track for the unfilled remainder: a light base with subtle diagonal hatching.
-.bar {
- background-color: #f2f3f5;
- background-image: repeating-linear-gradient(
- 45deg,
- transparent,
- transparent 6px,
- rgba(0, 0, 0, 0.05) 6px,
- rgba(0, 0, 0, 0.05) 12px
- );
- border-radius: 4px;
- flex: 1 1 auto;
- height: 1.5rem;
- overflow: hidden;
- position: relative;
-}
-
-// Filled portion. Width is set inline from the progress percentage.
-.fill {
- align-items: center;
- background-color: $color-positive;
- border-radius: 4px;
- display: flex;
- height: 100%;
- justify-content: flex-end;
- min-width: 2.5rem;
- transition: width 0.3s ease;
-}
-
-// The percentage label, rendered as a lighter rounded "thumb" at the fill edge.
-.percentage {
- background-color: rgba(255, 255, 255, 0.25);
- border-radius: 3px;
- color: #fff;
- font-size: 0.875rem;
- font-weight: 600;
- line-height: 1;
- margin-right: 0.25rem;
- padding: 0.2rem 0.4rem;
-}
-
-.eta {
- color: $color-mid-dark;
- font-size: 0.875rem;
- white-space: nowrap;
-}
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.test.tsx b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.test.tsx
deleted file mode 100644
index c49e82ec31..0000000000
--- a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.test.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import ExportProgressBar, { formatSecondsRemaining } from "./ExportProgressBar";
-
-describe("ExportProgressBar", () => {
- it("renders the percentage inside the bar and reflects it on the progressbar role", () => {
- render();
-
- expect(screen.getByText("35%")).toBeInTheDocument();
- const bar = screen.getByRole("progressbar");
- expect(bar).toHaveAttribute("aria-valuenow", "35");
- expect(bar).toHaveAttribute("aria-valuemin", "0");
- expect(bar).toHaveAttribute("aria-valuemax", "100");
- });
-
- it("clamps out-of-range progress values", () => {
- const { rerender } = render(
- ,
- );
- expect(screen.getByText("0%")).toBeInTheDocument();
-
- rerender();
- expect(screen.getByText("100%")).toBeInTheDocument();
- });
-
- it("shows 'Estimating...' when there is no estimate yet", () => {
- render();
- expect(screen.getByText("Estimating...")).toBeInTheDocument();
- });
-
- it("shows 'Almost done' for very small estimates", () => {
- render();
- expect(screen.getByText("Almost done")).toBeInTheDocument();
- });
-
- it("formats the remaining time to the right of the bar", () => {
- render();
- expect(screen.getByText("2m 30s left")).toBeInTheDocument();
- });
-});
-
-const FORTY_FIVE_SECONDS = 45;
-const TWO_MINUTES = 120;
-const TWO_AND_A_HALF_MINUTES = 150;
-const NEGATIVE_DURATION = -30;
-
-describe("formatSecondsRemaining", () => {
- it("formats sub-minute durations in seconds", () => {
- expect(formatSecondsRemaining(0)).toBe("0s left");
- expect(formatSecondsRemaining(FORTY_FIVE_SECONDS)).toBe("45s left");
- });
-
- it("formats whole minutes without trailing seconds", () => {
- expect(formatSecondsRemaining(TWO_MINUTES)).toBe("2m left");
- });
-
- it("formats minutes with remaining seconds", () => {
- expect(formatSecondsRemaining(TWO_AND_A_HALF_MINUTES)).toBe("2m 30s left");
- });
-
- it("never returns a negative duration", () => {
- expect(formatSecondsRemaining(NEGATIVE_DURATION)).toBe("0s left");
- });
-});
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.tsx b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.tsx
deleted file mode 100644
index 23d0c13609..0000000000
--- a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/ExportProgressBar.tsx
+++ /dev/null
@@ -1,66 +0,0 @@
-import type { FC } from "react";
-import classes from "./ExportProgressBar.module.scss";
-
-const SECONDS_PER_MINUTE = 60;
-// Below this, a precise countdown reads as noise — show "Almost done" instead.
-const ALMOST_DONE_THRESHOLD_SECONDS = 5;
-const MAX_PROGRESS = 100;
-
-export const formatSecondsRemaining = (seconds: number): string => {
- const safe = Math.max(0, Math.round(seconds));
-
- if (safe < SECONDS_PER_MINUTE) {
- return `${safe}s left`;
- }
-
- const minutes = Math.floor(safe / SECONDS_PER_MINUTE);
- const remainderSeconds = safe % SECONDS_PER_MINUTE;
-
- return remainderSeconds
- ? `${minutes}m ${remainderSeconds}s left`
- : `${minutes}m left`;
-};
-
-const getEtaLabel = (secondsRemaining: number | null): string => {
- if (secondsRemaining === null) {
- return "Estimating...";
- }
-
- if (secondsRemaining <= ALMOST_DONE_THRESHOLD_SECONDS) {
- return "Almost done";
- }
-
- return formatSecondsRemaining(secondsRemaining);
-};
-
-interface ExportProgressBarProps {
- readonly progress: number;
- readonly secondsRemaining: number | null;
-}
-
-const ExportProgressBar: FC = ({
- progress,
- secondsRemaining,
-}) => {
- const clampedProgress = Math.min(MAX_PROGRESS, Math.max(0, Math.round(progress)));
-
- return (
-
-
-
- {clampedProgress}%
-
-
-
{getEtaLabel(secondsRemaining)}
-
- );
-};
-
-export default ExportProgressBar;
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/index.ts b/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/index.ts
deleted file mode 100644
index d5f2ddaf0a..0000000000
--- a/src/features/instances/components/InstancesExportDetailsPanel/components/ExportProgressBar/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./ExportProgressBar";
diff --git a/src/features/instances/components/InstancesExportDetailsPanel/index.ts b/src/features/instances/components/InstancesExportDetailsPanel/index.ts
deleted file mode 100644
index 20d402ae51..0000000000
--- a/src/features/instances/components/InstancesExportDetailsPanel/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { default } from "./InstancesExportDetailsPanel";
diff --git a/src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx b/src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx
index 97518966b3..116395e79e 100644
--- a/src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx
+++ b/src/features/instances/components/InstancesExportForm/InstancesExportForm.test.tsx
@@ -1,63 +1,45 @@
-import server from "@/tests/server";
-import useSidePanel from "@/hooks/useSidePanel";
-import useNotify from "@/hooks/useNotify";
-import { API_URL } from "@/constants";
import { renderWithProviders } from "@/tests/render";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { describe, expect, it, vi } from "vitest";
-import { http, HttpResponse } from "msw";
+import type { ComponentProps } from "react";
+import { describe, expect, it } from "vitest";
import InstancesExportForm from "./InstancesExportForm";
-const closeSidePanel = vi.fn();
-const notify = {
- clear: vi.fn(),
- error: vi.fn(),
- info: vi.fn(),
- success: vi.fn(),
- notification: null,
+type InstancesExportFormProps = ComponentProps;
+
+const defaultProps = {
+ exportParams: {
+ query: "name:prod",
+ archived_only: false,
+ wsl_children: false,
+ wsl_parents: false,
+ },
+ instanceCount: 1,
+} satisfies InstancesExportFormProps;
+
+const renderForm = (props: Partial = {}) =>
+ renderWithProviders();
+
+const openAttributeGroup = async (
+ user: ReturnType,
+ name: RegExp,
+) => {
+ await user.click(screen.getByRole("tab", { name }));
};
-vi.mock("@/hooks/useSidePanel");
-vi.mock("@/hooks/useNotify");
-
describe("InstancesExportForm", () => {
- beforeEach(() => {
- closeSidePanel.mockReset();
- vi.mocked(useSidePanel, { partial: true }).mockReturnValue({
- closeSidePanel,
- });
- vi.mocked(useNotify).mockReturnValue({
- notify,
- sidePanel: {
- open: false,
- setOpen: vi.fn(),
- },
- });
- notify.info.mockReset();
- notify.success.mockReset();
- notify.error.mockReset();
- notify.clear.mockReset();
- });
-
- it("shows step 1 with attribute groups in accordions", () => {
- renderWithProviders(
- ,
- );
+ it("shows the export details fields and instance attribute groups", async () => {
+ const user = userEvent.setup();
+ renderForm();
expect(
screen.getByText(/select the attributes you want to include/i),
).toBeInTheDocument();
- expect(screen.getByLabelText("Export name")).toBeInTheDocument();
- expect(screen.getByLabelText("Search attributes")).toBeInTheDocument();
+ expect(screen.getByRole("textbox", { name: "Export name" })).toBeVisible();
+ expect(screen.getByLabelText("Keep until")).toBeVisible();
+ expect(
+ screen.getByRole("searchbox", { name: "Search attributes" }),
+ ).toBeVisible();
expect(
screen.getByRole("tab", { name: /primary identity/i }),
).toBeInTheDocument();
@@ -66,67 +48,47 @@ describe("InstancesExportForm", () => {
name: /granular metadata & deep diagnostics/i,
}),
).toBeInTheDocument();
- expect(screen.getByLabelText("Annotations")).toBeInTheDocument();
- });
-
- it("disables Next button when no fields are selected", () => {
- renderWithProviders(
- ,
- );
-
- expect(screen.getByRole("button", { name: "Next" })).toHaveAttribute(
- "aria-disabled",
- "true",
- );
-
- expect(screen.getByLabelText("Export name")).toHaveValue("");
+ await openAttributeGroup(user, /business logic/i);
+ expect(
+ screen.getByRole("checkbox", { name: "Annotations" }),
+ ).toBeInTheDocument();
});
- it("filters attributes in step 1", async () => {
+ it("keeps Next disabled until an export name and at least one field are selected", async () => {
const user = userEvent.setup();
+ renderForm();
- renderWithProviders(
- ,
- );
+ const nextButton = screen.getByRole("button", { name: "Next" });
+ expect(nextButton).toHaveAttribute("aria-disabled", "true");
- await user.type(screen.getByLabelText("Search attributes"), "host");
+ await user.type(
+ screen.getByRole("textbox", { name: "Export name" }),
+ "Weekly export",
+ );
+ expect(nextButton).toHaveAttribute("aria-disabled", "true");
- expect(screen.getByLabelText("Hostname")).toBeInTheDocument();
- expect(screen.queryByLabelText("Instance name")).not.toBeInTheDocument();
+ await openAttributeGroup(user, /primary identity/i);
+ await user.click(screen.getByRole("checkbox", { name: "Instance name" }));
+ expect(nextButton).not.toHaveAttribute("aria-disabled", "true");
});
- it("does not match parent group titles when filtering attributes", async () => {
+ it("filters attributes by field name without matching group titles", async () => {
const user = userEvent.setup();
+ renderForm();
- renderWithProviders(
- ,
- );
+ const search = screen.getByRole("searchbox", { name: "Search attributes" });
+ await user.type(search, "host");
- await user.type(screen.getByLabelText("Search attributes"), "primary");
+ await openAttributeGroup(user, /primary identity/i);
+ expect(
+ screen.getByRole("checkbox", { name: "Hostname" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("checkbox", { name: "Instance name" }),
+ ).not.toBeInTheDocument();
+
+ await user.clear(search);
+ await user.type(search, "primary");
expect(
screen.getByText("No attributes match your search."),
@@ -136,99 +98,65 @@ describe("InstancesExportForm", () => {
).not.toBeInTheDocument();
});
- it("shows attribute validation with error styling", async () => {
+ it("shows attribute validation after clearing the last selected field", async () => {
const user = userEvent.setup();
+ renderForm();
- renderWithProviders(
- ,
+ await user.type(
+ screen.getByRole("textbox", { name: "Export name" }),
+ "Weekly export",
);
-
- await user.type(screen.getByLabelText("Export name"), "Weekly export");
- await user.click(screen.getByLabelText("Instance name"));
- await user.click(screen.getByLabelText("Instance name"));
+ await openAttributeGroup(user, /primary identity/i);
+ await user.click(screen.getByRole("checkbox", { name: "Instance name" }));
+ await user.click(screen.getByRole("checkbox", { name: "Instance name" }));
expect(screen.getByText("Select at least one attribute")).toHaveClass(
"p-form-validation__message",
);
});
- it("moves to step 2 showing reorder table when Next is clicked", async () => {
+ it("moves to the review step and back without losing selected fields", async () => {
const user = userEvent.setup();
+ renderForm();
- renderWithProviders(
- ,
+ await user.type(
+ screen.getByRole("textbox", { name: "Export name" }),
+ "Weekly export",
);
-
- await user.type(screen.getByLabelText("Export name"), "Weekly export");
- await user.click(screen.getByLabelText("Instance name"));
+ await openAttributeGroup(user, /primary identity/i);
+ await user.click(screen.getByRole("checkbox", { name: "Instance name" }));
+ await user.click(screen.getByRole("checkbox", { name: "Hostname" }));
await user.click(screen.getByRole("button", { name: "Next" }));
expect(
screen.getByText(/review and reorder the columns/i),
).toBeInTheDocument();
- expect(screen.getByLabelText("Order for Instance name")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Back" })).toBeInTheDocument();
- });
-
- it("allows going back from step 2 to step 1", async () => {
- const user = userEvent.setup();
-
- renderWithProviders(
- ,
- );
+ expect(
+ screen.getByLabelText("Order for Instance name"),
+ ).toBeInTheDocument();
+ expect(screen.getByLabelText("Order for Hostname")).toBeInTheDocument();
- await user.type(screen.getByLabelText("Export name"), "Weekly export");
- await user.click(screen.getByLabelText("Instance name"));
- await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Back" }));
+ expect(screen.getByRole("button", { name: "Next" })).toBeInTheDocument();
+ await openAttributeGroup(user, /primary identity/i);
expect(
- screen.getByRole("button", { name: "Next" }),
- ).toBeInTheDocument();
+ screen.getByRole("checkbox", { name: "Instance name" }),
+ ).toBeChecked();
+ expect(screen.getByRole("checkbox", { name: "Hostname" })).toBeChecked();
});
it("keeps the order input focused while typing", async () => {
const user = userEvent.setup();
+ renderForm();
- renderWithProviders(
- ,
+ await user.type(
+ screen.getByRole("textbox", { name: "Export name" }),
+ "Weekly export",
);
-
- await user.type(screen.getByLabelText("Export name"), "Weekly export");
- await user.click(screen.getByLabelText("Instance name"));
- await user.click(screen.getByLabelText("Hostname"));
+ await openAttributeGroup(user, /primary identity/i);
+ await user.click(screen.getByRole("checkbox", { name: "Instance name" }));
+ await user.click(screen.getByRole("checkbox", { name: "Hostname" }));
await user.click(screen.getByRole("button", { name: "Next" }));
const orderInput = screen.getByLabelText("Order for Hostname");
@@ -240,56 +168,29 @@ describe("InstancesExportForm", () => {
expect(orderInput).toHaveValue(1);
});
- it("queues an export, closes the side panel, and shows a toast", async () => {
+ it("queues an export and shows a success notification with a status action", async () => {
const user = userEvent.setup();
- let capturedBody: unknown = null;
- server.use(
- http.post(`${API_URL}computers/export/csv`, async ({ request }) => {
- capturedBody = await request.json();
- return HttpResponse.json(
- {
- id: "job-1",
- name: "Weekly export",
- filename: "instances-export.tsv",
- instanceCount: 8,
- attributeLabels: ["Instance name"],
- selectedFieldIds: ["title"],
- createdAt: "2026-06-03T12:10:00.000Z",
- status: "processing",
- progress: 0,
- downloadReady: false,
- },
- { status: 201 },
- );
- }),
- );
+ renderForm({ instanceCount: 8 });
- renderWithProviders(
- ,
+ await user.type(
+ screen.getByRole("textbox", { name: "Export name" }),
+ "Weekly export",
);
-
- await user.type(screen.getByLabelText("Export name"), "Weekly export");
- await user.click(screen.getByLabelText("Instance name"));
- await user.click(screen.getByLabelText("Annotations"));
+ await openAttributeGroup(user, /primary identity/i);
+ await user.click(screen.getByRole("checkbox", { name: "Instance name" }));
+ await openAttributeGroup(user, /business logic/i);
+ await user.click(screen.getByRole("checkbox", { name: "Annotations" }));
await user.click(screen.getByRole("button", { name: "Next" }));
await user.click(screen.getByRole("button", { name: "Generate TSV" }));
- expect(closeSidePanel).toHaveBeenCalled();
- expect(notify.info).toHaveBeenCalledWith(
- expect.objectContaining({ title: "TSV export in progress" }),
- );
- expect(capturedBody).toMatchObject({
- name: "Weekly export",
- query: "name:prod",
- selected_field_ids: ["title", "annotations"],
- });
+ expect(await screen.findByText("TSV export in progress")).toBeVisible();
+ expect(
+ screen.getByText(
+ 'Your instances export "Weekly export" for "name:prod" is being generated.',
+ ),
+ ).toBeVisible();
+ expect(
+ screen.getByRole("button", { name: "View export status" }),
+ ).toBeInTheDocument();
});
-});
\ No newline at end of file
+});
diff --git a/src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx b/src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx
index 19021e22a7..7c39fc3155 100644
--- a/src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx
+++ b/src/features/instances/components/InstancesExportForm/InstancesExportForm.tsx
@@ -1,7 +1,9 @@
import SidePanelFormButtons from "@/components/form/SidePanelFormButtons";
+import { INPUT_DATE_FORMAT } from "@/constants";
import useDebug from "@/hooks/useDebug";
import useNotify from "@/hooks/useNotify";
import useSidePanel from "@/hooks/useSidePanel";
+import { ROUTES } from "@/libs/routes";
import { getFormikError } from "@/utils/formikErrors";
import {
Accordion,
@@ -10,17 +12,17 @@ import {
Input,
} from "@canonical/react-components";
import { useFormik } from "formik";
-import {
- useCallback,
- useMemo,
- useState,
- type FC,
-} from "react";
+import moment from "moment";
+import { useCallback, useMemo, useState, type FC } from "react";
+import { useNavigate } from "react-router";
import { useExportInstancesCsv } from "../../api/useExportInstancesCsv";
import type { InstanceListParams } from "../../helpers";
import classes from "./InstancesExportForm.module.scss";
-import { INITIAL_VALUES, VALIDATION_SCHEMA } from "./constants";
-import { EXPORT_FIELD_GROUPS } from "./constants";
+import {
+ EXPORT_FIELD_GROUPS,
+ INITIAL_VALUES,
+ VALIDATION_SCHEMA,
+} from "./constants";
import { buildExportQuery } from "./helpers";
import type {
ExportField,
@@ -28,7 +30,7 @@ import type {
StepIndex,
} from "./types";
import classNames from "classnames";
-import SortableFieldList from "./components/SortableFieldList";
+import { SortableFieldList } from "@/features/exports";
interface InstancesExportFormProps {
readonly exportParams: InstanceListParams;
@@ -45,6 +47,7 @@ const InstancesExportForm: FC = ({
}) => {
const { closeSidePanel } = useSidePanel();
const { notify } = useNotify();
+ const navigate = useNavigate();
const debug = useDebug();
const { exportInstancesCsv, isExportInstancesCsvLoading } =
useExportInstancesCsv();
@@ -52,6 +55,10 @@ const InstancesExportForm: FC = ({
const [attributeSearch, setAttributeSearch] = useState("");
const [orderedFields, setOrderedFields] = useState([]);
+ const handleBack = () => {
+ setStep(0);
+ };
+
const formik = useFormik({
initialValues: INITIAL_VALUES,
validationSchema: VALIDATION_SCHEMA,
@@ -75,19 +82,32 @@ const InstancesExportForm: FC = ({
});
try {
- await exportInstancesCsv({
+ const response = await exportInstancesCsv({
name: values.name.trim(),
query,
archived_only: exportParams.archived_only,
wsl_children: exportParams.wsl_children,
wsl_parents: exportParams.wsl_parents,
selected_field_ids: fieldsToExport.map((field) => field.id),
+ retain_until: moment(values.retainUntil).toISOString(),
+ display_query: exportParams.query ?? "",
+ has_selection: !!selectedInstanceIds?.length,
});
+ const job = response.data;
closeSidePanel();
- notify.info({
+ notify.success({
title: "TSV export in progress",
- message: `Your instances export "${values.name.trim()}"${exportParams.query ? ` for "${exportParams.query}"` : ""} is being generated. You can track it from the instances page.`,
+ message: `Your instances export "${values.name.trim()}"${exportParams.query ? ` for "${exportParams.query}"` : ""} is being generated.`,
+ actions: [
+ {
+ label: "View export status",
+ onClick: () =>
+ navigate(
+ ROUTES.exports.root({ sidePath: ["view"], name: job.id }),
+ ),
+ },
+ ],
});
} catch (error) {
debug(error);
@@ -198,6 +218,15 @@ const InstancesExportForm: FC = ({
error={getFormikError(formik, "name")}
{...formik.getFieldProps("name")}
/>
+
= ({
return (