diff --git a/CHANGELOG.md b/CHANGELOG.md index adff28b947..5c8db11582 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The types of changes are: ### Added - Make all "Description" table columns expandable in Admin UI tables [#5340](https://github.com/ethyca/fides/pull/5340) - Added access support for Shipstation [#5343](https://github.com/ethyca/fides/pull/5343) +- Introduce custom reports to Data map report [#5352](https://github.com/ethyca/fides/pull/5352) ### Changed - Updated the filter postprocessor (SaaS integration framework) to support dataset references [#5343](https://github.com/ethyca/fides/pull/5343) diff --git a/clients/admin-ui/cypress/e2e/datamap-report.cy.ts b/clients/admin-ui/cypress/e2e/datamap-report.cy.ts index cc92cb8f14..bc1e7ecad5 100644 --- a/clients/admin-ui/cypress/e2e/datamap-report.cy.ts +++ b/clients/admin-ui/cypress/e2e/datamap-report.cy.ts @@ -8,6 +8,7 @@ import { REPORTING_DATAMAP_ROUTE } from "~/features/common/nav/v2/routes"; import { AllowedTypes, CustomFieldDefinition, + ReportType, ResourceTypes, } from "~/types/api"; @@ -30,6 +31,7 @@ const mockCustomField = (overrides?: Partial) => { describe("Minimal datamap report table", () => { beforeEach(() => { + cy.intercept("GET", "/api/v1/system*", { body: [] }); cy.login(); stubPlus(true); stubSystemCrud(); @@ -194,6 +196,192 @@ describe("Minimal datamap report table", () => { }); }); + describe("Custom report templates", () => { + beforeEach(() => { + cy.intercept("GET", "/api/v1/plus/custom-report/minimal*", { + fixture: "custom-reports/minimal.json", + }).as("getCustomReportsMinimal"); + cy.intercept("GET", "/api/v1/plus/custom-report/plu_*", { + fixture: "custom-reports/custom-report.json", + }).as("getCustomReportById"); + cy.intercept("POST", "/api/v1/plus/custom-report", { + fixture: "custom-reports/custom-report.json", + }).as("createCustomReport"); + cy.intercept("DELETE", "/api/v1/plus/custom-report/*", "").as( + "deleteCustomReport", + ); + }); + it("should show an empty state when no custom reports are available", () => { + cy.intercept("GET", "/api/v1/plus/custom-report/minimal*", { + fixture: "custom-reports/empty_custom-reports.json", + }).as("getEmptyCustomReports"); + cy.getByTestId("custom-reports-trigger").click(); + cy.getByTestId("custom-reports-popover").should("be.visible"); + cy.wait("@getEmptyCustomReports"); + cy.getByTestId("custom-reports-empty-state").should("be.visible"); + }); + it("should list the available reports in the popover", () => { + cy.getByTestId("custom-reports-trigger").click(); + cy.getByTestId("custom-reports-popover").should("be.visible"); + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("custom-report-item").should("have.length", 2); + }); + }); + it("should allow the user to select a report", () => { + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-trigger") + .should("contain.text", "Reports") + .click(); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("custom-report-item").first().click(); + }); + cy.wait("@getCustomReportById"); + cy.getByTestId("apply-report-button").click(); + cy.getByTestId("custom-reports-popover").should("not.be.visible"); + cy.get("#toast-datamap-report-toast") + .should("be.visible") + .should("have.attr", "data-status", "success"); + cy.getByTestId("custom-reports-trigger") + .should("contain.text", "My Custom Report") + .click(); + cy.getByTestId("fidesTable").within(() => { + // reordering applied to report + cy.get("thead th").eq(2).should("contain.text", "Legal name"); + // column visibility applied to report + cy.get("thead th").eq(4).should("not.contain.text", "Data subject"); + }); + cy.getByTestId("group-by-menu").should( + "contain.text", + "Group by data use", + ); + cy.getByTestId("edit-columns-btn").click(); + cy.get("button#data_subjects").should( + "have.attr", + "aria-checked", + "false", + ); + cy.getByTestId("column-settings-close-button").click(); + cy.getByTestId("filter-multiple-systems-btn").click(); + cy.getByTestId("datamap-report-filter-modal") + .should("be.visible") + .within(() => { + cy.getByTestId("filter-modal-accordion-button").eq(0).click(); + cy.getByTestId("checkbox-Analytics").within(() => { + cy.get("[data-checked]").should("exist"); + }); + cy.getByTestId("standard-dialog-close-btn").click(); + }); + }); + it("should allow the user to reset a report", () => { + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-trigger") + .should("contain.text", "Reports") + .click(); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("custom-report-item").first().click(); + }); + cy.wait("@getCustomReportById"); + cy.getByTestId("apply-report-button").click(); + cy.getByTestId("custom-reports-popover").should("not.be.visible"); + cy.getByTestId("custom-reports-trigger") + .should("contain.text", "My Custom Report") + .click(); + cy.getByTestId("custom-reports-reset-button").click(); + cy.getByTestId("apply-report-button").click(); + cy.getByTestId("custom-reports-popover").should("not.be.visible"); + cy.getByTestId("custom-reports-trigger").should( + "contain.text", + "Reports", + ); + }); + it("should allow the user cancel a report selection", () => { + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-trigger") + .should("contain.text", "Reports") + .click(); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("custom-report-item").first().click(); + }); + cy.wait("@getCustomReportById"); + cy.getByTestId("custom-report-popover-cancel").click(); + cy.getByTestId("custom-reports-popover").should("not.be.visible"); + cy.get("#toast-datamap-report-toast").should("not.exist"); + }); + it("should not be affected by the user's custom column settings", () => { + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-trigger") + .should("contain.text", "Reports") + .click(); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("custom-report-item").first().click(); + }); + cy.wait("@getCustomReportById"); + cy.getByTestId("apply-report-button").click(); + cy.getByTestId("data_categories-header-menu").click(); + cy.getByTestId("data_categories-header-menu-list").within(() => { + cy.get("button").contains("Expand all").click(); + }); + cy.getByTestId("custom-reports-trigger").should( + "contain.text", + "My Custom Report", + ); + }); + it("should show an error if the report fails to load", () => { + cy.intercept("GET", "/api/v1/plus/custom-report/plu_*", { + statusCode: 500, + body: "Internal Server Error", + }).as("getCustomReportById500"); + cy.getByTestId("custom-reports-trigger").click(); + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("custom-report-item").first().click(); + }); + cy.wait("@getCustomReportById500"); + cy.get("#toast-custom-report-toast") + .should("be.visible") + .should("have.attr", "data-status", "error"); + }); + it("should allow an authorized user to create a new report", () => { + cy.getByTestId("custom-reports-trigger").click(); + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("create-report-button").click(); + }); + cy.getByTestId("custom-report-form").should("be.visible"); + cy.getByTestId("custom-report-form").within(() => { + cy.get("#reportName").type("My Custom Report").blur(); + cy.getByTestId("error-reportName").should("exist"); + cy.get("#reportName").clear(); + }); + cy.getByTestId("custom-report-form").within(() => { + cy.get("#reportName").type("My new report"); + cy.getByTestId("error-reportName").should("not.exist"); + cy.getByTestId("custom-report-form-submit").click(); + }); + cy.wait("@createCustomReport").then((interception) => { + expect(interception.request.body.name).to.equal("My new report"); + expect(interception.request.body.type).to.equal(ReportType.DATAMAP); + expect(interception.request.body.config).to.not.be.empty; + }); + cy.getByTestId("custom-reports-popover").should("be.visible"); + }); + it("should allow an authorized user to delete a report", () => { + cy.getByTestId("custom-reports-trigger").click(); + cy.wait("@getCustomReportsMinimal"); + cy.getByTestId("custom-reports-popover").within(() => { + cy.getByTestId("delete-report-button").first().click(); + }); + cy.getByTestId("confirmation-modal").should("be.visible"); + cy.getByTestId("confirmation-modal").within(() => { + cy.getByTestId("continue-btn").click(); + }); + cy.wait("@deleteCustomReport") + .its("request.url") + .should("include", "1234"); + }); + }); + describe("Exporting", () => { it("should open the export modal", () => { cy.getByTestId("export-btn").click(); diff --git a/clients/admin-ui/cypress/fixtures/custom-reports/custom-report.json b/clients/admin-ui/cypress/fixtures/custom-reports/custom-report.json new file mode 100644 index 0000000000..4735fe165c --- /dev/null +++ b/clients/admin-ui/cypress/fixtures/custom-reports/custom-report.json @@ -0,0 +1,112 @@ +{ + "id": "plu_1234", + "type": "datamap", + "name": "My Custom Report", + "config": { + "table_state": { + "filters": { + "dataUses": ["analytics"], + "dataSubjects": [], + "dataCategories": [] + }, + "groupBy": "data_use, system", + "columnOrder": [ + "data_use", + "system_name", + "legal_name", + "data_categories", + "data_subjects", + "dpo", + "legal_basis_for_processing", + "administrating_department", + "cookie_max_age_seconds", + "privacy_policy", + "legal_address", + "cookie_refresh", + "data_security_practices", + "DATA_SHARED_WITH_THIRD_PARTIES", + "data_stewards", + "declaration_name", + "does_international_transfers", + "dpa_location", + "egress", + "exempt_from_privacy_regulations", + "features", + "fides_key", + "flexible_legal_basis_for_processing", + "impact_assessment_location", + "ingress", + "joint_controller_info", + "legal_basis_for_profiling", + "legal_basis_for_transfers", + "legitimate_interest_disclosure_url", + "link_to_processor_contract", + "processes_personal_data", + "reason_for_exemption", + "requires_data_protection_assessments", + "responsibility", + "retention_period", + "shared_categories", + "special_category_legal_basis", + "system_dependencies", + "third_country_safeguards", + "third_parties", + "system_undeclared_data_categories", + "data_use_undeclared_data_categories", + "cookies", + "uses_cookies", + "uses_non_cookie_access", + "uses_profiling", + "system_lorem" + ], + "columnVisibility": { + "dpo": true, + "egress": true, + "cookies": true, + "ingress": true, + "features": true, + "fides_key": true, + "legal_name": true, + "dpa_location": true, + "system_lorem": true, + "uses_cookies": true, + "data_stewards": true, + "data_subjects": false, + "legal_address": true, + "third_parties": true, + "cookie_refresh": true, + "privacy_policy": true, + "responsibility": true, + "uses_profiling": true, + "data_categories": true, + "declaration_name": true, + "retention_period": true, + "shared_categories": true, + "system_dependencies": true, + "reason_for_exemption": true, + "joint_controller_info": true, + "cookie_max_age_seconds": true, + "uses_non_cookie_access": true, + "data_security_practices": true, + "processes_personal_data": true, + "third_country_safeguards": true, + "administrating_department": true, + "legal_basis_for_profiling": true, + "legal_basis_for_transfers": true, + "impact_assessment_location": true, + "legal_basis_for_processing": true, + "link_to_processor_contract": true, + "does_international_transfers": true, + "special_category_legal_basis": true, + "DATA_SHARED_WITH_THIRD_PARTIES": true, + "exempt_from_privacy_regulations": true, + "system_undeclared_data_categories": false, + "legitimate_interest_disclosure_url": true, + "data_use_undeclared_data_categories": false, + "flexible_legal_basis_for_processing": true, + "requires_data_protection_assessments": true + } + }, + "column_map": {} + } +} diff --git a/clients/admin-ui/cypress/fixtures/custom-reports/empty_custom-reports.json b/clients/admin-ui/cypress/fixtures/custom-reports/empty_custom-reports.json new file mode 100644 index 0000000000..ca92a0b220 --- /dev/null +++ b/clients/admin-ui/cypress/fixtures/custom-reports/empty_custom-reports.json @@ -0,0 +1 @@ +{ "items": [], "total": 0, "page": 1, "size": 50, "pages": 0 } diff --git a/clients/admin-ui/cypress/fixtures/custom-reports/minimal.json b/clients/admin-ui/cypress/fixtures/custom-reports/minimal.json new file mode 100644 index 0000000000..5f699c5baa --- /dev/null +++ b/clients/admin-ui/cypress/fixtures/custom-reports/minimal.json @@ -0,0 +1,18 @@ +{ + "items": [ + { + "id": "plu_1234", + "type": "datamap", + "name": "My Custom Report" + }, + { + "id": "plu_5678", + "type": "datamap", + "name": "Another Saved Report" + } + ], + "total": 2, + "page": 1, + "size": 50, + "pages": 1 +} diff --git a/clients/admin-ui/cypress/fixtures/user-management/permissions.json b/clients/admin-ui/cypress/fixtures/user-management/permissions.json index 50dfab68ba..0b912e22b8 100644 --- a/clients/admin-ui/cypress/fixtures/user-management/permissions.json +++ b/clients/admin-ui/cypress/fixtures/user-management/permissions.json @@ -48,6 +48,9 @@ "custom_field_definition:delete", "custom_field_definition:read", "custom_field_definition:update", + "custom_report:read", + "custom_report:create", + "custom_report:delete", "data_category:create", "data_category:delete", "data_category:read", diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts index e3ec934b0a..4a6c5a9f1e 100644 --- a/clients/admin-ui/src/features/common/api.slice.ts +++ b/clients/admin-ui/src/features/common/api.slice.ts @@ -26,6 +26,7 @@ export const baseApi = createApi({ "Custom Assets", "Custom Field Definition", "Custom Fields", + "Custom Reports", "Data Categories", "Datamap", "Data Subjects", diff --git a/clients/admin-ui/src/features/common/custom-reports/CustomReportCreationModal.tsx b/clients/admin-ui/src/features/common/custom-reports/CustomReportCreationModal.tsx new file mode 100644 index 0000000000..2d66209b7e --- /dev/null +++ b/clients/admin-ui/src/features/common/custom-reports/CustomReportCreationModal.tsx @@ -0,0 +1,142 @@ +import { + AntButton as Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, + Text, + useToast, +} from "fidesui"; +import { Form, Formik } from "formik"; +import { useMemo } from "react"; +import * as Yup from "yup"; + +import { CustomTextInput } from "~/features/common/form/inputs"; +import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; +import { CustomReportResponse, ReportType } from "~/types/api"; + +import { usePostCustomReportMutation } from "../../datamap/reporting/custom-reports.slice"; +import { CustomReportTableState } from "../../datamap/types"; + +const CUSTOM_REPORT_LABEL = "Report name"; + +interface CustomReportCreationModalProps { + isOpen: boolean; + handleClose: () => void; + tableStateToSave: CustomReportTableState | undefined; + columnMapToSave: Record | undefined; + unavailableNames?: string[]; + onCreateCustomReport: (newReport: CustomReportResponse) => void; +} + +export const CustomReportCreationModal = ({ + isOpen, + handleClose, + tableStateToSave, + columnMapToSave, + unavailableNames, + onCreateCustomReport, +}: CustomReportCreationModalProps) => { + const toast = useToast(); + + const [postCustomReportMutationTrigger] = usePostCustomReportMutation(); + + const ValidationSchema = useMemo( + () => + Yup.object().shape({ + reportName: Yup.string() + .label(CUSTOM_REPORT_LABEL) + .required("Please provide a name for this report") + .test("is-unique", "", async (value, context) => { + if (unavailableNames?.includes(value)) { + return context.createError({ + message: `This name already exists`, + }); + } + return true; + }) + .max(80, "Report name is too long (max 80 characters)"), + }), + [unavailableNames], + ); + + const handleCreateReport = async (reportName: string) => { + try { + const newReportTemplate = { + name: reportName.trim(), + type: ReportType.DATAMAP, + config: { + column_map: columnMapToSave, + table_state: tableStateToSave, + }, + }; + const result = await postCustomReportMutationTrigger(newReportTemplate); + if (isErrorResult(result)) { + throw result.error as Error; + } + onCreateCustomReport(result.data); + handleClose(); + } catch (error: any) { + const errorMsg = getErrorMessage( + error, + "A problem occurred while creating the report.", + ); + toast({ status: "error", description: errorMsg }); + } + }; + + return ( + + + + Create a report + handleCreateReport(values.reportName)} + onReset={handleClose} + validateOnBlur={false} + validationSchema={ValidationSchema} + > + {({ dirty, isValid }) => ( +
+ + + Customize and save your current filter settings for easy + access in the future. This reporting customReport will save + the column layout and currently applied filter settings. + + + + + + + +
+ )} +
+
+
+ ); +}; diff --git a/clients/admin-ui/src/features/common/custom-reports/CustomReportTemplates.tsx b/clients/admin-ui/src/features/common/custom-reports/CustomReportTemplates.tsx new file mode 100644 index 0000000000..66d8a99d4e --- /dev/null +++ b/clients/admin-ui/src/features/common/custom-reports/CustomReportTemplates.tsx @@ -0,0 +1,424 @@ +import { + AntButton as Button, + ChevronDownIcon, + ConfirmationModal, + HStack, + IconButton, + Input, + InputGroup, + Popover, + PopoverArrow, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverFooter, + PopoverHeader, + PopoverTrigger, + Portal, + Radio, + RadioGroup, + Skeleton, + Text, + theme, + useDisclosure, + useToast, + VStack, +} from "fidesui"; +import { Form, Formik } from "formik"; +import { useEffect, useMemo, useState } from "react"; + +import { AddIcon } from "~/features/common/custom-fields/icons/AddIcon"; +import { getErrorMessage } from "~/features/common/helpers"; +import { TrashCanOutlineIcon } from "~/features/common/Icon/TrashCanOutlineIcon"; +import { useHasPermission } from "~/features/common/Restrict"; +import { + CustomReportResponse, + CustomReportResponseMinimal, + Page_CustomReportResponseMinimal_, + ReportType, + ScopeRegistryEnum, +} from "~/types/api"; + +import { + useDeleteCustomReportMutation, + useGetMinimalCustomReportsQuery, + useLazyGetCustomReportByIdQuery, +} from "../../datamap/reporting/custom-reports.slice"; +import { CustomReportTableState } from "../../datamap/types"; +import { CustomReportCreationModal } from "./CustomReportCreationModal"; + +const CUSTOM_REPORT_TITLE = "Report"; +const CUSTOM_REPORTS_TITLE = "Reports"; + +interface CustomReportTemplatesProps { + reportType: ReportType; + savedReportId: string; // from local storage + tableStateToSave: CustomReportTableState | undefined; + currentColumnMap?: Record | undefined; + onCustomReportSaved: (customReport: CustomReportResponse | null) => void; + onSavedReportDeleted: () => void; +} + +export const CustomReportTemplates = ({ + reportType, + savedReportId, + tableStateToSave, + currentColumnMap, + onCustomReportSaved, + onSavedReportDeleted, +}: CustomReportTemplatesProps) => { + const userCanSeeReports = useHasPermission([ + ScopeRegistryEnum.CUSTOM_REPORT_READ, + ]); + const userCanCreateReports = useHasPermission([ + ScopeRegistryEnum.CUSTOM_REPORT_CREATE, + ]); + const userCanDeleteReports = useHasPermission([ + ScopeRegistryEnum.CUSTOM_REPORT_DELETE, + ]); + + const toast = useToast({ id: "custom-report-toast" }); + + const { data: customReportsList, isLoading: isCustomReportsLoading } = + useGetMinimalCustomReportsQuery({ report_type: reportType }); + const [searchResults, setSearchResults] = + useState(); + const [getCustomReportByIdTrigger] = useLazyGetCustomReportByIdQuery(); + const [deleteCustomReportMutationTrigger] = useDeleteCustomReportMutation(); + const { + isOpen: popoverIsOpen, + onToggle: popoverOnToggle, + onOpen: popoverOnOpen, + onClose: popoverOnClose, + } = useDisclosure(); + const { + isOpen: modalIsOpen, + onOpen: modalOnOpen, + onClose: modalOnClose, + } = useDisclosure(); + const { + isOpen: deleteIsOpen, + onOpen: onDeleteOpen, + onClose: onDeleteClose, + } = useDisclosure(); + + const [selectedReportId, setSelectedReportId] = useState(); // for the radio buttons + const [fetchedReport, setFetchedReport] = useState(); + const [reportToDelete, setReportToDelete] = + useState(); + const [showSpinner, setShowSpinner] = useState(false); + + const buttonLabel = useMemo(() => { + const reportName = customReportsList?.items.find( + (report) => report.id === savedReportId, + )?.name; + return reportName ?? CUSTOM_REPORTS_TITLE; + }, [customReportsList?.items, savedReportId]); + + const isEmpty = !isCustomReportsLoading && !customReportsList?.items?.length; + + const handleSearch = (searchTerm: string) => { + const results = customReportsList?.items.filter((customReport) => + customReport.name.toLowerCase().includes(searchTerm.toLowerCase()), + ); + setSearchResults(results); + }; + + const handleSelection = async (id: string) => { + setSelectedReportId(id); + const { data, isError, error } = await getCustomReportByIdTrigger(id); + if (isError) { + const errorMsg = getErrorMessage( + error, + `A problem occurred while fetching the ${CUSTOM_REPORT_TITLE}.`, + ); + if (errorMsg.includes("not found")) { + onSavedReportDeleted(); + } + toast({ status: "error", description: errorMsg }); + } else { + setFetchedReport(data); + } + }; + + const handleReset = () => { + setSelectedReportId(""); + setFetchedReport(undefined); + setShowSpinner(false); + }; + + const handleCancel = () => { + if (savedReportId) { + handleSelection(savedReportId); + } else { + handleReset(); + } + popoverOnClose(); + }; + + const handleApplyTemplate = () => { + if (fetchedReport) { + setShowSpinner(false); + if (fetchedReport.id !== savedReportId) { + onCustomReportSaved(fetchedReport); + } + popoverOnClose(); + } else if (selectedReportId) { + // user clicked apply before the report was fetched + setShowSpinner(true); + } else { + // form was reset, apply the reset + onCustomReportSaved(null); + popoverOnClose(); + } + }; + + const handleDeleteReport = async (id: string) => { + if (id === fetchedReport?.id) { + handleReset(); + onSavedReportDeleted(); + } + deleteCustomReportMutationTrigger(id); + }; + + const handleCloseModal = () => { + modalOnClose(); + setTimeout(() => { + // switch back to the popover once the modal has closed + popoverOnOpen(); + }, 100); + }; + + useEffect(() => { + if (customReportsList) { + setSearchResults(customReportsList.items); + } + }, [customReportsList]); + + useEffect(() => { + // If the user clicks the apply button before the report is fetched, the spinner will show. Once the selected report is fetched, stop the spinner and apply the template. + if (showSpinner) { + setShowSpinner(false); + handleApplyTemplate(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchedReport]); + + useEffect(() => { + // When we first load the component, we want to get and apply the saved report id from local storage. + if (savedReportId) { + handleSelection(savedReportId); + } else { + handleReset(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [savedReportId]); + + useEffect(() => { + if (reportToDelete) { + onDeleteOpen(); + } + }, [onDeleteOpen, reportToDelete]); + + const applyDisabled = + (!fetchedReport && !savedReportId) || fetchedReport?.id === savedReportId; + + if (!userCanSeeReports) { + return null; + } + + return ( + <> + + + + + + + + +
+ + + {CUSTOM_REPORTS_TITLE} + + handleSearch(e.target.value)} + /> + + + + + {isEmpty && ( + + } + onClick={modalOnOpen} + data-testid="add-report-button" + /> + + No {CUSTOM_REPORTS_TITLE.toLowerCase()} have been + created. Start by applying your preferred filter and + column settings, then create a + {CUSTOM_REPORT_TITLE.toLowerCase()} for easy access + later. + + + )} + {!isEmpty && + (isCustomReportsLoading ? ( + + + + + + ) : ( + + {searchResults?.map((customReport) => ( + + + {customReport.name} + + {userCanDeleteReports && ( + } + onClick={() => { + setReportToDelete(customReport); + }} + data-testid="delete-report-button" + /> + )} + + ))} + + ))} + + + + {userCanCreateReports && tableStateToSave && ( + + )} + + + + +
+
+
+
+ { + return customReport.name; + })} + onCreateCustomReport={onCustomReportSaved} + /> + { + setReportToDelete(undefined); + onDeleteClose(); + }} + onConfirm={() => { + if (reportToDelete) { + handleDeleteReport(reportToDelete.id); + } + onDeleteClose(); + }} + title={`Delete ${CUSTOM_REPORT_TITLE}`} + message={ + + You are about to permanently delete the{" "} + {CUSTOM_REPORT_TITLE.toLowerCase()} named{" "} + {reportToDelete?.name}. Are you sure you would like + to continue? + + } + /> + + ); +}; diff --git a/clients/admin-ui/src/features/common/form/inputs.tsx b/clients/admin-ui/src/features/common/form/inputs.tsx index c3345e773a..a98922af46 100644 --- a/clients/admin-ui/src/features/common/form/inputs.tsx +++ b/clients/admin-ui/src/features/common/form/inputs.tsx @@ -24,6 +24,7 @@ import { Flex, FormControl, FormErrorMessage, + FormErrorMessageProps, FormLabel, FormLabelProps, forwardRef, @@ -158,16 +159,17 @@ export const ErrorMessage = ({ isInvalid, message, fieldName, + ...props }: { isInvalid: boolean; fieldName: string; message?: string; -}) => { +} & FormErrorMessageProps) => { if (!isInvalid) { return null; } return ( - + {message} ); @@ -568,6 +570,7 @@ export const CustomTextInput = ({ const innerInput = ( diff --git a/clients/admin-ui/src/features/common/modals/StandardDialog.tsx b/clients/admin-ui/src/features/common/modals/StandardDialog.tsx index 9cb7fd89af..c776b99877 100644 --- a/clients/admin-ui/src/features/common/modals/StandardDialog.tsx +++ b/clients/admin-ui/src/features/common/modals/StandardDialog.tsx @@ -42,7 +42,10 @@ const StandardDialog = ({ {heading && {heading}} - + {children && {children}} = { headerText: string; prefixColumns: string[]; tableInstance: TableInstance; + savedCustomReportId: string; onColumnOrderChange: (columns: string[]) => void; + onColumnVisibilityChange: (columnVisibility: Record) => void; }; export const ColumnSettingsModal = ({ @@ -39,7 +41,9 @@ export const ColumnSettingsModal = ({ headerText, tableInstance, prefixColumns, + savedCustomReportId, onColumnOrderChange, + onColumnVisibilityChange, }: ColumnSettingsModalProps) => { const initialColumns = useMemo( () => @@ -68,8 +72,9 @@ export const ColumnSettingsModal = ({ } return aIndex - bIndex; }), + // watch savedCustomReportId so that when a saved report is loaded, we can update these column definitions to match // eslint-disable-next-line react-hooks/exhaustive-deps - [], + [savedCustomReportId], ); const columnEditor = useEditableColumns({ columns: initialColumns, @@ -80,24 +85,23 @@ export const ColumnSettingsModal = ({ ...prefixColumns, ...columnEditor.columns.map((c) => c.id), ]; - onColumnOrderChange(newColumnOrder); - tableInstance.setColumnVisibility( - columnEditor.columns.reduce( - (acc: Record, current: DraggableColumn) => { - // eslint-disable-next-line no-param-reassign - acc[current.id] = current.isVisible; - return acc; - }, - {}, - ), + const newColumnVisibility = columnEditor.columns.reduce( + (acc: Record, current: DraggableColumn) => { + // eslint-disable-next-line no-param-reassign + acc[current.id] = current.isVisible; + return acc; + }, + {}, ); + onColumnOrderChange(newColumnOrder); + onColumnVisibilityChange(newColumnVisibility); onClose(); }, [ onClose, prefixColumns, - tableInstance, columnEditor.columns, onColumnOrderChange, + onColumnVisibilityChange, ]); return ( @@ -105,7 +109,7 @@ export const ColumnSettingsModal = ({ {headerText} - + You can toggle columns on and off to hide or show them in the table. diff --git a/clients/admin-ui/src/features/datamap/Datamap.tsx b/clients/admin-ui/src/features/datamap/Datamap.tsx index ce65902e5f..66adf3d4b9 100644 --- a/clients/admin-ui/src/features/datamap/Datamap.tsx +++ b/clients/admin-ui/src/features/datamap/Datamap.tsx @@ -1,3 +1,8 @@ +/** + * NOTE: This component relates to the Spatial Datamap. + * For the Data Map Report component, see DatamapReportTable.tsx. + */ + import { Box, Center, Flex, Spinner } from "fidesui"; import dynamic from "next/dynamic"; import { useCallback, useContext, useState } from "react"; diff --git a/clients/admin-ui/src/features/datamap/constants.ts b/clients/admin-ui/src/features/datamap/constants.ts index 6e97b0320d..7d1f45a6f7 100644 --- a/clients/admin-ui/src/features/datamap/constants.ts +++ b/clients/admin-ui/src/features/datamap/constants.ts @@ -64,11 +64,13 @@ COLUMN_NAME_MAP[SYSTEM_EGRESS] = "Destination Systems"; // eslint-disable-next-line @typescript-eslint/naming-convention export enum DATAMAP_LOCAL_STORAGE_KEYS { - GROUP_BY = "datamap-group-by", COLUMN_ORDER = "datamap-column-order", - TABLE_GROUPING = "datamap-table-grouping", - TABLE_STATE = "datamap-report-table-state", + COLUMN_VISIBILITY = "datamap-column-visibility", + COLUMN_SIZING = "datamap-column-sizing", COLUMN_EXPANSION_STATE = "datamap-column-expansion-state", + CUSTOM_REPORT_ID = "datamap-custom-report-id", + FILTERS = "datamap-filters", + GROUP_BY = "datamap-group-by", SORTING_STATE = "datamap-sorting-state", WRAPPING_COLUMNS = "datamap-wrapping-columns", } diff --git a/clients/admin-ui/src/features/datamap/datamap.slice.ts b/clients/admin-ui/src/features/datamap/datamap.slice.ts index 53c243c5fe..b36e36b74e 100644 --- a/clients/admin-ui/src/features/datamap/datamap.slice.ts +++ b/clients/admin-ui/src/features/datamap/datamap.slice.ts @@ -107,6 +107,7 @@ const datamapApi = baseApi.injectEndpoints({ dataCategories?: string; dataSubjects?: string; format?: ExportFormat; + report_id?: string; } >({ query: ({ @@ -118,6 +119,7 @@ const datamapApi = baseApi.injectEndpoints({ dataCategories, dataSubjects, format, + report_id, }) => { let queryString = `page=${pageIndex}&size=${pageSize}&group_by=${groupBy}`; if (dataUses) { @@ -132,6 +134,9 @@ const datamapApi = baseApi.injectEndpoints({ if (search) { queryString += `&search=${search}`; } + if (report_id) { + queryString += `&report_id=${report_id}`; + } return { url: `plus/datamap/minimal/${format}?${queryString}`, responseHandler: async (response) => { diff --git a/clients/admin-ui/src/features/datamap/reporting/DatamapReportFilterModal.tsx b/clients/admin-ui/src/features/datamap/reporting/DatamapReportFilterModal.tsx index 552701aa00..b5e76cd607 100644 --- a/clients/admin-ui/src/features/datamap/reporting/DatamapReportFilterModal.tsx +++ b/clients/admin-ui/src/features/datamap/reporting/DatamapReportFilterModal.tsx @@ -8,7 +8,7 @@ import { Box, Heading, } from "fidesui"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useAppSelector } from "~/app/hooks"; import CheckboxTree from "~/features/common/CheckboxTree"; @@ -30,14 +30,11 @@ import { useGetAllDataCategoriesQuery, } from "~/features/taxonomy/taxonomy.slice"; -export type DatamapReportFilterSelections = { - dataUses: string[]; - dataSubjects: string[]; - dataCategories: string[]; -}; +import { DatamapReportFilterSelections } from "../types"; interface DatamapReportFilterModalProps extends Omit { + selectedFilters: DatamapReportFilterSelections; onFilterChange: (selectedFilters: DatamapReportFilterSelections) => void; } @@ -67,21 +64,41 @@ const FilterModalAccordionItem = ({ ); export const DatamapReportFilterModal = ({ - onClose, + selectedFilters, onFilterChange, + onClose, ...props }: DatamapReportFilterModalProps): JSX.Element => { useGetAllDataUsesQuery(); useGetAllDataSubjectsQuery(); useGetAllDataCategoriesQuery(); + const { + dataUses: selectedDataUses, + dataSubjects: selectedDataSubjects, + dataCategories: selectedDataCategories, + } = selectedFilters; + const dataUses = useAppSelector(selectDataUses); const dataSubjects = useAppSelector(selectDataSubjects); const dataCategories = useAppSelector(selectDataCategories); - const [checkedUses, setCheckedUses] = useState([]); - const [checkedSubjects, setCheckedSubjects] = useState([]); - const [checkedCategories, setCheckedCategories] = useState([]); + const [checkedUses, setCheckedUses] = useState(selectedDataUses); + const [checkedSubjects, setCheckedSubjects] = + useState(selectedDataSubjects); + const [checkedCategories, setCheckedCategories] = useState( + selectedDataCategories, + ); + + useEffect(() => { + setCheckedUses(selectedDataUses); + }, [selectedDataUses]); + useEffect(() => { + setCheckedSubjects(selectedDataSubjects); + }, [selectedDataSubjects]); + useEffect(() => { + setCheckedCategories(selectedDataCategories); + }, [selectedDataCategories]); const dataUseNodes: TreeNode[] = useMemo( () => transformTaxonomyEntityToNodes(dataUses), @@ -116,6 +133,7 @@ export const DatamapReportFilterModal = ({ }); onClose(); }; + return ( ; - -const columnHelper = createColumnHelper(); +import { CustomReportTemplates } from "../../common/custom-reports/CustomReportTemplates"; +import { DatamapReportWithCustomFields as DatamapReport } from "./datamap-report"; +import { useDatamapReport } from "./datamap-report-context"; +import { getDatamapReportColumns } from "./DatamapReportTableColumns"; +import { getGrouping, getPrefixColumns } from "./utils"; const emptyMinimalDatamapReportResponse: Page_DatamapReport_ = { items: [], @@ -78,121 +68,7 @@ const emptyMinimalDatamapReportResponse: Page_DatamapReport_ = { pages: 1, }; -// Custom fields are prepended by `system_` or `privacy_declaration_` -const CUSTOM_FIELD_SYSTEM_PREFIX = "system_"; -const CUSTOM_FIELD_DATA_USE_PREFIX = "privacy_declaration_"; - -// eslint-disable-next-line @typescript-eslint/naming-convention -enum COLUMN_IDS { - SYSTEM_NAME = "system_name", - DATA_USE = "data_use", - DATA_CATEGORY = "data_categories", - DATA_SUBJECT = "data_subjects", - LEGAL_NAME = "legal_name", - DPO = "dpo", - LEGAL_BASIS_FOR_PROCESSING = "legal_basis_for_processing", - ADMINISTRATING_DEPARTMENT = "administrating_department", - COOKIE_MAX_AGE_SECONDS = "cookie_max_age_seconds", - PRIVACY_POLICY = "privacy_policy", - LEGAL_ADDRESS = "legal_address", - COOKIE_REFRESH = "cookie_refresh", - DATA_SECURITY_PRACTICES = "data_security_practices", - DATA_SHARED_WITH_THIRD_PARTIES = "DATA_SHARED_WITH_THIRD_PARTIES", - DATA_STEWARDS = "data_stewards", - DECLARATION_NAME = "declaration_name", - DESCRIPTION = "description", - DOES_INTERNATIONAL_TRANSFERS = "does_international_transfers", - DPA_LOCATION = "dpa_location", - DESTINATIONS = "egress", - EXEMPT_FROM_PRIVACY_REGULATIONS = "exempt_from_privacy_regulations", - FEATURES = "features", - FIDES_KEY = "fides_key", - FLEXIBLE_LEGAL_BASIS_FOR_PROCESSING = "flexible_legal_basis_for_processing", - IMPACT_ASSESSMENT_LOCATION = "impact_assessment_location", - SOURCES = "ingress", - JOINT_CONTROLLER_INFO = "joint_controller_info", - LEGAL_BASIS_FOR_PROFILING = "legal_basis_for_profiling", - LEGAL_BASIS_FOR_TRANSFERS = "legal_basis_for_transfers", - LEGITIMATE_INTEREST_DISCLOSURE_URL = "legitimate_interest_disclosure_url", - LINK_TO_PROCESSOR_CONTRACT = "link_to_processor_contract", - PROCESSES_PERSONAL_DATA = "processes_personal_data", - REASON_FOR_EXEMPTION = "reason_for_exemption", - REQUIRES_DATA_PROTECTION_ASSESSMENTS = "requires_data_protection_assessments", - RESPONSIBILITY = "responsibility", - RETENTION_PERIOD = "retention_period", - SHARED_CATEGORIES = "shared_categories", - SPECIAL_CATEGORY_LEGAL_BASIS = "special_category_legal_basis", - SYSTEM_DEPENDENCIES = "system_dependencies", - THIRD_COUNTRY_SAFEGUARDS = "third_country_safeguards", - THIRD_PARTIES = "third_parties", - COOKIES = "cookies", - USES_COOKIES = "uses_cookies", - USES_NON_COOKIE_ACCESS = "uses_non_cookie_access", - USES_PROFILING = "uses_profiling", - SYSTEM_UNDECLARED_DATA_CATEGORIES = "system_undeclared_data_categories", - DATA_USE_UNDECLARED_DATA_CATEGORIES = "data_use_undeclared_data_categories", -} - -const getGrouping = (groupBy: DATAMAP_GROUPING) => { - let grouping: string[] = []; - switch (groupBy) { - case DATAMAP_GROUPING.SYSTEM_DATA_USE: { - grouping = [COLUMN_IDS.SYSTEM_NAME]; - break; - } - case DATAMAP_GROUPING.DATA_USE_SYSTEM: { - grouping = [COLUMN_IDS.DATA_USE]; - break; - } - default: - grouping = [COLUMN_IDS.SYSTEM_NAME]; - } - return grouping; -}; -const getColumnOrder = (groupBy: DATAMAP_GROUPING) => { - let columnOrder: string[] = []; - if (DATAMAP_GROUPING.SYSTEM_DATA_USE === groupBy) { - columnOrder = [ - COLUMN_IDS.SYSTEM_NAME, - COLUMN_IDS.DATA_USE, - COLUMN_IDS.DATA_CATEGORY, - COLUMN_IDS.DATA_SUBJECT, - ]; - } - if (DATAMAP_GROUPING.DATA_USE_SYSTEM === groupBy) { - columnOrder = [ - COLUMN_IDS.DATA_USE, - COLUMN_IDS.SYSTEM_NAME, - COLUMN_IDS.DATA_CATEGORY, - COLUMN_IDS.DATA_SUBJECT, - ]; - } - return columnOrder; -}; - -const getPrefixColumns = (groupBy: DATAMAP_GROUPING) => { - let columnOrder: string[] = []; - if (DATAMAP_GROUPING.SYSTEM_DATA_USE === groupBy) { - columnOrder = [COLUMN_IDS.SYSTEM_NAME, COLUMN_IDS.DATA_USE]; - } - if (DATAMAP_GROUPING.DATA_USE_SYSTEM === groupBy) { - columnOrder = [COLUMN_IDS.DATA_USE, COLUMN_IDS.SYSTEM_NAME]; - } - return columnOrder; -}; - export const DatamapReportTable = () => { - const [tableState, setTableState] = useLocalStorage( - "datamap-report-table-state", - undefined, - ); - const storedTableState = useMemo( - // snag the stored table state from local storage if it exists and use it to initialize the tableInstance. - // memoize this so we don't get stuck in a loop as the tableState gets updated during the session. - () => tableState, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); const { isLoading: isLoadingHealthCheck } = useGetHealthQuery(); const { PAGE_SIZES, @@ -208,6 +84,7 @@ export const DatamapReportTable = () => { setTotalPages, resetPageIndexToDefault, } = useServerSidePagination(); + const toast = useToast({ id: "datamap-report-toast" }); const { isOpen: isFilterModalOpen, @@ -222,15 +99,31 @@ export const DatamapReportTable = () => { isLoading: isLoadingFidesLang, } = useTaxonomies(); - const [selectedDataUseFilters, setSelectedDataUseFilters] = - useState(); - const [selectedDataCategoriesFilters, setSelectedDataCategoriesFilters] = - useState(); - const [selectedDataSubjectFilters, setSelectedDataSubjectFilters] = - useState(); - const [selectedSystemId, setSelectedSystemId] = useState(); + const [selectedSystemId, setSelectedSystemId] = useState(); // for opening the drawer + + const { + savedCustomReportId, + setSavedCustomReportId, + groupBy, + setGroupBy, + selectedFilters, + setSelectedFilters, + columnOrder, + setColumnOrder, + columnVisibility, + setColumnVisibility, + columnSizing, + setColumnSizing, + } = useDatamapReport(); const [groupChangeStarted, setGroupChangeStarted] = useState(false); + const onGroupChange = (group: DATAMAP_GROUPING) => { + setSavedCustomReportId(""); + setGroupBy(group); + setGroupChangeStarted(true); + resetPageIndexToDefault(); + }; + const [globalFilter, setGlobalFilter] = useState(""); const updateGlobalFilter = useCallback( (searchTerm: string) => { @@ -240,40 +133,27 @@ export const DatamapReportTable = () => { [resetPageIndexToDefault, setGlobalFilter], ); - const [groupBy, setGroupBy] = useLocalStorage( - DATAMAP_LOCAL_STORAGE_KEYS.GROUP_BY, - DATAMAP_GROUPING.SYSTEM_DATA_USE, - ); - - const [columnOrder, setColumnOrder] = useLocalStorage( - DATAMAP_LOCAL_STORAGE_KEYS.COLUMN_ORDER, - getColumnOrder(groupBy), - ); - - const [grouping, setGrouping] = useLocalStorage( - DATAMAP_LOCAL_STORAGE_KEYS.TABLE_GROUPING, - getGrouping(groupBy), - ); - - const onGroupChange = (group: DATAMAP_GROUPING) => { - setGroupBy(group); - setGroupChangeStarted(true); - resetPageIndexToDefault(); + const reportQuery = { + pageIndex, + pageSize, + groupBy, + search: globalFilter, + dataUses: getQueryParamsFromArray(selectedFilters.dataUses, "data_uses"), + dataSubjects: getQueryParamsFromArray( + selectedFilters.dataSubjects, + "data_subjects", + ), + dataCategories: getQueryParamsFromArray( + selectedFilters.dataCategories, + "data_categories", + ), }; const { data: datamapReport, isLoading: isReportLoading, isFetching: isReportFetching, - } = useGetMinimalDatamapReportQuery({ - pageIndex, - pageSize, - groupBy, - search: globalFilter, - dataUses: selectedDataUseFilters, - dataSubjects: selectedDataSubjectFilters, - dataCategories: selectedDataCategoriesFilters, - }); + } = useGetMinimalDatamapReportQuery(reportQuery); const [ exportMinimalDatamapReport, @@ -298,727 +178,26 @@ export const DatamapReportTable = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [datamapReport]); - useEffect(() => { - // changing the groupBy should wait until the data is loaded to update the grouping - const newGrouping = getGrouping(groupBy); - if (datamapReport) { - setGrouping(newGrouping); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [datamapReport]); - // Get custom fields useGetAllCustomFieldDefinitionsQuery(); const customFields = useAppSelector(selectAllCustomFieldDefinitions); - const customFieldColumns = useMemo(() => { - // Determine custom field keys by - // 1. If they aren't in our expected, static, columns - // 2. If they start with one of the custom field prefixes - const datamapKeys = datamapReport?.items?.length - ? Object.keys(datamapReport.items[0]) - : []; - const defaultKeys = Object.values(COLUMN_IDS); - const customFieldKeys = datamapKeys - .filter((k) => !defaultKeys.includes(k as COLUMN_IDS)) - .filter( - (k) => - k.startsWith(CUSTOM_FIELD_DATA_USE_PREFIX) || - k.startsWith(CUSTOM_FIELD_SYSTEM_PREFIX), - ); - - // Create column objects for each custom field key - const columns = customFieldKeys.map((key) => { - // We need to figure out the original custom field object in order to see - // if the value is a string[], which would want `showHeaderMenu=true` - const customField = customFields.find((cf) => - key.includes(_.snakeCase(cf.name)), - ); - const keyWithoutPrefix = key.replace( - /^(system_|privacy_declaration_)/, - "", - ); - const displayText = _.upperFirst(keyWithoutPrefix.replaceAll("_", " ")); - return columnHelper.accessor((row) => row[key], { - id: key, - cell: (props) => - // Conditionally render the Group cell if we have more than one value. - // Alternatively, could check the customField type - Array.isArray(props.getValue()) ? ( - - ) : ( - - ), - header: (props) => , - meta: { - displayText, - showHeaderMenu: customField?.field_type === "string[]", - }, - }); - }); - - return columns; - }, [datamapReport, customFields]); - - const tcfColumns = useMemo( - () => [ - columnHelper.accessor((row) => row.system_name, { - enableGrouping: true, - id: COLUMN_IDS.SYSTEM_NAME, - cell: (props) => , - header: (props) => , - meta: { - displayText: "System", - onCellClick: (row) => { - setSelectedSystemId(row.fides_key); - }, - }, - }), - columnHelper.accessor((row) => row.data_uses, { - id: COLUMN_IDS.DATA_USE, - cell: (props) => { - const value = props.getValue(); - return ( - - ); - }, - header: (props) => , - meta: { - displayText: "Data use", - width: "auto", - }, - }), - columnHelper.accessor((row) => row.data_categories, { - id: COLUMN_IDS.DATA_CATEGORY, - cell: (props) => { - const cellValues = props.getValue(); - if (!cellValues || cellValues.length === 0) { - return null; - } - const values = isArray(cellValues) - ? cellValues.map((value) => { - return { label: getDataCategoryDisplayName(value), key: value }; - }) - : [ - { - label: getDataCategoryDisplayName(cellValues), - key: cellValues, - }, - ]; - return ( - - ); - }, - header: (props) => ( - - ), - meta: { - displayText: "Data categories", - showHeaderMenu: true, - showHeaderMenuWrapOption: true, - width: "auto", - }, - }), - columnHelper.accessor((row) => row.data_subjects, { - id: COLUMN_IDS.DATA_SUBJECT, - cell: (props) => { - const value = props.getValue(); - - return ( - - ); - }, - header: (props) => ( - - ), - meta: { - displayText: "Data subject", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.legal_name, { - id: COLUMN_IDS.LEGAL_NAME, - cell: (props) => , - header: (props) => , - meta: { - displayText: "Legal name", - }, - }), - columnHelper.accessor((row) => row.dpo, { - id: COLUMN_IDS.DPO, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Data privacy officer", - }, - }), - columnHelper.accessor((row) => row.legal_basis_for_processing, { - id: COLUMN_IDS.LEGAL_BASIS_FOR_PROCESSING, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Legal basis for processing", - }, - }), - columnHelper.accessor((row) => row.administrating_department, { - id: COLUMN_IDS.ADMINISTRATING_DEPARTMENT, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Administrating department", - }, - }), - columnHelper.accessor((row) => row.cookie_max_age_seconds, { - id: COLUMN_IDS.COOKIE_MAX_AGE_SECONDS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Cookie max age seconds", - }, - }), - columnHelper.accessor((row) => row.privacy_policy, { - id: COLUMN_IDS.PRIVACY_POLICY, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Privacy policy", - }, - }), - columnHelper.accessor((row) => row.legal_address, { - id: COLUMN_IDS.LEGAL_ADDRESS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Legal address", - }, - }), - columnHelper.accessor((row) => row.cookie_refresh, { - id: COLUMN_IDS.COOKIE_REFRESH, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Cookie refresh", - }, - }), - columnHelper.accessor((row) => row.data_security_practices, { - id: COLUMN_IDS.DATA_SECURITY_PRACTICES, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Data security practices", - }, - }), - columnHelper.accessor((row) => row.data_shared_with_third_parties, { - id: COLUMN_IDS.DATA_SHARED_WITH_THIRD_PARTIES, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Data shared with third parties", - }, - }), - columnHelper.accessor((row) => row.data_stewards, { - id: COLUMN_IDS.DATA_STEWARDS, - cell: (props) => ( - - ), - header: (props) => ( - - ), - meta: { - displayText: "Data stewards", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.declaration_name, { - id: COLUMN_IDS.DECLARATION_NAME, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Declaration name", - }, - }), - columnHelper.accessor((row) => row.does_international_transfers, { - id: COLUMN_IDS.DOES_INTERNATIONAL_TRANSFERS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Does international transfers", - }, - }), - columnHelper.accessor((row) => row.dpa_location, { - id: COLUMN_IDS.DPA_LOCATION, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "DPA Location", - }, - }), - columnHelper.accessor((row) => row.egress, { - id: COLUMN_IDS.DESTINATIONS, - cell: (props) => ( - - ), - header: (props) => ( - - ), - meta: { - displayText: "Destinations", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.exempt_from_privacy_regulations, { - id: COLUMN_IDS.EXEMPT_FROM_PRIVACY_REGULATIONS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Exempt from privacy regulations", - }, - }), - columnHelper.accessor((row) => row.features, { - id: COLUMN_IDS.FEATURES, - cell: (props) => ( - - ), - header: (props) => , - meta: { - displayText: "Features", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.fides_key, { - id: COLUMN_IDS.FIDES_KEY, - cell: (props) => , - header: (props) => , - meta: { - displayText: "Fides key", - }, - }), - columnHelper.accessor((row) => row.flexible_legal_basis_for_processing, { - id: COLUMN_IDS.FLEXIBLE_LEGAL_BASIS_FOR_PROCESSING, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Flexible legal basis for processing", - }, - }), - columnHelper.accessor((row) => row.impact_assessment_location, { - id: COLUMN_IDS.IMPACT_ASSESSMENT_LOCATION, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Impact assessment location", - }, - }), - columnHelper.accessor((row) => row.ingress, { - id: COLUMN_IDS.SOURCES, - cell: (props) => ( - - ), - header: (props) => , - meta: { - displayText: "Sources", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.joint_controller_info, { - id: COLUMN_IDS.JOINT_CONTROLLER_INFO, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Joint controller info", - }, - }), - columnHelper.accessor((row) => row.legal_basis_for_profiling, { - id: COLUMN_IDS.LEGAL_BASIS_FOR_PROFILING, - cell: (props) => ( - - ), - header: (props) => ( - - ), - meta: { - displayText: "Legal basis for profiling", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.legal_basis_for_transfers, { - id: COLUMN_IDS.LEGAL_BASIS_FOR_TRANSFERS, - cell: (props) => ( - - ), - header: (props) => ( - - ), - meta: { - displayText: "Legal basis for transfers", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.legitimate_interest_disclosure_url, { - id: COLUMN_IDS.LEGITIMATE_INTEREST_DISCLOSURE_URL, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Legitimate interest disclosure url", - }, - }), - columnHelper.accessor((row) => row.link_to_processor_contract, { - id: COLUMN_IDS.LINK_TO_PROCESSOR_CONTRACT, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Link to processor contract", - }, - }), - columnHelper.accessor((row) => row.processes_personal_data, { - id: COLUMN_IDS.PROCESSES_PERSONAL_DATA, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Processes personal data", - }, - }), - columnHelper.accessor((row) => row.reason_for_exemption, { - id: COLUMN_IDS.REASON_FOR_EXEMPTION, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Reason for exemption", - }, - }), - columnHelper.accessor((row) => row.requires_data_protection_assessments, { - id: COLUMN_IDS.REQUIRES_DATA_PROTECTION_ASSESSMENTS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Requires data protection assessments", - }, - }), - columnHelper.accessor((row) => row.responsibility, { - id: COLUMN_IDS.RESPONSIBILITY, - cell: (props) => ( - - ), - header: (props) => ( - - ), - meta: { - displayText: "Responsibility", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.retention_period, { - id: COLUMN_IDS.RETENTION_PERIOD, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Retention period", - }, - }), - columnHelper.accessor((row) => row.shared_categories, { - id: COLUMN_IDS.SHARED_CATEGORIES, - cell: (props) => ( - - ), - header: (props) => ( - - ), - meta: { - displayText: "Shared categories", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.special_category_legal_basis, { - id: COLUMN_IDS.SPECIAL_CATEGORY_LEGAL_BASIS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Special category legal basis", - }, - }), - columnHelper.accessor((row) => row.system_dependencies, { - id: COLUMN_IDS.SYSTEM_DEPENDENCIES, - cell: (props) => ( - - ), - header: (props) => ( - - ), - meta: { - displayText: "System dependencies", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.third_country_safeguards, { - id: COLUMN_IDS.THIRD_COUNTRY_SAFEGUARDS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Third country safeguards", - }, - }), - columnHelper.accessor((row) => row.third_parties, { - id: COLUMN_IDS.THIRD_PARTIES, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Third parties", - }, - }), - columnHelper.accessor((row) => row.system_undeclared_data_categories, { - id: COLUMN_IDS.SYSTEM_UNDECLARED_DATA_CATEGORIES, - cell: (props) => { - const value = props.getValue(); - - return ( - - ); - }, - header: (props) => ( - - ), - meta: { - displayText: "System undeclared data categories", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.data_use_undeclared_data_categories, { - id: COLUMN_IDS.DATA_USE_UNDECLARED_DATA_CATEGORIES, - cell: (props) => { - const value = props.getValue(); - - return ( - - ); - }, - header: (props) => ( - - ), - meta: { - displayText: "Data use undeclared data categories", - showHeaderMenu: true, - }, + const columns = useMemo( + () => + getDatamapReportColumns({ + onSelectRow: (row) => setSelectedSystemId(row.fides_key), + getDataUseDisplayName, + getDataCategoryDisplayName, + getDataSubjectDisplayName, + datamapReport, + customFields, }), - columnHelper.accessor((row) => row.cookies, { - id: COLUMN_IDS.COOKIES, - cell: (props) => ( - - ), - header: (props) => , - meta: { - displayText: "Cookies", - showHeaderMenu: true, - }, - }), - columnHelper.accessor((row) => row.uses_cookies, { - id: COLUMN_IDS.USES_COOKIES, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Uses cookies", - }, - }), - columnHelper.accessor((row) => row.uses_non_cookie_access, { - id: COLUMN_IDS.USES_NON_COOKIE_ACCESS, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Uses non cookie access", - }, - }), - columnHelper.accessor((row) => row.uses_profiling, { - id: COLUMN_IDS.USES_PROFILING, - cell: (props) => , - header: (props) => ( - - ), - meta: { - displayText: "Uses profiling", - }, - }), - // Tack on the custom field columns to the end - ...customFieldColumns, - ], [ - customFieldColumns, getDataUseDisplayName, getDataSubjectDisplayName, getDataCategoryDisplayName, + datamapReport, + customFields, ], ); @@ -1036,14 +215,9 @@ export const DatamapReportTable = () => { const onExport = (downloadType: ExportFormat) => { exportMinimalDatamapReport({ - pageIndex, - pageSize, - groupBy, - search: globalFilter, - dataUses: selectedDataUseFilters, - dataSubjects: selectedDataSubjectFilters, - dataCategories: selectedDataCategoriesFilters, + ...reportQuery, format: downloadType, + report_id: savedCustomReportId, }).then(() => { if (isExportReportSuccess) { onExportReportClose(); @@ -1055,32 +229,38 @@ export const DatamapReportTable = () => { getCoreRowModel: getCoreRowModel(), getGroupedRowModel: getGroupedRowModel(), getExpandedRowModel: getExpandedRowModel(), - columns: tcfColumns, manualPagination: true, + enableColumnResizing: true, + columnResizeMode: "onChange", + columns, data, initialState: { - columnVisibility: { - [COLUMN_IDS.SYSTEM_UNDECLARED_DATA_CATEGORIES]: false, - [COLUMN_IDS.DATA_USE_UNDECLARED_DATA_CATEGORIES]: false, - }, - ...storedTableState, - }, - state: { expanded: true, - grouping, + columnSizing, columnOrder, - }, - columnResizeMode: "onChange", - enableColumnResizing: true, - onStateChange: (updater) => { - const valueToStore = - updater instanceof Function - ? updater(tableInstance.getState()) - : updater; - setTableState(valueToStore); + columnVisibility, + grouping: getGrouping(groupBy), }, }); + useEffect(() => { + // changing the groupBy should wait until the data is loaded to update the grouping + const newGrouping = getGrouping(groupBy); + if (datamapReport) { + tableInstance.setGrouping(newGrouping); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [datamapReport]); + + useEffect(() => { + // update stored column sizing when it changes + const colSizing = tableInstance.getState().columnSizing; + if (colSizing) { + setColumnSizing(colSizing); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tableInstance.getState().columnSizing]); + const getMenuDisplayValue = () => { switch (groupBy) { case DATAMAP_GROUPING.SYSTEM_DATA_USE: { @@ -1095,28 +275,62 @@ export const DatamapReportTable = () => { } }; + const handleSavedReport = (savedReport: CustomReportResponse | null) => { + if (!savedReport) { + setSavedCustomReportId(""); + return; + } + try { + if (savedReport.config?.table_state) { + const { + groupBy: savedGroupBy, + filters: savedFilters, + columnOrder: savedColumnOrder, + columnVisibility: savedColumnVisibility, + } = savedReport.config.table_state; + if (savedGroupBy) { + setGroupBy(savedGroupBy); + tableInstance.setGrouping(getGrouping(savedGroupBy)); + } + if (savedFilters) { + setSelectedFilters(savedFilters); + } + if (savedColumnOrder) { + setColumnOrder(savedColumnOrder); + tableInstance.setColumnOrder(savedColumnOrder); + } + if (savedColumnVisibility) { + setColumnVisibility(savedColumnVisibility); + tableInstance.setColumnVisibility(savedColumnVisibility); + } + } + setSavedCustomReportId(savedReport.id); + toast({ + status: "success", + description: "Report applied successfully.", + }); + } catch (error: any) { + toast({ + status: "error", + description: "There was a problem applying report.", + }); + } + }; + if (isReportLoading || isLoadingHealthCheck || isLoadingFidesLang) { return ; } - const handleFilterChange = (newFilters: DatamapReportFilterSelections) => { - setSelectedDataUseFilters( - getQueryParamsFromArray(newFilters.dataUses, "data_uses"), - ); - setSelectedDataCategoriesFilters( - getQueryParamsFromArray(newFilters.dataCategories, "data_categories"), - ); - setSelectedDataSubjectFilters( - getQueryParamsFromArray(newFilters.dataSubjects, "data_subjects"), - ); - }; - return ( { + setSavedCustomReportId(""); + setSelectedFilters(newFilters); + }} /> isOpen={isColumnSettingsOpen} @@ -1124,9 +338,17 @@ export const DatamapReportTable = () => { headerText="Data map settings" prefixColumns={getPrefixColumns(groupBy)} tableInstance={tableInstance} + savedCustomReportId={savedCustomReportId} onColumnOrderChange={(newColumnOrder) => { + setSavedCustomReportId(""); + tableInstance.setColumnOrder(newColumnOrder); setColumnOrder(newColumnOrder); }} + onColumnVisibilityChange={(newColumnVisibility) => { + setSavedCustomReportId(""); + tableInstance.setColumnVisibility(newColumnVisibility); + setColumnVisibility(newColumnVisibility); + }} /> { placeholder="System name, Fides key, or ID" /> + { + setSavedCustomReportId(""); + }} + /> (); + +// eslint-disable-next-line @typescript-eslint/naming-convention +export enum COLUMN_IDS { + SYSTEM_NAME = "system_name", + DATA_USE = "data_use", + DATA_CATEGORY = "data_categories", + DATA_SUBJECT = "data_subjects", + LEGAL_NAME = "legal_name", + DPO = "dpo", + LEGAL_BASIS_FOR_PROCESSING = "legal_basis_for_processing", + ADMINISTRATING_DEPARTMENT = "administrating_department", + COOKIE_MAX_AGE_SECONDS = "cookie_max_age_seconds", + PRIVACY_POLICY = "privacy_policy", + LEGAL_ADDRESS = "legal_address", + COOKIE_REFRESH = "cookie_refresh", + DATA_SECURITY_PRACTICES = "data_security_practices", + DATA_SHARED_WITH_THIRD_PARTIES = "DATA_SHARED_WITH_THIRD_PARTIES", + DATA_STEWARDS = "data_stewards", + DECLARATION_NAME = "declaration_name", + DESCRIPTION = "description", + DOES_INTERNATIONAL_TRANSFERS = "does_international_transfers", + DPA_LOCATION = "dpa_location", + DESTINATIONS = "egress", + EXEMPT_FROM_PRIVACY_REGULATIONS = "exempt_from_privacy_regulations", + FEATURES = "features", + FIDES_KEY = "fides_key", + FLEXIBLE_LEGAL_BASIS_FOR_PROCESSING = "flexible_legal_basis_for_processing", + IMPACT_ASSESSMENT_LOCATION = "impact_assessment_location", + SOURCES = "ingress", + JOINT_CONTROLLER_INFO = "joint_controller_info", + LEGAL_BASIS_FOR_PROFILING = "legal_basis_for_profiling", + LEGAL_BASIS_FOR_TRANSFERS = "legal_basis_for_transfers", + LEGITIMATE_INTEREST_DISCLOSURE_URL = "legitimate_interest_disclosure_url", + LINK_TO_PROCESSOR_CONTRACT = "link_to_processor_contract", + PROCESSES_PERSONAL_DATA = "processes_personal_data", + REASON_FOR_EXEMPTION = "reason_for_exemption", + REQUIRES_DATA_PROTECTION_ASSESSMENTS = "requires_data_protection_assessments", + RESPONSIBILITY = "responsibility", + RETENTION_PERIOD = "retention_period", + SHARED_CATEGORIES = "shared_categories", + SPECIAL_CATEGORY_LEGAL_BASIS = "special_category_legal_basis", + SYSTEM_DEPENDENCIES = "system_dependencies", + THIRD_COUNTRY_SAFEGUARDS = "third_country_safeguards", + THIRD_PARTIES = "third_parties", + COOKIES = "cookies", + USES_COOKIES = "uses_cookies", + USES_NON_COOKIE_ACCESS = "uses_non_cookie_access", + USES_PROFILING = "uses_profiling", + SYSTEM_UNDECLARED_DATA_CATEGORIES = "system_undeclared_data_categories", + DATA_USE_UNDECLARED_DATA_CATEGORIES = "data_use_undeclared_data_categories", +} + +// Custom fields are prepended by `system_` or `privacy_declaration_` +const CUSTOM_FIELD_SYSTEM_PREFIX = "system_"; +const CUSTOM_FIELD_DATA_USE_PREFIX = "privacy_declaration_"; + +const getCustomFieldColumns = ( + datamapReport: Page_DatamapReport_ | undefined, + customFields: CustomFieldDefinitionWithId[], +) => { + // Determine custom field keys by + // 1. If they aren't in our expected, static, columns + // 2. If they start with one of the custom field prefixes + const datamapKeys = datamapReport?.items?.length + ? Object.keys(datamapReport.items[0]) + : []; + const defaultKeys = Object.values(COLUMN_IDS); + const customFieldKeys = datamapKeys + .filter((k) => !defaultKeys.includes(k as COLUMN_IDS)) + .filter( + (k) => + k.startsWith(CUSTOM_FIELD_DATA_USE_PREFIX) || + k.startsWith(CUSTOM_FIELD_SYSTEM_PREFIX), + ); + + // Create column objects for each custom field key + const customColumns = customFieldKeys.map((key) => { + // We need to figure out the original custom field object in order to see + // if the value is a string[], which would want `showHeaderMenu=true` + const customField = customFields.find((cf) => + key.includes(_.snakeCase(cf.name)), + ); + const keyWithoutPrefix = key.replace(/^(system_|privacy_declaration_)/, ""); + const displayText = _.upperFirst(keyWithoutPrefix.replaceAll("_", " ")); + return columnHelper.accessor((row) => row[key], { + id: key, + cell: (props) => + // Conditionally render the Group cell if we have more than one value. + // Alternatively, could check the customField type + Array.isArray(props.getValue()) ? ( + + ) : ( + + ), + header: (props) => , + meta: { + displayText, + showHeaderMenu: customField?.field_type === "string[]", + }, + }); + }); + + return customColumns; +}; + +interface DatamapReportColumnProps { + onSelectRow: (row: DatamapReport) => void; + getDataUseDisplayName: (dataUseKey: string) => ReactNode; + getDataCategoryDisplayName: (dataCategoryKey: string) => string | JSX.Element; + getDataSubjectDisplayName: (dataSubjectKey: string) => ReactNode; + datamapReport: Page_DatamapReport_ | undefined; + customFields: CustomFieldDefinitionWithId[]; +} +export const getDatamapReportColumns = ({ + onSelectRow, + getDataUseDisplayName, + getDataCategoryDisplayName, + getDataSubjectDisplayName, + datamapReport, + customFields, +}: DatamapReportColumnProps) => { + const customFieldColumns = getCustomFieldColumns(datamapReport, customFields); + return [ + columnHelper.accessor((row) => row.system_name, { + enableGrouping: true, + id: COLUMN_IDS.SYSTEM_NAME, + cell: (props) => , + header: (props) => , + meta: { + displayText: "System", + onCellClick: onSelectRow, + }, + }), + columnHelper.accessor((row) => row.data_uses, { + id: COLUMN_IDS.DATA_USE, + cell: (props) => { + const value = props.getValue(); + return ( + + ); + }, + header: (props) => , + meta: { + displayText: "Data use", + width: "auto", + }, + }), + columnHelper.accessor((row) => row.data_categories, { + id: COLUMN_IDS.DATA_CATEGORY, + cell: (props) => { + const cellValues = props.getValue(); + if (!cellValues || cellValues.length === 0) { + return null; + } + const values = isArray(cellValues) + ? cellValues.map((value) => { + return { label: getDataCategoryDisplayName(value), key: value }; + }) + : [ + { + label: getDataCategoryDisplayName(cellValues), + key: cellValues, + }, + ]; + return ( + + ); + }, + header: (props) => ( + + ), + meta: { + displayText: "Data categories", + showHeaderMenu: true, + showHeaderMenuWrapOption: true, + width: "auto", + }, + }), + columnHelper.accessor((row) => row.data_subjects, { + id: COLUMN_IDS.DATA_SUBJECT, + cell: (props) => { + const value = props.getValue(); + + return ( + + ); + }, + header: (props) => , + meta: { + displayText: "Data subject", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.legal_name, { + id: COLUMN_IDS.LEGAL_NAME, + cell: (props) => , + header: (props) => , + meta: { + displayText: "Legal name", + }, + }), + columnHelper.accessor((row) => row.dpo, { + id: COLUMN_IDS.DPO, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Data privacy officer", + }, + }), + columnHelper.accessor((row) => row.legal_basis_for_processing, { + id: COLUMN_IDS.LEGAL_BASIS_FOR_PROCESSING, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Legal basis for processing", + }, + }), + columnHelper.accessor((row) => row.administrating_department, { + id: COLUMN_IDS.ADMINISTRATING_DEPARTMENT, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Administrating department", + }, + }), + columnHelper.accessor((row) => row.cookie_max_age_seconds, { + id: COLUMN_IDS.COOKIE_MAX_AGE_SECONDS, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Cookie max age seconds", + }, + }), + columnHelper.accessor((row) => row.privacy_policy, { + id: COLUMN_IDS.PRIVACY_POLICY, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Privacy policy", + }, + }), + columnHelper.accessor((row) => row.legal_address, { + id: COLUMN_IDS.LEGAL_ADDRESS, + cell: (props) => , + header: (props) => , + meta: { + displayText: "Legal address", + }, + }), + columnHelper.accessor((row) => row.cookie_refresh, { + id: COLUMN_IDS.COOKIE_REFRESH, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Cookie refresh", + }, + }), + columnHelper.accessor((row) => row.data_security_practices, { + id: COLUMN_IDS.DATA_SECURITY_PRACTICES, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Data security practices", + }, + }), + columnHelper.accessor((row) => row.data_shared_with_third_parties, { + id: COLUMN_IDS.DATA_SHARED_WITH_THIRD_PARTIES, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Data shared with third parties", + }, + }), + columnHelper.accessor((row) => row.data_stewards, { + id: COLUMN_IDS.DATA_STEWARDS, + cell: (props) => ( + + ), + header: (props) => , + meta: { + displayText: "Data stewards", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.declaration_name, { + id: COLUMN_IDS.DECLARATION_NAME, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Declaration name", + }, + }), + columnHelper.accessor((row) => row.does_international_transfers, { + id: COLUMN_IDS.DOES_INTERNATIONAL_TRANSFERS, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Does international transfers", + }, + }), + columnHelper.accessor((row) => row.dpa_location, { + id: COLUMN_IDS.DPA_LOCATION, + cell: (props) => , + header: (props) => , + meta: { + displayText: "DPA Location", + }, + }), + columnHelper.accessor((row) => row.egress, { + id: COLUMN_IDS.DESTINATIONS, + cell: (props) => ( + + ), + header: (props) => , + meta: { + displayText: "Destinations", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.exempt_from_privacy_regulations, { + id: COLUMN_IDS.EXEMPT_FROM_PRIVACY_REGULATIONS, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Exempt from privacy regulations", + }, + }), + columnHelper.accessor((row) => row.features, { + id: COLUMN_IDS.FEATURES, + cell: (props) => ( + + ), + header: (props) => , + meta: { + displayText: "Features", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.fides_key, { + id: COLUMN_IDS.FIDES_KEY, + cell: (props) => , + header: (props) => , + meta: { + displayText: "Fides key", + }, + }), + columnHelper.accessor((row) => row.flexible_legal_basis_for_processing, { + id: COLUMN_IDS.FLEXIBLE_LEGAL_BASIS_FOR_PROCESSING, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Flexible legal basis for processing", + }, + }), + columnHelper.accessor((row) => row.impact_assessment_location, { + id: COLUMN_IDS.IMPACT_ASSESSMENT_LOCATION, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Impact assessment location", + }, + }), + columnHelper.accessor((row) => row.ingress, { + id: COLUMN_IDS.SOURCES, + cell: (props) => ( + + ), + header: (props) => , + meta: { + displayText: "Sources", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.joint_controller_info, { + id: COLUMN_IDS.JOINT_CONTROLLER_INFO, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Joint controller info", + }, + }), + columnHelper.accessor((row) => row.legal_basis_for_profiling, { + id: COLUMN_IDS.LEGAL_BASIS_FOR_PROFILING, + cell: (props) => ( + + ), + header: (props) => ( + + ), + meta: { + displayText: "Legal basis for profiling", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.legal_basis_for_transfers, { + id: COLUMN_IDS.LEGAL_BASIS_FOR_TRANSFERS, + cell: (props) => ( + + ), + header: (props) => ( + + ), + meta: { + displayText: "Legal basis for transfers", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.legitimate_interest_disclosure_url, { + id: COLUMN_IDS.LEGITIMATE_INTEREST_DISCLOSURE_URL, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Legitimate interest disclosure url", + }, + }), + columnHelper.accessor((row) => row.link_to_processor_contract, { + id: COLUMN_IDS.LINK_TO_PROCESSOR_CONTRACT, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Link to processor contract", + }, + }), + columnHelper.accessor((row) => row.processes_personal_data, { + id: COLUMN_IDS.PROCESSES_PERSONAL_DATA, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Processes personal data", + }, + }), + columnHelper.accessor((row) => row.reason_for_exemption, { + id: COLUMN_IDS.REASON_FOR_EXEMPTION, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Reason for exemption", + }, + }), + columnHelper.accessor((row) => row.requires_data_protection_assessments, { + id: COLUMN_IDS.REQUIRES_DATA_PROTECTION_ASSESSMENTS, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Requires data protection assessments", + }, + }), + columnHelper.accessor((row) => row.responsibility, { + id: COLUMN_IDS.RESPONSIBILITY, + cell: (props) => ( + + ), + header: (props) => ( + + ), + meta: { + displayText: "Responsibility", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.retention_period, { + id: COLUMN_IDS.RETENTION_PERIOD, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Retention period", + }, + }), + columnHelper.accessor((row) => row.shared_categories, { + id: COLUMN_IDS.SHARED_CATEGORIES, + cell: (props) => ( + + ), + header: (props) => ( + + ), + meta: { + displayText: "Shared categories", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.special_category_legal_basis, { + id: COLUMN_IDS.SPECIAL_CATEGORY_LEGAL_BASIS, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Special category legal basis", + }, + }), + columnHelper.accessor((row) => row.system_dependencies, { + id: COLUMN_IDS.SYSTEM_DEPENDENCIES, + cell: (props) => ( + + ), + header: (props) => ( + + ), + meta: { + displayText: "System dependencies", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.third_country_safeguards, { + id: COLUMN_IDS.THIRD_COUNTRY_SAFEGUARDS, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Third country safeguards", + }, + }), + columnHelper.accessor((row) => row.third_parties, { + id: COLUMN_IDS.THIRD_PARTIES, + cell: (props) => , + header: (props) => , + meta: { + displayText: "Third parties", + }, + }), + columnHelper.accessor((row) => row.system_undeclared_data_categories, { + id: COLUMN_IDS.SYSTEM_UNDECLARED_DATA_CATEGORIES, + cell: (props) => { + const value = props.getValue(); + + return ( + + ); + }, + header: (props) => ( + + ), + meta: { + displayText: "System undeclared data categories", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.data_use_undeclared_data_categories, { + id: COLUMN_IDS.DATA_USE_UNDECLARED_DATA_CATEGORIES, + cell: (props) => { + const value = props.getValue(); + + return ( + + ); + }, + header: (props) => ( + + ), + meta: { + displayText: "Data use undeclared data categories", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.cookies, { + id: COLUMN_IDS.COOKIES, + cell: (props) => ( + + ), + header: (props) => , + meta: { + displayText: "Cookies", + showHeaderMenu: true, + }, + }), + columnHelper.accessor((row) => row.uses_cookies, { + id: COLUMN_IDS.USES_COOKIES, + cell: (props) => , + header: (props) => , + meta: { + displayText: "Uses cookies", + }, + }), + columnHelper.accessor((row) => row.uses_non_cookie_access, { + id: COLUMN_IDS.USES_NON_COOKIE_ACCESS, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Uses non cookie access", + }, + }), + columnHelper.accessor((row) => row.uses_profiling, { + id: COLUMN_IDS.USES_PROFILING, + cell: (props) => , + header: (props) => ( + + ), + meta: { + displayText: "Uses profiling", + }, + }), + // Tack on the custom field columns to the end + ...customFieldColumns, + ]; +}; diff --git a/clients/admin-ui/src/features/datamap/reporting/custom-reports.slice.ts b/clients/admin-ui/src/features/datamap/reporting/custom-reports.slice.ts new file mode 100644 index 0000000000..9895af174a --- /dev/null +++ b/clients/admin-ui/src/features/datamap/reporting/custom-reports.slice.ts @@ -0,0 +1,61 @@ +import { baseApi } from "~/features/common/api.slice"; +import { + CustomReportCreate, + CustomReportResponse, + Page_CustomReportResponseMinimal_, + ReportType, +} from "~/types/api"; + +// API endpoints +const customReportsApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + getMinimalCustomReports: build.query< + Page_CustomReportResponseMinimal_, + { + pageIndex?: number; + pageSize?: number; + report_type?: ReportType; + } + >({ + query: ({ + pageIndex = 1, + pageSize = 50, + report_type = ReportType.DATAMAP, + }) => { + const queryString = `page=${pageIndex}&size=${pageSize}&report_type=${report_type}`; + return { + url: `plus/custom-report/minimal?${queryString}`, + }; + }, + providesTags: ["Custom Reports"], + }), + getCustomReportById: build.query({ + query: (id) => ({ url: `plus/custom-report/${id}` }), + providesTags: (_result, _error, arg) => [ + { type: "Custom Reports", id: arg }, + ], + }), + postCustomReport: build.mutation({ + query: (payload) => ({ + method: "POST", + url: "plus/custom-report", + body: payload, + }), + invalidatesTags: ["Custom Reports"], + }), + deleteCustomReport: build.mutation({ + query: (id) => ({ + method: "DELETE", + url: `plus/custom-report/${id}`, + }), + invalidatesTags: ["Custom Reports"], + }), + }), +}); + +export const { + useGetMinimalCustomReportsQuery, + useLazyGetCustomReportByIdQuery, + usePostCustomReportMutation, + useDeleteCustomReportMutation, +} = customReportsApi; diff --git a/clients/admin-ui/src/features/datamap/reporting/datamap-report-context.tsx b/clients/admin-ui/src/features/datamap/reporting/datamap-report-context.tsx new file mode 100644 index 0000000000..c0955aaf4e --- /dev/null +++ b/clients/admin-ui/src/features/datamap/reporting/datamap-report-context.tsx @@ -0,0 +1,120 @@ +import { + createContext, + Dispatch, + SetStateAction, + useContext, + useMemo, +} from "react"; + +import { useLocalStorage } from "~/features/common/hooks/useLocalStorage"; +import { DATAMAP_GROUPING } from "~/types/api"; + +import { DATAMAP_LOCAL_STORAGE_KEYS } from "../constants"; +import { DatamapReportFilterSelections } from "../types"; +import { COLUMN_IDS } from "./DatamapReportTableColumns"; +import { getColumnOrder } from "./utils"; + +interface DatamapReportContextProps { + savedCustomReportId: string; + setSavedCustomReportId: Dispatch>; + groupBy: DATAMAP_GROUPING; + setGroupBy: Dispatch>; + selectedFilters: DatamapReportFilterSelections; + setSelectedFilters: Dispatch>; + columnOrder: string[]; + setColumnOrder: Dispatch>; + columnVisibility: Record; + setColumnVisibility: Dispatch>>; + columnSizing: Record; + setColumnSizing: Dispatch>>; +} + +export const DatamapReportContext = createContext( + {} as DatamapReportContextProps, +); + +export const DatamapReportProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [savedCustomReportId, setSavedCustomReportId] = useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.CUSTOM_REPORT_ID, + "", + ); + const [groupBy, setGroupBy] = useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.GROUP_BY, + DATAMAP_GROUPING.SYSTEM_DATA_USE, + ); + const [selectedFilters, setSelectedFilters] = + useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.FILTERS, + { + dataUses: [], + dataSubjects: [], + dataCategories: [], + }, + ); + const [columnOrder, setColumnOrder] = useLocalStorage( + DATAMAP_LOCAL_STORAGE_KEYS.COLUMN_ORDER, + getColumnOrder(groupBy), + ); + const [columnVisibility, setColumnVisibility] = useLocalStorage< + Record + >(DATAMAP_LOCAL_STORAGE_KEYS.COLUMN_VISIBILITY, { + [COLUMN_IDS.SYSTEM_UNDECLARED_DATA_CATEGORIES]: false, + [COLUMN_IDS.DATA_USE_UNDECLARED_DATA_CATEGORIES]: false, + }); + const [columnSizing, setColumnSizing] = useLocalStorage< + Record + >(DATAMAP_LOCAL_STORAGE_KEYS.COLUMN_SIZING, {}); + const contextValue: DatamapReportContextProps = useMemo( + () => ({ + savedCustomReportId, + setSavedCustomReportId, + groupBy, + setGroupBy, + selectedFilters, + setSelectedFilters, + columnOrder, + setColumnOrder, + columnVisibility, + setColumnVisibility, + columnSizing, + setColumnSizing, + }), + [ + savedCustomReportId, + setSavedCustomReportId, + groupBy, + setGroupBy, + selectedFilters, + setSelectedFilters, + columnOrder, + setColumnOrder, + columnVisibility, + setColumnVisibility, + columnSizing, + setColumnSizing, + ], + ); + + return ( + + {children} + + ); +}; + +/** + * Note: All values stored in local storage. + */ +export const useDatamapReport = () => { + const context = useContext(DatamapReportContext); + if (!context || Object.keys(context).length === 0) { + throw new Error( + "useDatamapReport must be used within a DatamapReportProvider", + ); + } + return context; +}; diff --git a/clients/admin-ui/src/features/datamap/reporting/datamap-report.d.ts b/clients/admin-ui/src/features/datamap/reporting/datamap-report.d.ts new file mode 100644 index 0000000000..036b94963d --- /dev/null +++ b/clients/admin-ui/src/features/datamap/reporting/datamap-report.d.ts @@ -0,0 +1,5 @@ +import { CustomField, DatamapReport } from "~/types/api"; + +// Extend the base datamap report type to also have custom fields +export type DatamapReportWithCustomFields = DatamapReport & + Record; diff --git a/clients/admin-ui/src/features/datamap/reporting/utils.ts b/clients/admin-ui/src/features/datamap/reporting/utils.ts new file mode 100644 index 0000000000..654a21317f --- /dev/null +++ b/clients/admin-ui/src/features/datamap/reporting/utils.ts @@ -0,0 +1,45 @@ +import { DATAMAP_GROUPING } from "~/types/api"; + +import { COLUMN_IDS } from "./DatamapReportTableColumns"; + +export const getGrouping = (groupBy?: DATAMAP_GROUPING) => { + switch (groupBy) { + case DATAMAP_GROUPING.DATA_USE_SYSTEM: { + return [COLUMN_IDS.DATA_USE]; + } + default: + return [COLUMN_IDS.SYSTEM_NAME]; + } +}; + +export const getColumnOrder = (groupBy: DATAMAP_GROUPING) => { + let columnOrder: string[] = []; + if (DATAMAP_GROUPING.SYSTEM_DATA_USE === groupBy) { + columnOrder = [ + COLUMN_IDS.SYSTEM_NAME, + COLUMN_IDS.DATA_USE, + COLUMN_IDS.DATA_CATEGORY, + COLUMN_IDS.DATA_SUBJECT, + ]; + } + if (DATAMAP_GROUPING.DATA_USE_SYSTEM === groupBy) { + columnOrder = [ + COLUMN_IDS.DATA_USE, + COLUMN_IDS.SYSTEM_NAME, + COLUMN_IDS.DATA_CATEGORY, + COLUMN_IDS.DATA_SUBJECT, + ]; + } + return columnOrder; +}; + +export const getPrefixColumns = (groupBy: DATAMAP_GROUPING) => { + let columnOrder: string[] = []; + if (DATAMAP_GROUPING.SYSTEM_DATA_USE === groupBy) { + columnOrder = [COLUMN_IDS.SYSTEM_NAME, COLUMN_IDS.DATA_USE]; + } + if (DATAMAP_GROUPING.DATA_USE_SYSTEM === groupBy) { + columnOrder = [COLUMN_IDS.DATA_USE, COLUMN_IDS.SYSTEM_NAME]; + } + return columnOrder; +}; diff --git a/clients/admin-ui/src/features/datamap/types.ts b/clients/admin-ui/src/features/datamap/types.ts index ca4f47c6d1..8dfc685937 100644 --- a/clients/admin-ui/src/features/datamap/types.ts +++ b/clients/admin-ui/src/features/datamap/types.ts @@ -1,3 +1,5 @@ +import { DATAMAP_GROUPING } from "~/types/api"; + export type Link = { source: string; target: string; @@ -19,3 +21,16 @@ export type SystemNode = { export type SetSelectedSystemId = { setSelectedSystemId: (id: string) => void; }; + +export type DatamapReportFilterSelections = { + dataUses: string[]; + dataSubjects: string[]; + dataCategories: string[]; +}; + +export interface CustomReportTableState { + groupBy?: DATAMAP_GROUPING; + filters?: unknown; + columnOrder?: string[]; + columnVisibility?: Record; +} diff --git a/clients/admin-ui/src/pages/reporting/datamap/index.tsx b/clients/admin-ui/src/pages/reporting/datamap/index.tsx index 81e7682494..7eb781c807 100644 --- a/clients/admin-ui/src/pages/reporting/datamap/index.tsx +++ b/clients/admin-ui/src/pages/reporting/datamap/index.tsx @@ -2,6 +2,7 @@ import React from "react"; import FixedLayout from "~/features/common/FixedLayout"; import PageHeader from "~/features/common/PageHeader"; +import { DatamapReportProvider } from "~/features/datamap/reporting/datamap-report-context"; import { DatamapReportTable } from "~/features/datamap/reporting/DatamapReportTable"; const DatamapReportingPage = () => ( @@ -15,7 +16,9 @@ const DatamapReportingPage = () => ( data-testid="datamap-report-heading" breadcrumbs={[{ title: "Data map report" }]} /> - + + + ); diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index df2900d993..4e64377d12 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -98,6 +98,10 @@ export type { CustomFieldDefinition } from "./models/CustomFieldDefinition"; export type { CustomFieldDefinitionResponse } from "./models/CustomFieldDefinitionResponse"; export type { CustomFieldDefinitionWithId } from "./models/CustomFieldDefinitionWithId"; export type { CustomFieldWithId } from "./models/CustomFieldWithId"; +export type { CustomReportConfig } from "./models/CustomReportConfig"; +export type { CustomReportCreate } from "./models/CustomReportCreate"; +export type { CustomReportResponse } from "./models/CustomReportResponse"; +export type { CustomReportResponseMinimal } from "./models/CustomReportResponseMinimal"; export type { Database } from "./models/Database"; export type { DatabaseConfig } from "./models/DatabaseConfig"; export type { DatabaseHealthCheck } from "./models/DatabaseHealthCheck"; @@ -269,6 +273,8 @@ export type { Page_ConnectionConfigurationResponse_ } from "./models/Page_Connec export type { Page_ConnectionSystemTypeMap_ } from "./models/Page_ConnectionSystemTypeMap_"; export type { Page_ConsentReport_ } from "./models/Page_ConsentReport_"; export type { Page_ConsentReportingSchema_ } from "./models/Page_ConsentReportingSchema_"; +export type { Page_CustomReportResponse_ } from "./models/Page_CustomReportResponse_"; +export type { Page_CustomReportResponseMinimal_ } from "./models/Page_CustomReportResponseMinimal_"; export type { Page_DatamapReport_ } from "./models/Page_DatamapReport_"; export type { Page_Dataset_ } from "./models/Page_Dataset_"; export type { Page_DatasetConfigSchema_ } from "./models/Page_DatasetConfigSchema_"; @@ -357,6 +363,7 @@ export type { RecordConsentServedRequest } from "./models/RecordConsentServedReq export type { RecordsServedResponse } from "./models/RecordsServedResponse"; export type { RedshiftDocsSchema } from "./models/RedshiftDocsSchema"; export type { Registration } from "./models/Registration"; +export { ReportType } from "./models/ReportType"; export { RequestOrigin } from "./models/RequestOrigin"; export type { RequestTaskCallbackRequest } from "./models/RequestTaskCallbackRequest"; export type { ResourceFilter } from "./models/ResourceFilter"; diff --git a/clients/admin-ui/src/types/api/models/CustomReportConfig.ts b/clients/admin-ui/src/types/api/models/CustomReportConfig.ts new file mode 100644 index 0000000000..157e19c36b --- /dev/null +++ b/clients/admin-ui/src/types/api/models/CustomReportConfig.ts @@ -0,0 +1,17 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * The configuration for a custom report. + */ +export type CustomReportConfig = { + /** + * Flexible dictionary storing UI-specific table state data without a fixed schema + */ + table_state?: any; + /** + * A map between column keys and custom labels + */ + column_map?: Record; +}; diff --git a/clients/admin-ui/src/types/api/models/CustomReportCreate.ts b/clients/admin-ui/src/types/api/models/CustomReportCreate.ts new file mode 100644 index 0000000000..a4891d3bc7 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/CustomReportCreate.ts @@ -0,0 +1,12 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { CustomReportConfig } from "./CustomReportConfig"; +import type { ReportType } from "./ReportType"; + +export type CustomReportCreate = { + name: string; + type: ReportType; + config: CustomReportConfig; +}; diff --git a/clients/admin-ui/src/types/api/models/CustomReportResponse.ts b/clients/admin-ui/src/types/api/models/CustomReportResponse.ts new file mode 100644 index 0000000000..cdc57df1d5 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/CustomReportResponse.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { CustomReportConfig } from "./CustomReportConfig"; +import type { ReportType } from "./ReportType"; + +export type CustomReportResponse = { + id: string; + type: ReportType; + name: string; + config: CustomReportConfig; +}; diff --git a/clients/admin-ui/src/types/api/models/CustomReportResponseMinimal.ts b/clients/admin-ui/src/types/api/models/CustomReportResponseMinimal.ts new file mode 100644 index 0000000000..ed99eae1cc --- /dev/null +++ b/clients/admin-ui/src/types/api/models/CustomReportResponseMinimal.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ReportType } from "./ReportType"; + +export type CustomReportResponseMinimal = { + id: string; + type: ReportType; + name: string; +}; diff --git a/clients/admin-ui/src/types/api/models/DatamapReport.ts b/clients/admin-ui/src/types/api/models/DatamapReport.ts index 5283343ba5..ba93d77221 100644 --- a/clients/admin-ui/src/types/api/models/DatamapReport.ts +++ b/clients/admin-ui/src/types/api/models/DatamapReport.ts @@ -6,6 +6,7 @@ export type DatamapReport = { administrating_department?: string | null; cookie_max_age_seconds?: number | null; cookie_refresh: boolean; + cookies?: Array | null; data_categories?: string | Array | null; system_undeclared_data_categories?: Array | null; data_use_undeclared_data_categories?: Array | null; diff --git a/clients/admin-ui/src/types/api/models/Page_CustomReportResponseMinimal_.ts b/clients/admin-ui/src/types/api/models/Page_CustomReportResponseMinimal_.ts new file mode 100644 index 0000000000..7f05615f2d --- /dev/null +++ b/clients/admin-ui/src/types/api/models/Page_CustomReportResponseMinimal_.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { CustomReportResponseMinimal } from "./CustomReportResponseMinimal"; + +export type Page_CustomReportResponseMinimal_ = { + items: Array; + total: number | null; + page: number | null; + size: number | null; + pages?: number | null; +}; diff --git a/clients/admin-ui/src/types/api/models/Page_CustomReportResponse_.ts b/clients/admin-ui/src/types/api/models/Page_CustomReportResponse_.ts new file mode 100644 index 0000000000..c526fc060c --- /dev/null +++ b/clients/admin-ui/src/types/api/models/Page_CustomReportResponse_.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { CustomReportResponse } from "./CustomReportResponse"; + +export type Page_CustomReportResponse_ = { + items: Array; + total: number | null; + page: number | null; + size: number | null; + pages?: number | null; +}; diff --git a/clients/admin-ui/src/types/api/models/ReportType.ts b/clients/admin-ui/src/types/api/models/ReportType.ts new file mode 100644 index 0000000000..3789d57441 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ReportType.ts @@ -0,0 +1,10 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Enum for custom report types. + */ +export enum ReportType { + DATAMAP = "datamap", +} diff --git a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts index 4df721cfe0..bc061c8d5f 100644 --- a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts +++ b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts @@ -50,6 +50,9 @@ export enum ScopeRegistryEnum { CUSTOM_FIELD_DEFINITION_DELETE = "custom_field_definition:delete", CUSTOM_FIELD_DEFINITION_READ = "custom_field_definition:read", CUSTOM_FIELD_DEFINITION_UPDATE = "custom_field_definition:update", + CUSTOM_REPORT_CREATE = "custom_report:create", + CUSTOM_REPORT_DELETE = "custom_report:delete", + CUSTOM_REPORT_READ = "custom_report:read", DATA_CATEGORY_CREATE = "data_category:create", DATA_CATEGORY_DELETE = "data_category:delete", DATA_CATEGORY_READ = "data_category:read",