diff --git a/CHANGELOG.md b/CHANGELOG.md index 20cd632706d..2a7670d0aef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The types of changes are: ### Added - Added new Performance-related nox commands and included them as part of the CI suite [#3997](https://github.com/ethyca/fides/pull/3997) +- Added dictionary suggestions for data uses [4035](https://github.com/ethyca/fides/pull/4035) ## [2.19.0](https://github.com/ethyca/fides/compare/2.18.0...2.19.0) diff --git a/clients/admin-ui/src/features/plus/plus.slice.ts b/clients/admin-ui/src/features/plus/plus.slice.ts index 06e02d37f5c..87ac7c9b588 100644 --- a/clients/admin-ui/src/features/plus/plus.slice.ts +++ b/clients/admin-ui/src/features/plus/plus.slice.ts @@ -32,7 +32,7 @@ import { } from "~/types/api"; import { SystemHistoryResponse } from "~/types/api/models/SystemHistoryResponse"; -import { DictEntry, Page } from "./types"; +import { DictDataUse, DictEntry, Page } from "./types"; interface ScanParams { classify?: boolean; @@ -251,6 +251,17 @@ const plusApi = baseApi.injectEndpoints({ }), providesTags: ["Dictionary"], }), + getDictionaryDataUses: build.query< + Page, + { vendor_id: number } + >({ + query: ({ vendor_id }) => ({ + params: { size: 1000 }, + url: `plus/dictionary/data-use-declarations/${vendor_id}`, + method: "GET", + }), + providesTags: ["Dictionary"], + }), getSystemHistory: build.query< SystemHistoryResponse, { system_key: string } @@ -283,6 +294,7 @@ export const { useGetAllCustomFieldDefinitionsQuery, useGetAllowListQuery, useGetAllDictionaryEntriesQuery, + useGetDictionaryDataUsesQuery, useGetSystemHistoryQuery, } = plusApi; @@ -425,3 +437,14 @@ export const selectDictEntry = (vendorId: string) => return dictEntry || EMPTY_DICT_ENTRY; } ); + +const EMPTY_DATA_USES: DictDataUse[] = []; + +export const selectDictDataUses = (vendorId: number) => + createSelector( + [ + (state) => state, + plusApi.endpoints.getDictionaryDataUses.select({ vendor_id: vendorId }), + ], + (state, { data }) => (data ? data.items : EMPTY_DATA_USES) + ); diff --git a/clients/admin-ui/src/features/plus/types.ts b/clients/admin-ui/src/features/plus/types.ts index e0ffbc1d8a3..6a73ff8e44d 100644 --- a/clients/admin-ui/src/features/plus/types.ts +++ b/clients/admin-ui/src/features/plus/types.ts @@ -38,3 +38,16 @@ export type DictCookie = { vendor_id: string; domains: string; }; + +export type DictDataUse = { + vendor_id: string; + vendor_name: string; + data_use: string; + data_categories: string[]; + features: string[]; + legal_basis_for_processing: string; + retention_period: number; + purpose: number; + special_purpose: number; + cookies: any[]; +}; diff --git a/clients/admin-ui/src/features/system/dictionary-data-uses/DataUseCheckboxTable.tsx b/clients/admin-ui/src/features/system/dictionary-data-uses/DataUseCheckboxTable.tsx new file mode 100644 index 00000000000..2e5aa4b0dee --- /dev/null +++ b/clients/admin-ui/src/features/system/dictionary-data-uses/DataUseCheckboxTable.tsx @@ -0,0 +1,121 @@ +import { + Checkbox, + Table, + Tag, + Tbody, + Td, + Text, + Th, + Thead, + Tr, +} from "@fidesui/react"; + +import { DataUse } from "../../../types/api"; +import { DictDataUse } from "../../plus/types"; + +interface Props { + onChange: (dataUses: DictDataUse[]) => void; + allDataUses: DataUse[]; + dictDataUses: DictDataUse[]; + checked: DictDataUse[]; +} + +const DataUseCheckboxTable = ({ + onChange, + allDataUses, + dictDataUses, + checked, +}: Props) => { + const handleChangeAll = (event: React.ChangeEvent) => { + if (event.target.checked) { + onChange(dictDataUses); + } else { + onChange([]); + } + }; + + const onCheck = (dataUse: DictDataUse) => { + const exists = + checked.filter((du) => du.data_use === dataUse.data_use).length > 0; + if (!exists) { + const newChecked = [...checked, dataUse]; + onChange(newChecked); + } else { + const newChecked = checked.filter( + (use) => use.data_use !== dataUse.data_use + ); + onChange(newChecked); + } + }; + + const declarationTitle = (declaration: DictDataUse) => { + const dataUse = allDataUses.filter( + (du) => du.fides_key === declaration.data_use + )[0]; + if (dataUse) { + return dataUse.name; + } + return declaration.data_use; + }; + + const allChecked = dictDataUses.length === checked.length; + + return ( + + + + + + + + + {dictDataUses.map((du) => ( + + + + + ))} + +
+ + + + Data use + +
+ use.data_use === du.data_use).length > + 0 + } + onChange={() => onCheck(du)} + data-testid={`checkbox-${du.data_use}`} + /> + + + {declarationTitle(du)} + +
+ ); +}; + +export default DataUseCheckboxTable; diff --git a/clients/admin-ui/src/features/system/dictionary-data-uses/EmptyTableState.tsx b/clients/admin-ui/src/features/system/dictionary-data-uses/EmptyTableState.tsx new file mode 100644 index 00000000000..a48ab76df6f --- /dev/null +++ b/clients/admin-ui/src/features/system/dictionary-data-uses/EmptyTableState.tsx @@ -0,0 +1,99 @@ +import { AddIcon, WarningTwoIcon } from "@chakra-ui/icons"; +import { Box, Button, HStack, Stack, Text, Tooltip } from "@fidesui/react"; +import { ReactNode, useMemo } from "react"; + +import { SparkleIcon } from "../../common/Icon/SparkleIcon"; + +type Props = { + title: string; + description: string | ReactNode; + dictAvailable: boolean; + handleAdd: () => void; + handleDictSuggestion: () => void; + vendorSelected: boolean; +}; + +const EmptyTableState = ({ + title, + description, + dictAvailable = false, + handleAdd, + handleDictSuggestion, + vendorSelected, +}: Props) => { + const dictDisabledTooltip = useMemo( + () => + "You will need to select a vendor for this system before you can use the Fides dictionary. You can do this on System Information tab above.", + [] + ); + + return ( + + + {dictAvailable ? ( + + ) : ( + + )} + + + + {title} + + + + {description} + + + {dictAvailable ? ( + <> + + + + or + + ) : null} + + + + + + ); +}; + +export default EmptyTableState; diff --git a/clients/admin-ui/src/features/system/dictionary-data-uses/PrivacyDeclarationDictModalComponents.tsx b/clients/admin-ui/src/features/system/dictionary-data-uses/PrivacyDeclarationDictModalComponents.tsx new file mode 100644 index 00000000000..360d5cda20f --- /dev/null +++ b/clients/admin-ui/src/features/system/dictionary-data-uses/PrivacyDeclarationDictModalComponents.tsx @@ -0,0 +1,110 @@ +import { + Box, + Button, + HStack, + Stack, + TableContainer, + Text, +} from "@fidesui/react"; +import { useEffect, useState } from "react"; + +import { + selectDictDataUses, + useGetDictionaryDataUsesQuery, +} from "~/features/plus/plus.slice"; + +import { useAppSelector } from "../../../app/hooks"; +import { DataUse } from "../../../types/api"; +import { SparkleIcon } from "../../common/Icon/SparkleIcon"; +import { DictDataUse } from "../../plus/types"; +import DataUseCheckboxTable from "./DataUseCheckboxTable"; + +interface Props { + alreadyHasDataUses: boolean; + allDataUses: DataUse[]; + onCancel: () => void; + onAccept: (suggestions: DictDataUse[]) => void; + vendorId: number; +} + +const PrivacyDeclarationDictModalComponents = ({ + alreadyHasDataUses, + allDataUses, + onCancel, + onAccept, + vendorId, +}: Props) => { + const [selectedDataUses, setSelectedDataUses] = useState([]); + + useGetDictionaryDataUsesQuery({ vendor_id: vendorId }); + const dictDataUses = useAppSelector(selectDictDataUses(vendorId)); + + useEffect(() => { + setSelectedDataUses(dictDataUses); + }, [dictDataUses]); + + const handleChangeChecked = (newChecked: DictDataUse[]) => { + setSelectedDataUses(newChecked); + }; + + return ( + + + + + + Fides has automatically generated the following data uses. These + data uses are commonly assigned to this system type. You can accept + these data use suggestions and optionally edit them or cancel and + add data uses manually. + + + + + + + {alreadyHasDataUses ? ( + + Please note, accepting these dictionary suggestions will override any + data uses you have manually added or modified. For more help on this + see our docs. + + ) : null} + + + + + + ); +}; + +export default PrivacyDeclarationDictModalComponents; diff --git a/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationDisplayGroup.tsx b/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationDisplayGroup.tsx index 83fc4c3e03e..26f5d81e0b7 100644 --- a/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationDisplayGroup.tsx +++ b/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationDisplayGroup.tsx @@ -13,14 +13,18 @@ import { Text, } from "@fidesui/react"; -import { PrivacyDeclarationResponse } from "~/types/api"; +import { DataUse, PrivacyDeclarationResponse } from "~/types/api"; + +import { SparkleIcon } from "../../common/Icon/SparkleIcon"; const PrivacyDeclarationRow = ({ declaration, + title, handleDelete, handleEdit, }: { declaration: PrivacyDeclarationResponse; + title?: string; handleDelete: (dec: PrivacyDeclarationResponse) => void; handleEdit: (dec: PrivacyDeclarationResponse) => void; }) => ( @@ -34,9 +38,7 @@ const PrivacyDeclarationRow = ({ cursor="pointer" > - - {declaration.name ? declaration.name : declaration.data_use} - + {title || declaration.data_use} @@ -58,17 +60,17 @@ const PrivacyDeclarationRow = ({ export const PrivacyDeclarationTabTable = ({ heading, children, - hasAddButton = false, - handleAdd, + headerButton, + footerButton, }: { heading: string; children?: React.ReactNode; - hasAddButton?: boolean; - handleAdd?: () => void; + headerButton?: React.ReactNode; + footerButton?: React.ReactNode; }) => ( - {heading} - + + {headerButton || null} + {children} - {hasAddButton ? ( - - ) : null} + {footerButton || null} ); +type Props = { + heading: string; + dictionaryEnabled?: boolean; + declarations: PrivacyDeclarationResponse[]; + handleDelete: (dec: PrivacyDeclarationResponse) => void; + handleAdd?: () => void; + handleEdit: (dec: PrivacyDeclarationResponse) => void; + handleOpenDictModal: () => void; + allDataUses: DataUse[]; +}; + export const PrivacyDeclarationDisplayGroup = ({ heading, + dictionaryEnabled = false, declarations, handleAdd, handleDelete, handleEdit, -}: { - heading: string; - declarations: PrivacyDeclarationResponse[]; - handleAdd?: () => void; - handleDelete: (dec: PrivacyDeclarationResponse) => void; - handleEdit: (dec: PrivacyDeclarationResponse) => void; -}) => ( - - {declarations.map((pd) => ( - - ))} - -); + handleOpenDictModal, + allDataUses, +}: Props) => { + const declarationTitle = (declaration: PrivacyDeclarationResponse) => { + const dataUse = allDataUses.filter( + (du) => du.fides_key === declaration.data_use + )[0]; + if (dataUse) { + return declaration.name + ? `${dataUse.name} - ${declaration.name}` + : dataUse.name; + } + return ""; + }; + + return ( + + + + ) : null + } + footerButton={ + + } + > + {declarations.map((pd) => ( + + ))} + + ); +}; diff --git a/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormModal.tsx b/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormModal.tsx index 08edf290414..bfdd65d3e4b 100644 --- a/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormModal.tsx +++ b/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormModal.tsx @@ -11,17 +11,27 @@ import { type DataUseFormModalProps = { isOpen: boolean; onClose: () => void; - testId?: String; + heading: string; + isCentered?: boolean; + testId?: string; children: React.ReactNode; }; export const PrivacyDeclarationFormModal: React.FC = ({ isOpen, onClose, + heading, + isCentered = false, testId = "privacy-declaration-modal", children, }) => ( - + @@ -34,7 +44,7 @@ export const PrivacyDeclarationFormModal: React.FC = ({ borderTopRadius={6} > - Configure data use + {heading} diff --git a/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormTab.tsx b/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormTab.tsx index 5df46f84a38..3125c3d17ae 100644 --- a/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormTab.tsx +++ b/clients/admin-ui/src/features/system/system-form-declaration-tab/PrivacyDeclarationFormTab.tsx @@ -1,19 +1,19 @@ import { Box, - Button, ButtonProps, Divider, Stack, Text, + useDisclosure, useToast, } from "@fidesui/react"; import { SerializedError } from "@reduxjs/toolkit"; import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query"; -import EmptyTableState from "common/table/EmptyTableState"; import { useEffect, useState } from "react"; import { getErrorMessage } from "~/features/common/helpers"; import { errorToastParams, successToastParams } from "~/features/common/toast"; +import EmptyTableState from "~/features/system/dictionary-data-uses/EmptyTableState"; import { useUpdateSystemMutation } from "~/features/system/system.slice"; import { PrivacyDeclarationDisplayGroup, @@ -31,6 +31,10 @@ import { } from "~/types/api"; import { isErrorResult } from "~/types/errors"; +import { useFeatures } from "../../common/features"; +import { DictDataUse } from "../../plus/types"; +import PrivacyDeclarationDictModalComponents from "../dictionary-data-uses/PrivacyDeclarationDictModalComponents"; + interface Props { system: SystemResponse; addButtonProps?: ButtonProps; @@ -55,6 +59,14 @@ const PrivacyDeclarationFormTab = ({ PrivacyDeclarationResponse | undefined >(undefined); + const features = useFeatures(); + + const { + isOpen: showDictionaryModal, + onOpen: handleOpenDictModal, + onClose: handleCloseDictModal, + } = useDisclosure(); + const assignedCookies = [ ...system.privacy_declarations .filter((d) => d.cookies !== undefined) @@ -86,6 +98,36 @@ const PrivacyDeclarationFormTab = ({ return false; }; + const transformDictDataUseToDeclaration = ( + dataUse: DictDataUse + ): PrivacyDeclarationResponse => { + // fix "Legitimate Interests" capitalization for API + const legalBasisForProcessing = + dataUse.legal_basis_for_processing === "Legitimate Interests" + ? "Legitimate interests" + : dataUse.legal_basis_for_processing; + + // some data categories are nested on the backend, flatten them + // https://github.com/ethyca/fides-services/issues/100 + const dataCategories = dataUse.data_categories.flatMap((dc) => + dc.split(",") + ); + + return { + data_use: dataUse.data_use, + data_categories: dataCategories, + features: dataUse.features, + // @ts-ignore + legal_basis_for_processing: legalBasisForProcessing, + retention_period: `${dataUse.retention_period}`, + cookies: dataUse.cookies.map((c) => ({ + name: c.identifier, + domain: c.domains, + path: "/", + })), + }; + }; + const handleSave = async ( updatedDeclarations: PrivacyDeclarationResponse[], isDelete?: boolean @@ -182,6 +224,15 @@ const PrivacyDeclarationFormTab = ({ setCurrentDeclaration(declarationToEdit); }; + const handleAcceptDictSuggestions = (suggestions: DictDataUse[]) => { + const newDeclarations = suggestions.map((du) => + transformDictDataUseToDeclaration(du) + ); + + handleSave(newDeclarations); + handleCloseDictModal(); + }; + const handleSubmit = (values: PrivacyDeclarationResponse) => { handleCloseForm(); if (currentDeclaration) { @@ -211,18 +262,10 @@ const PrivacyDeclarationFormTab = ({ - Add data use - - } + dictAvailable={features.dictionaryService} + handleAdd={handleOpenNewForm} + handleDictSuggestion={handleOpenDictModal} + vendorSelected={!!system.meta.vendor} /> ) : ( )} {unassignedCookies && unassignedCookies.length > 0 ? ( @@ -245,7 +291,11 @@ const PrivacyDeclarationFormTab = ({ ))} ) : null} - + + + 0} + allDataUses={dataProps.allDataUses} + onCancel={handleCloseDictModal} + onAccept={handleAcceptDictSuggestions} + vendorId={system.meta?.vendor?.id ? system.meta.vendor.id : undefined} + /> + ); };