From 7501b407f91a50911658ebec2d47d07d27cd1bdc Mon Sep 17 00:00:00 2001 From: Nicolas Clerc Date: Tue, 10 Dec 2024 17:24:06 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20add=20a=20button=20to=20e?= =?UTF-8?q?xport=20orders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We want to export the order list using the current filters. --- CHANGELOG.md | 1 + .../presentational/filters/SearchFilters.tsx | 21 +++++++++++- .../templates/orders/filters/OrderFilters.tsx | 8 ++++- .../admin/src/hooks/useOrders/useOrders.tsx | 21 ++++++++++++ .../admin/src/hooks/useResources/types.ts | 1 + .../repositories/orders/OrderRepository.ts | 14 +++++++- .../tests/orders/orders-filters.test.e2e.ts | 34 +++++++++++++++++++ src/frontend/admin/tsconfig.json | 6 ++-- 8 files changed, 100 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79cce5dc8..3c41a3f1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to to `Product`model - Add admin api endpoints to CRUD `Teacher` and `Skill` resources. - Add certification section in back office product detail view +- Add order export to CSV in back office ### Changed diff --git a/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx b/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx index 4dc72cfb9..b169c6e41 100644 --- a/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx +++ b/src/frontend/admin/src/components/presentational/filters/SearchFilters.tsx @@ -12,7 +12,7 @@ import CircularProgress from "@mui/material/CircularProgress"; import SearchOutlined from "@mui/icons-material/SearchOutlined"; import TextField from "@mui/material/TextField"; import { useDebouncedCallback } from "use-debounce"; -import { FilterList } from "@mui/icons-material"; +import { FileDownload, FilterList } from "@mui/icons-material"; import Chip from "@mui/material/Chip"; import Stack from "@mui/material/Stack"; import Box from "@mui/material/Box"; @@ -32,6 +32,11 @@ const messages = defineMessages({ defaultMessage: "Filters", description: "Label for the filters button", }, + exportLabelButton: { + id: "components.presentational.filters.searchFilters.exportLabelButton", + defaultMessage: "Export", + description: "Label for the export button", + }, clear: { id: "components.presentational.filters.searchFilters.clear", defaultMessage: "Clear", @@ -74,6 +79,7 @@ export type SearchFilterProps = MandatorySearchFilterProps & { addChip: (chip: FilterChip) => void, removeChip: (chipName: string) => void, ) => ReactNode; + export?: () => void; }; export function SearchFilters(props: PropsWithChildren) { @@ -181,6 +187,19 @@ export function SearchFilters(props: PropsWithChildren) { )} + {props.export && ( + + )} {props.renderContent && ( diff --git a/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx b/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx index de98baed7..677b6df34 100644 --- a/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx +++ b/src/frontend/admin/src/components/templates/orders/filters/OrderFilters.tsx @@ -20,7 +20,7 @@ import { OrganizationSearch } from "@/components/templates/organizations/inputs/ import { UserSearch } from "@/components/templates/users/inputs/search/UserSearch"; import { RHFOrderState } from "@/components/templates/orders/inputs/RHFOrderState"; import { entitiesInputLabel } from "@/translations/common/entitiesInputLabel"; -import { OrderListQuery } from "@/hooks/useOrders/useOrders"; +import { OrderListQuery, useOrders } from "@/hooks/useOrders/useOrders"; const messages = defineMessages({ searchPlaceholder: { @@ -49,6 +49,7 @@ type Props = MandatorySearchFilterProps & { export function OrderFilters({ onFilter, ...searchFilterProps }: Props) { const intl = useIntl(); + const ordersQuery = useOrders({}, { enabled: false }); const getDefaultValues = () => { return { @@ -162,6 +163,11 @@ export function OrderFilters({ onFilter, ...searchFilterProps }: Props) { )} + export={() => { + ordersQuery.methods.export({ + currentFilters: formValuesToFilterValues(methods.getValues()), + }); + }} /> ); } diff --git a/src/frontend/admin/src/hooks/useOrders/useOrders.tsx b/src/frontend/admin/src/hooks/useOrders/useOrders.tsx index 762a2d618..3bf04f88a 100644 --- a/src/frontend/admin/src/hooks/useOrders/useOrders.tsx +++ b/src/frontend/admin/src/hooks/useOrders/useOrders.tsx @@ -57,6 +57,13 @@ export const useOrdersMessages = defineMessages({ description: "Error message shown to the user when no order matches.", defaultMessage: "Cannot find the order", }, + errorExport: { + id: "hooks.useOrders.errorExport", + description: + "Error message shown to the user when order export request fails.", + defaultMessage: + "An error occurred while exporting orders. Please retry later.", + }, }); export type OrderListQuery = ResourcesQuery & { @@ -97,6 +104,9 @@ const orderProps: UseResourcesProps = { refund: async (id: string) => { return OrderRepository.refund(id); }, + export: async (filters) => { + return OrderRepository.export(filters); + }, }), session: true, messages: useOrdersMessages, @@ -148,6 +158,17 @@ export const useOrders = ( ); }, }).mutate, + export: mutation({ + mutationFn: async (data: { currentFilters: OrderListQuery }) => { + return OrderRepository.export(data.currentFilters); + }, + onError: (error: HttpError) => { + custom.methods.setError( + error.data?.details ?? + intl.formatMessage(useOrdersMessages.errorExport), + ); + }, + }).mutate, }, }; }; diff --git a/src/frontend/admin/src/hooks/useResources/types.ts b/src/frontend/admin/src/hooks/useResources/types.ts index 1eeef82f1..ab72f6dbb 100644 --- a/src/frontend/admin/src/hooks/useResources/types.ts +++ b/src/frontend/admin/src/hooks/useResources/types.ts @@ -25,6 +25,7 @@ export interface ApiResourceInterface< create?: (payload: any) => Promise; update?: (payload: any) => Promise; delete?: (id: TData["id"]) => Promise; + export?: (filters?: TResourceQuery) => Promise; } export const useLocalizedQueryKey = (queryKey: QueryKey) => queryKey; diff --git a/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts b/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts index ca8d5aecb..e04ab6850 100644 --- a/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts +++ b/src/frontend/admin/src/services/repositories/orders/OrderRepository.ts @@ -2,7 +2,11 @@ import queryString from "query-string"; import { Order, OrderQuery } from "@/services/api/models/Order"; import { Maybe } from "@/types/utils"; import { ResourcesQuery } from "@/hooks/useResources/types"; -import { checkStatus, fetchApi } from "@/services/http/HttpService"; +import { + buildApiUrl, + checkStatus, + fetchApi, +} from "@/services/http/HttpService"; import { PaginatedResponse } from "@/types/api"; export const orderRoutes = { @@ -11,6 +15,7 @@ export const orderRoutes = { delete: (id: string) => `/orders/${id}/`, generateCertificate: (id: string) => `/orders/${id}/generate_certificate/`, refund: (id: string) => `/orders/${id}/refund/`, + export: (params: string = "") => `/orders/export/${params}`, }; export class OrderRepository { @@ -45,4 +50,11 @@ export class OrderRepository { const url = orderRoutes.refund(id); return fetchApi(url, { method: "POST" }).then(checkStatus); } + + static export(filters: Maybe): void { + const url = orderRoutes.export( + filters ? `?${queryString.stringify(filters)}` : "", + ); + window.open(buildApiUrl(url)); + } } diff --git a/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts b/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts index b3c560039..7e64a3916 100644 --- a/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts +++ b/src/frontend/admin/src/tests/orders/orders-filters.test.e2e.ts @@ -31,6 +31,19 @@ test.describe("Order filters", () => { } }); + const context = page.context(); + const exportUrl = "http://localhost:8071/api/v1.0/admin/orders/export/"; + const exportQueryParamsRegex = getUrlCatchSearchParamsRegex(exportUrl); + await context.unroute(exportQueryParamsRegex); + await context.route(exportQueryParamsRegex, async (route, request) => { + if (request.method() === "GET") { + await route.fulfill({ + contentType: "application/csv", + body: "data", + }); + } + }); + await mockPlaywrightCrud({ data: store.products, routeUrl: "http://localhost:8071/api/v1.0/admin/products/", @@ -124,4 +137,25 @@ test.describe("Order filters", () => { page.getByRole("button", { name: `Owner: ${store.users[0].username}` }), ).toBeVisible(); }); + + test("Test export with filters", async ({ page }) => { + await page.goto(PATH_ADMIN.orders.list); + + await page.getByRole("button", { name: "Filters" }).click(); + await page + .getByTestId("select-order-state-filter") + .getByLabel("State") + .click(); + await page.getByRole("option", { name: "Completed" }).click(); + await page.getByLabel("close").click(); + + await page.getByRole("button", { name: "Export" }).click(); + + page.on("popup", async (popup) => { + await popup.waitForLoadState(); + expect(popup.url()).toBe( + "http://localhost:8071/api/v1.0/admin/orders/export/?state=completed", + ); + }); + }); }); diff --git a/src/frontend/admin/tsconfig.json b/src/frontend/admin/tsconfig.json index b68b48147..f9f734a96 100644 --- a/src/frontend/admin/tsconfig.json +++ b/src/frontend/admin/tsconfig.json @@ -16,9 +16,9 @@ "incremental": true, "baseUrl": ".", "paths": { - "@/*": ["./src/*"], - }, + "@/*": ["./src/*"] + } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"], + "exclude": ["node_modules"] }