From e17a786d92c5d9a7b4de8e1b7846afe579cea1bb Mon Sep 17 00:00:00 2001 From: Allison King Date: Fri, 3 Feb 2023 14:59:00 -0500 Subject: [PATCH 01/16] Remove old manual system flow --- .../config-wizard/ConfigWizardWalkthrough.tsx | 69 +----------- .../config-wizard/config-wizard.slice.ts | 103 +----------------- .../src/features/config-wizard/constants.tsx | 25 ----- .../src/features/system/SystemOptions.tsx | 0 4 files changed, 3 insertions(+), 194 deletions(-) create mode 100644 clients/admin-ui/src/features/system/SystemOptions.tsx diff --git a/clients/admin-ui/src/features/config-wizard/ConfigWizardWalkthrough.tsx b/clients/admin-ui/src/features/config-wizard/ConfigWizardWalkthrough.tsx index 41c9717c33..84c8d74696 100644 --- a/clients/admin-ui/src/features/config-wizard/ConfigWizardWalkthrough.tsx +++ b/clients/admin-ui/src/features/config-wizard/ConfigWizardWalkthrough.tsx @@ -1,49 +1,25 @@ import { Box, Button, CloseSolidIcon, Divider, Stack } from "@fidesui/react"; -import HorizontalStepper from "common/HorizontalStepper"; import Stepper from "common/Stepper"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; import { useFeatures } from "~/features/common/features"; -import DescribeSystemStep from "~/features/system/DescribeSystemStep"; -import PrivacyDeclarationStep from "~/features/system/PrivacyDeclarationStep"; -import ReviewSystemStep from "~/features/system/ReviewSystemStep"; -import { System } from "~/types/api"; import AddSystem from "./AddSystem"; import AuthenticateScanner from "./AuthenticateScanner"; -import { - changeReviewStep, - changeStep, - reset, - reviewManualSystem, - selectReviewStep, - selectStep, - selectSystemInReview, - selectSystemsForReview, - setSystemInReview, -} from "./config-wizard.slice"; -import { HORIZONTAL_STEPS, STEPS } from "./constants"; +import { changeStep, reset, selectStep } from "./config-wizard.slice"; +import { STEPS } from "./constants"; import OrganizationInfoForm from "./OrganizationInfoForm"; import ScanResults from "./ScanResults"; -import SuccessPage from "./SuccessPage"; const ConfigWizardWalkthrough = () => { const dispatch = useAppDispatch(); const step = useAppSelector(selectStep); - const reviewStep = useAppSelector(selectReviewStep); - const system = useAppSelector(selectSystemInReview); - const systemsForReview = useAppSelector(selectSystemsForReview); const features = useFeatures(); const handleCancelSetup = () => { dispatch(reset()); }; - const handleSuccess = (values: System) => { - dispatch(setSystemInReview(values)); - dispatch(changeReviewStep()); - }; - return ( <> {!features.flags.navV2 && ( @@ -90,47 +66,6 @@ const ConfigWizardWalkthrough = () => { ) : null} - {/* These steps should only apply if you're creating systems manually */} - {step === 5 ? ( - - {reviewStep <= 3 ? ( - - ) : null} - {reviewStep === 1 && ( - - )} - {reviewStep === 2 && system && ( - - )} - {reviewStep === 3 && system && ( - dispatch(changeReviewStep())} - abridged - /> - )} - {reviewStep === 4 && system && ( - { - dispatch(reviewManualSystem()); - }} - /> - )} - - ) : null} diff --git a/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts b/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts index 78989d7adf..02a484b361 100644 --- a/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts +++ b/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts @@ -4,30 +4,22 @@ import type { RootState } from "~/app/store"; import { DEFAULT_ORGANIZATION_FIDES_KEY } from "~/features/organization"; import { Organization, System } from "~/types/api"; -import { REVIEW_STEPS, STEPS } from "./constants"; +import { STEPS } from "./constants"; import { AddSystemMethods, SystemMethods } from "./types"; export interface State { step: number; - reviewStep: number; organization?: Organization; /** * The systems that were returned by a system scan, some of which the user will select for review. * These are persisted to the backend after the "Describe" step. */ systemsForReview?: System[]; - /** - * The system that is currently being put through the review steps. It is persisted - * on the "Describe" step and then updated with additional info. Once it's registered, - * the next `systemForReview` is swapped in, if any. - */ - systemInReview?: System; addSystemsMethod: AddSystemMethods; } const initialState: State = { step: 0, - reviewStep: 1, addSystemsMethod: SystemMethods.MANUAL, }; @@ -55,85 +47,9 @@ export const slice = createSlice({ draftState.step = STEPS.length - 1; } }, - /** - * With no argument: increment to the next review step. - * With an argument: switch to that review step. - */ - changeReviewStep: ( - draftState, - action: PayloadAction - ) => { - const reviewStep = action.payload; - - if (reviewStep === undefined) { - draftState.reviewStep += 1; - } else { - draftState.reviewStep = reviewStep; - } - - // Ensure the number stays in the valid range. - if (draftState.reviewStep < 1) { - draftState.reviewStep = 1; - } else if (REVIEW_STEPS <= draftState.reviewStep) { - draftState.reviewStep = REVIEW_STEPS - 1; - } - }, - reviewManualSystem: (draftState) => { - draftState.systemInReview = undefined; - draftState.reviewStep = 1; - }, - /** - * Move to the next system that needs review, if any. - */ - continueReview: (draftState) => { - const { systemInReview, systemsForReview } = draftState; - if (!(systemInReview && systemsForReview)) { - throw new Error( - "Can't finish system review when there is no review in progress." - ); - } - - const reviewIndex = systemsForReview.findIndex( - (s) => s.fides_key === systemInReview.fides_key - ); - if (reviewIndex < 0) { - throw new Error("The system in review couldn't be found by fides key."); - } - - const nextIndex = reviewIndex + 1; - if (nextIndex < systemsForReview.length) { - // If there is another system to review, swap it in. - draftState.systemInReview = systemsForReview[nextIndex]; - } else { - // Otherwise move to the next wizard step. - draftState.systemInReview = undefined; - draftState.step += 1; - } - - // Always reset the review step - draftState.reviewStep = 1; - }, setOrganization: (draftState, action: PayloadAction) => { draftState.organization = action.payload; }, - setSystemInReview: (draftState, action: PayloadAction) => { - const systemInReview = action.payload; - const { systemsForReview = [] } = draftState; - - // Whenever the in-progress system is updated, ensure the object in the array - // is updated in tandem. - const reviewIndex = systemsForReview.findIndex( - (s) => s.fides_key === systemInReview.fides_key - ); - if (reviewIndex < 0) { - systemsForReview.push(systemInReview); - } else { - systemsForReview[reviewIndex] = systemInReview; - } - - draftState.systemInReview = systemInReview; - draftState.systemsForReview = systemsForReview; - }, setSystemsForReview: (draftState, action: PayloadAction) => { draftState.systemsForReview = action.payload; }, @@ -142,9 +58,6 @@ export const slice = createSlice({ draftState.systemsForReview = (draftState.systemsForReview ?? []).filter( (system) => fidesKeySet.has(system.fides_key) ); - // Start reviewing the first system. (ESLint false positive.) - // eslint-disable-next-line prefer-destructuring - draftState.systemInReview = draftState.systemsForReview[0]; }, setAddSystemsMethod: ( draftState, @@ -165,11 +78,7 @@ export const slice = createSlice({ export const { changeStep, - changeReviewStep, - continueReview, reset, - reviewManualSystem, - setSystemInReview, setOrganization, setSystemsForReview, chooseSystemsForReview, @@ -185,21 +94,11 @@ export const selectStep = createSelector( (state) => state.step ); -export const selectReviewStep = createSelector( - selectConfigWizard, - (state) => state.reviewStep -); - export const selectOrganizationFidesKey = createSelector( selectConfigWizard, (state) => state.organization?.fides_key ?? DEFAULT_ORGANIZATION_FIDES_KEY ); -export const selectSystemInReview = createSelector( - selectConfigWizard, - (state) => state.systemInReview -); - export const selectSystemsForReview = createSelector( selectConfigWizard, (state) => state.systemsForReview ?? [] diff --git a/clients/admin-ui/src/features/config-wizard/constants.tsx b/clients/admin-ui/src/features/config-wizard/constants.tsx index bbf590f884..466bbeb657 100644 --- a/clients/admin-ui/src/features/config-wizard/constants.tsx +++ b/clients/admin-ui/src/features/config-wizard/constants.tsx @@ -15,31 +15,6 @@ export const STEPS = [ number: 4, name: "Scan results", }, - { - number: 5, - name: "Describe systems", - }, - { - number: 6, - name: "View your data map", - }, -]; - -export const REVIEW_STEPS = 5; - -export const HORIZONTAL_STEPS = [ - { - number: 1, - name: "Describe", - }, - { - number: 2, - name: "Declare", - }, - { - number: 3, - name: "Review", - }, ]; // When more links like these are introduced we should move them to a single file. diff --git a/clients/admin-ui/src/features/system/SystemOptions.tsx b/clients/admin-ui/src/features/system/SystemOptions.tsx new file mode 100644 index 0000000000..e69de29bb2 From 42c0e06a9c286194e44d90a86260cd8f6bb399ce Mon Sep 17 00:00:00 2001 From: Allison King Date: Mon, 6 Feb 2023 11:08:31 -0500 Subject: [PATCH 02/16] Add new system page --- .../src/features/config-wizard/AddSystem.tsx | 4 ++- .../admin-ui/src/pages/add-systems/new.tsx | 33 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 clients/admin-ui/src/pages/add-systems/new.tsx diff --git a/clients/admin-ui/src/features/config-wizard/AddSystem.tsx b/clients/admin-ui/src/features/config-wizard/AddSystem.tsx index e4681d395b..b65e47cf9d 100644 --- a/clients/admin-ui/src/features/config-wizard/AddSystem.tsx +++ b/clients/admin-ui/src/features/config-wizard/AddSystem.tsx @@ -1,4 +1,5 @@ import { Box, Heading, SimpleGrid, Stack, Text } from "@fidesui/react"; +import { useRouter } from "next/router"; import { useAppDispatch } from "~/app/hooks"; import { @@ -27,6 +28,7 @@ const SectionTitle = ({ children }: { children: string }) => ( const AddSystem = () => { const dispatch = useAppDispatch(); + const router = useRouter(); return ( @@ -55,8 +57,8 @@ const AddSystem = () => { icon={} description="Manually add a system for services not covered by automated scanners" onClick={() => { - dispatch(changeStep(5)); dispatch(setAddSystemsMethod(SystemMethods.MANUAL)); + router.push("/add-system/new"); }} data-testid="manual-btn" /> diff --git a/clients/admin-ui/src/pages/add-systems/new.tsx b/clients/admin-ui/src/pages/add-systems/new.tsx new file mode 100644 index 0000000000..18689e310a --- /dev/null +++ b/clients/admin-ui/src/pages/add-systems/new.tsx @@ -0,0 +1,33 @@ +import { Box, Heading, Spinner } from "@fidesui/react"; +import type { NextPage } from "next"; +import React from "react"; + +import Layout from "~/features/common/Layout"; +import { useGetAllSystemsQuery } from "~/features/system"; +import SystemsManagement from "~/features/system/SystemsManagement"; + +const useSystemsData = () => { + const { data, isLoading } = useGetAllSystemsQuery(); + + return { + isLoading, + systems: data, + }; +}; + +const NewSystem: NextPage = () => { + const { isLoading, systems } = useSystemsData(); + + return ( + + + + Choose a type of system + + + {isLoading ? : } + + ); +}; + +export default NewSystem; From 5d44db166228ca2e0b7b265223c4708b9302f3f5 Mon Sep 17 00:00:00 2001 From: Allison King Date: Mon, 6 Feb 2023 12:07:29 -0500 Subject: [PATCH 03/16] Fix step count bug --- .../admin-ui/src/features/config-wizard/config-wizard.slice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts b/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts index 02a484b361..bcabaea68a 100644 --- a/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts +++ b/clients/admin-ui/src/features/config-wizard/config-wizard.slice.ts @@ -43,7 +43,7 @@ export const slice = createSlice({ // Ensure the step number stays in the valid range. if (draftState.step < 1) { draftState.step = 1; - } else if (STEPS.length <= draftState.step) { + } else if (STEPS.length < draftState.step) { draftState.step = STEPS.length - 1; } }, From ea69f02feaf285caf88e5afd17940a60230ff722 Mon Sep 17 00:00:00 2001 From: Allison King Date: Mon, 6 Feb 2023 12:07:42 -0500 Subject: [PATCH 04/16] Add SystemOptions --- .../src/features/system/SystemOptions.tsx | 5 ++++ .../admin-ui/src/pages/add-systems/new.tsx | 27 +++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/clients/admin-ui/src/features/system/SystemOptions.tsx b/clients/admin-ui/src/features/system/SystemOptions.tsx index e69de29bb2..60841586a2 100644 --- a/clients/admin-ui/src/features/system/SystemOptions.tsx +++ b/clients/admin-ui/src/features/system/SystemOptions.tsx @@ -0,0 +1,5 @@ +import { Box } from "@fidesui/react"; + +const SystemOptions = () => hi; + +export default SystemOptions; diff --git a/clients/admin-ui/src/pages/add-systems/new.tsx b/clients/admin-ui/src/pages/add-systems/new.tsx index 18689e310a..ca85f3d273 100644 --- a/clients/admin-ui/src/pages/add-systems/new.tsx +++ b/clients/admin-ui/src/pages/add-systems/new.tsx @@ -1,12 +1,19 @@ -import { Box, Heading, Spinner } from "@fidesui/react"; +import { + Box, + Breadcrumb, + BreadcrumbItem, + Heading, + Spinner, +} from "@fidesui/react"; import type { NextPage } from "next"; +import NextLink from "next/link"; import React from "react"; import Layout from "~/features/common/Layout"; import { useGetAllSystemsQuery } from "~/features/system"; -import SystemsManagement from "~/features/system/SystemsManagement"; +import SystemOptions from "~/features/system/SystemOptions"; -const useSystemsData = () => { +const useNewSystemData = () => { const { data, isLoading } = useGetAllSystemsQuery(); return { @@ -16,7 +23,7 @@ const useSystemsData = () => { }; const NewSystem: NextPage = () => { - const { isLoading, systems } = useSystemsData(); + const { isLoading } = useNewSystemData(); return ( @@ -24,8 +31,18 @@ const NewSystem: NextPage = () => { Choose a type of system + + + + Add systems + + + Choose your system + + + - {isLoading ? : } + {isLoading ? : } ); }; From 95d647ae596ae63cd544bbf0b0aa5c3f0b76ad46 Mon Sep 17 00:00:00 2001 From: Allison King Date: Mon, 6 Feb 2023 15:19:28 -0500 Subject: [PATCH 05/16] Search feature --- .../src/features/common/SearchBar.tsx | 33 ++++++++++++++++--- .../src/features/config-wizard/AddSystem.tsx | 2 +- .../src/features/system/SystemCatalog.tsx | 24 ++++++++++++++ .../src/features/system/SystemOptions.tsx | 5 --- .../src/features/system/SystemsManagement.tsx | 1 + .../admin-ui/src/pages/add-systems/new.tsx | 20 ++++++++--- 6 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 clients/admin-ui/src/features/system/SystemCatalog.tsx delete mode 100644 clients/admin-ui/src/features/system/SystemOptions.tsx diff --git a/clients/admin-ui/src/features/common/SearchBar.tsx b/clients/admin-ui/src/features/common/SearchBar.tsx index a4cab616b2..6285e2aebf 100644 --- a/clients/admin-ui/src/features/common/SearchBar.tsx +++ b/clients/admin-ui/src/features/common/SearchBar.tsx @@ -1,24 +1,36 @@ import { + Button, Input, InputGroup, InputLeftElement, InputProps, + InputRightElement, SearchLineIcon, } from "@fidesui/react"; interface Props extends Omit { search?: string; onChange: (value: string) => void; + withIcon?: boolean; + withClear?: boolean; } -const SearchBar = ({ search, onChange, ...props }: Props) => { +const SearchBar = ({ + search, + onChange, + withIcon, + withClear, + ...props +}: Props) => { const handleSearchChange = (event: React.ChangeEvent) => onChange(event.target.value); return ( - - - + {withIcon ? ( + + + + ) : null} { onChange={handleSearchChange} {...props} /> + {withClear ? ( + + + + ) : null} ); }; diff --git a/clients/admin-ui/src/features/config-wizard/AddSystem.tsx b/clients/admin-ui/src/features/config-wizard/AddSystem.tsx index b65e47cf9d..a7ca49df8c 100644 --- a/clients/admin-ui/src/features/config-wizard/AddSystem.tsx +++ b/clients/admin-ui/src/features/config-wizard/AddSystem.tsx @@ -58,7 +58,7 @@ const AddSystem = () => { description="Manually add a system for services not covered by automated scanners" onClick={() => { dispatch(setAddSystemsMethod(SystemMethods.MANUAL)); - router.push("/add-system/new"); + router.push("/add-systems/new"); }} data-testid="manual-btn" /> diff --git a/clients/admin-ui/src/features/system/SystemCatalog.tsx b/clients/admin-ui/src/features/system/SystemCatalog.tsx new file mode 100644 index 0000000000..ae56bc4eec --- /dev/null +++ b/clients/admin-ui/src/features/system/SystemCatalog.tsx @@ -0,0 +1,24 @@ +import { Box } from "@fidesui/react"; +import { useState } from "react"; + +import SearchBar from "~/features/common/SearchBar"; + +const SystemCatalog = () => { + const [searchFilter, setSearchFilter] = useState(""); + + return ( + + + + + + ); +}; + +export default SystemCatalog; diff --git a/clients/admin-ui/src/features/system/SystemOptions.tsx b/clients/admin-ui/src/features/system/SystemOptions.tsx deleted file mode 100644 index 60841586a2..0000000000 --- a/clients/admin-ui/src/features/system/SystemOptions.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { Box } from "@fidesui/react"; - -const SystemOptions = () => hi; - -export default SystemOptions; diff --git a/clients/admin-ui/src/features/system/SystemsManagement.tsx b/clients/admin-ui/src/features/system/SystemsManagement.tsx index a379fe60ac..b87b3ca7e8 100644 --- a/clients/admin-ui/src/features/system/SystemsManagement.tsx +++ b/clients/admin-ui/src/features/system/SystemsManagement.tsx @@ -37,6 +37,7 @@ const SystemsManagement = ({ systems }: Props) => { maxWidth="30vw" placeholder="Search system name or description" data-testid="system-search" + withIcon /> diff --git a/clients/admin-ui/src/pages/add-systems/new.tsx b/clients/admin-ui/src/pages/add-systems/new.tsx index ca85f3d273..e9dc30318e 100644 --- a/clients/admin-ui/src/pages/add-systems/new.tsx +++ b/clients/admin-ui/src/pages/add-systems/new.tsx @@ -4,14 +4,16 @@ import { BreadcrumbItem, Heading, Spinner, + Text, } from "@fidesui/react"; import type { NextPage } from "next"; import NextLink from "next/link"; import React from "react"; +import { useInterzoneNav } from "~/features/common/hooks/useInterzoneNav"; import Layout from "~/features/common/Layout"; import { useGetAllSystemsQuery } from "~/features/system"; -import SystemOptions from "~/features/system/SystemOptions"; +import SystemCatalog from "~/features/system/SystemCatalog"; const useNewSystemData = () => { const { data, isLoading } = useGetAllSystemsQuery(); @@ -24,15 +26,19 @@ const useNewSystemData = () => { const NewSystem: NextPage = () => { const { isLoading } = useNewSystemData(); + const { systemOrDatamapRoute } = useInterzoneNav(); return ( - - + + Choose a type of system + + Data map + Add systems @@ -41,8 +47,14 @@ const NewSystem: NextPage = () => { + + Systems are anything that might store or process data in your + organization, from a web application, to a database or data warehouse. + Pick from common system types below or create a new type of system to + get started. + - {isLoading ? : } + {isLoading ? : } ); }; From a548a2896d07bfb8c00ebe571b643e991f2fc136 Mon Sep 17 00:00:00 2001 From: Allison King Date: Mon, 6 Feb 2023 16:05:53 -0500 Subject: [PATCH 06/16] Implement search --- .../src/features/system/SystemCatalog.tsx | 34 +++++++++++++++++-- .../admin-ui/src/pages/add-systems/new.tsx | 24 ++----------- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/clients/admin-ui/src/features/system/SystemCatalog.tsx b/clients/admin-ui/src/features/system/SystemCatalog.tsx index ae56bc4eec..0f8d5eacb1 100644 --- a/clients/admin-ui/src/features/system/SystemCatalog.tsx +++ b/clients/admin-ui/src/features/system/SystemCatalog.tsx @@ -1,14 +1,41 @@ -import { Box } from "@fidesui/react"; -import { useState } from "react"; +import { Box, Spinner, Text } from "@fidesui/react"; +import { useMemo, useState } from "react"; import SearchBar from "~/features/common/SearchBar"; +import { useGetAllConnectionTypesQuery } from "~/features/connection-type"; +import ConnectionTypeList from "~/features/datastore-connections/add-connection/ConnectionTypeList"; +import { ConnectionSystemTypeMap } from "~/types/api"; + +const SEARCH_FILTER = (connection: ConnectionSystemTypeMap, search: string) => + connection.human_readable + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase()) || + connection.identifier + .toLocaleLowerCase() + .includes(search.toLocaleLowerCase()); const SystemCatalog = () => { const [searchFilter, setSearchFilter] = useState(""); + const { data, isLoading } = useGetAllConnectionTypesQuery({}); + const filteredConnections = useMemo(() => { + if (!data) { + return []; + } + + return data.items.filter((s) => SEARCH_FILTER(s, searchFilter)); + }, [data, searchFilter]); + + if (isLoading) { + return ; + } + + if (!data) { + return Could not find system types, please try again.; + } return ( - + { withClear /> + ); }; diff --git a/clients/admin-ui/src/pages/add-systems/new.tsx b/clients/admin-ui/src/pages/add-systems/new.tsx index e9dc30318e..7350042150 100644 --- a/clients/admin-ui/src/pages/add-systems/new.tsx +++ b/clients/admin-ui/src/pages/add-systems/new.tsx @@ -1,31 +1,13 @@ -import { - Box, - Breadcrumb, - BreadcrumbItem, - Heading, - Spinner, - Text, -} from "@fidesui/react"; +import { Box, Breadcrumb, BreadcrumbItem, Heading, Text } from "@fidesui/react"; import type { NextPage } from "next"; import NextLink from "next/link"; import React from "react"; import { useInterzoneNav } from "~/features/common/hooks/useInterzoneNav"; import Layout from "~/features/common/Layout"; -import { useGetAllSystemsQuery } from "~/features/system"; import SystemCatalog from "~/features/system/SystemCatalog"; -const useNewSystemData = () => { - const { data, isLoading } = useGetAllSystemsQuery(); - - return { - isLoading, - systems: data, - }; -}; - const NewSystem: NextPage = () => { - const { isLoading } = useNewSystemData(); const { systemOrDatamapRoute } = useInterzoneNav(); return ( @@ -42,7 +24,7 @@ const NewSystem: NextPage = () => { Add systems - + Choose your system @@ -54,7 +36,7 @@ const NewSystem: NextPage = () => { get started. - {isLoading ? : } + ); }; From 9b35720c147ecb3f3cb68db3ac4307c826524604 Mon Sep 17 00:00:00 2001 From: Allison King Date: Mon, 6 Feb 2023 17:12:09 -0500 Subject: [PATCH 07/16] Accept image props on logo --- .../features/datastore-connections/ConnectionTypeLogo.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/clients/admin-ui/src/features/datastore-connections/ConnectionTypeLogo.tsx b/clients/admin-ui/src/features/datastore-connections/ConnectionTypeLogo.tsx index 81616ddecb..f9a06fc09a 100644 --- a/clients/admin-ui/src/features/datastore-connections/ConnectionTypeLogo.tsx +++ b/clients/admin-ui/src/features/datastore-connections/ConnectionTypeLogo.tsx @@ -1,4 +1,4 @@ -import { Image } from "@fidesui/react"; +import { Image, ImageProps } from "@fidesui/react"; import React from "react"; import { ConnectionType } from "~/types/api"; @@ -14,7 +14,10 @@ type ConnectionTypeLogoProps = { data: string | DatastoreConnection; }; -const ConnectionTypeLogo: React.FC = ({ data }) => { +const ConnectionTypeLogo: React.FC = ({ + data, + ...props +}) => { const getImageSrc = (): string => { let item; if (isDatastoreConnection(data)) { @@ -41,6 +44,7 @@ const ConnectionTypeLogo: React.FC = ({ data }) => { src={getImageSrc()} fallbackSrc={FALLBACK_CONNECTOR_LOGOS_PATH} alt={isDatastoreConnection(data) ? data.name : data} + {...props} /> ); }; From 96b4658ca55b0c5ecc44bc39f9fd85213a01cefd Mon Sep 17 00:00:00 2001 From: Allison King Date: Mon, 6 Feb 2023 17:13:45 -0500 Subject: [PATCH 08/16] Handle navigating to describe page --- .../src/features/system/DescribeSystem.tsx | 10 +++ .../src/features/system/SystemCatalog.tsx | 32 +++++-- .../admin-ui/src/pages/add-systems/new.tsx | 83 ++++++++++++++++--- 3 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 clients/admin-ui/src/features/system/DescribeSystem.tsx diff --git a/clients/admin-ui/src/features/system/DescribeSystem.tsx b/clients/admin-ui/src/features/system/DescribeSystem.tsx new file mode 100644 index 0000000000..943a74e952 --- /dev/null +++ b/clients/admin-ui/src/features/system/DescribeSystem.tsx @@ -0,0 +1,10 @@ +import { Box } from "@fidesui/react"; + +/** + * This is named very similarly to DescribeSystemStep because both components + * do very similar things. In the future, these two flows may be consolidated, + * and at that point we should also consolidate the components. + */ +const DescribeSystem = () => TODO: tabs; + +export default DescribeSystem; diff --git a/clients/admin-ui/src/features/system/SystemCatalog.tsx b/clients/admin-ui/src/features/system/SystemCatalog.tsx index 0f8d5eacb1..6d33c2eb90 100644 --- a/clients/admin-ui/src/features/system/SystemCatalog.tsx +++ b/clients/admin-ui/src/features/system/SystemCatalog.tsx @@ -1,4 +1,5 @@ -import { Box, Spinner, Text } from "@fidesui/react"; +import { Box, Button, Spinner, Text } from "@fidesui/react"; +import NextLink from "next/link"; import { useMemo, useState } from "react"; import SearchBar from "~/features/common/SearchBar"; @@ -35,14 +36,27 @@ const SystemCatalog = () => { return ( - - + + + + + + + diff --git a/clients/admin-ui/src/pages/add-systems/new.tsx b/clients/admin-ui/src/pages/add-systems/new.tsx index 7350042150..2577acf4a3 100644 --- a/clients/admin-ui/src/pages/add-systems/new.tsx +++ b/clients/admin-ui/src/pages/add-systems/new.tsx @@ -1,21 +1,72 @@ import { Box, Breadcrumb, BreadcrumbItem, Heading, Text } from "@fidesui/react"; import type { NextPage } from "next"; import NextLink from "next/link"; -import React from "react"; +import { useRouter } from "next/router"; +import React, { useMemo } from "react"; import { useInterzoneNav } from "~/features/common/hooks/useInterzoneNav"; import Layout from "~/features/common/Layout"; +import ConnectionTypeLogo from "~/features/datastore-connections/ConnectionTypeLogo"; +import DescribeSystem from "~/features/system/DescribeSystem"; import SystemCatalog from "~/features/system/SystemCatalog"; +import { ConnectionSystemTypeMap } from "~/types/api"; + +const CHOOSE_SYSTEM_COPY = + "Systems are anything that might store or process data in your organization, from a web application, to a database or data warehouse. Pick from common system types below or create a new type of system to get started."; +const DESCRIBE_SYSTEM_COPY = + "Systems are anything that might store or process data in your organization, from a web application, to a database or data warehouse. Describe your system below to register it to the map. You may optionally complete data entry for the system using the additional tabs to navigate the sections."; + +type Step = "choose-system" | "describe-system"; + +const Header = ({ + step, + connector, +}: { + step: Step; + connector?: ConnectionSystemTypeMap; +}) => { + if (step === "choose-system") { + return ( + + Choose a type of system + + ); + } + + return ( + + + + Describe your {connector ? connector.human_readable : "new"} system + + + ); +}; const NewSystem: NextPage = () => { const { systemOrDatamapRoute } = useInterzoneNav(); + const router = useRouter(); + const { step, connectorType } = router.query; + + const currentStep: Step = step === "2" ? "describe-system" : "choose-system"; + const connector: ConnectionSystemTypeMap | undefined = useMemo(() => { + if (!connectorType) { + return undefined; + } + + const value = Array.isArray(connectorType) + ? connectorType[0] + : connectorType; + return JSON.parse(value); + }, [connectorType]); return ( - - Choose a type of system - +
@@ -24,19 +75,29 @@ const NewSystem: NextPage = () => { Add systems - - Choose your system + + Choose your system + {currentStep === "describe-system" ? ( + + Describe your system + + ) : null} - Systems are anything that might store or process data in your - organization, from a web application, to a database or data warehouse. - Pick from common system types below or create a new type of system to - get started. + {currentStep === "choose-system" + ? CHOOSE_SYSTEM_COPY + : DESCRIBE_SYSTEM_COPY} - + {currentStep === "choose-system" ? : } ); }; From dac10f13e172446d7e2d7623a0bad10679ac7af0 Mon Sep 17 00:00:00 2001 From: Allison King Date: Tue, 7 Feb 2023 11:10:28 -0500 Subject: [PATCH 09/16] Update cypress tests and add search empty state --- clients/admin-ui/cypress/e2e/systems.cy.ts | 41 ++++++++++++- .../add-connection/ConnectionTypeList.tsx | 1 + .../src/features/system/SystemCatalog.tsx | 58 ++++++++++++++----- .../admin-ui/src/pages/add-systems/new.tsx | 11 +++- 4 files changed, 94 insertions(+), 17 deletions(-) diff --git a/clients/admin-ui/cypress/e2e/systems.cy.ts b/clients/admin-ui/cypress/e2e/systems.cy.ts index b5c52579b7..abab3c1fc6 100644 --- a/clients/admin-ui/cypress/e2e/systems.cy.ts +++ b/clients/admin-ui/cypress/e2e/systems.cy.ts @@ -56,9 +56,48 @@ describe("System management page", () => { beforeEach(() => { stubTaxonomyEntities(); stubSystemCrud(); + cy.intercept("GET", "/api/v1/connection_type*", { + fixture: "connectors/connection_types.json", + }).as("getConnectionTypes"); }); - it("Can step through the flow", () => { + it("shows available system types and lets the user choose one", () => { + cy.visit("/add-systems"); + cy.getByTestId("manual-btn").click(); + cy.url().should("contain", "/add-systems/new"); + cy.wait("@getConnectionTypes"); + cy.getByTestId("header").contains("Choose a type of system"); + cy.getByTestId("bigquery"); + cy.getByTestId("mariadb"); + // Click into one of the connectors + cy.getByTestId("mongodb").click(); + cy.getByTestId("header").contains("Describe your MongoDB system"); + + // Go back to choosing to add a new type of system + cy.getByTestId("breadcrumbs").contains("Choose your system").click(); + cy.getByTestId("create-system-btn").click(); + cy.getByTestId("header").contains("Describe your new system"); + }); + + it("should allow searching", () => { + cy.visit("/add-systems/new"); + cy.wait("@getConnectionTypes"); + cy.getByTestId("bigquery"); + cy.getByTestId("system-catalog-search").type("db"); + cy.getByTestId("bigquery").should("not.exist"); + cy.getByTestId("mariadb"); + cy.getByTestId("mongodb"); + cy.getByTestId("timescale"); + + // empty state + cy.getByTestId("system-catalog-search") + .clear() + .type("a very specific system that we do not have"); + cy.getByTestId("no-systems-found"); + }); + + // Skip while manual system creation is being redone + it.skip("Can step through the flow", () => { cy.fixture("system.json").then((system) => { // Fill in the describe form based on fixture data cy.visit("/add-systems"); diff --git a/clients/admin-ui/src/features/datastore-connections/add-connection/ConnectionTypeList.tsx b/clients/admin-ui/src/features/datastore-connections/add-connection/ConnectionTypeList.tsx index 680cd0cf4d..ced5af42c5 100644 --- a/clients/admin-ui/src/features/datastore-connections/add-connection/ConnectionTypeList.tsx +++ b/clients/admin-ui/src/features/datastore-connections/add-connection/ConnectionTypeList.tsx @@ -29,6 +29,7 @@ const ConnectionTypeList: React.FC = ({ items }) => ( boxShadow: "lg", cursor: "pointer", }} + data-testid={i.identifier} > { return []; } - return data.items.filter((s) => SEARCH_FILTER(s, searchFilter)); + return data.items.filter((i) => SEARCH_FILTER(i, searchFilter)); }, [data, searchFilter]); if (isLoading) { @@ -34,6 +41,11 @@ const SystemCatalog = () => { return Could not find system types, please try again.; } + const noSearchResults = + data.items.length > 0 && + searchFilter.length > 0 && + filteredConnections.length === 0; + return ( @@ -46,17 +58,37 @@ const SystemCatalog = () => { withClear /> - - - + + {noSearchResults ? ( + <> + + + No systems found + + + ) : null} + + + + diff --git a/clients/admin-ui/src/pages/add-systems/new.tsx b/clients/admin-ui/src/pages/add-systems/new.tsx index 2577acf4a3..a0e5fd02b7 100644 --- a/clients/admin-ui/src/pages/add-systems/new.tsx +++ b/clients/admin-ui/src/pages/add-systems/new.tsx @@ -27,14 +27,14 @@ const Header = ({ }) => { if (step === "choose-system") { return ( - + Choose a type of system ); } return ( - + {
- + Data map From e09fd5076e913481f87065b4496481b11ae93d17 Mon Sep 17 00:00:00 2001 From: Allison King Date: Tue, 7 Feb 2023 11:49:22 -0500 Subject: [PATCH 10/16] Decouple system management from config wizard --- .../features/system/DescribeSystemStep.tsx | 14 ++++------ .../src/features/system/ManualSystemFlow.tsx | 27 ++++++++++--------- .../system/PrivacyDeclarationStep.tsx | 19 +++++++------ .../src/features/system/ReviewSystemStep.tsx | 15 +++-------- 4 files changed, 33 insertions(+), 42 deletions(-) diff --git a/clients/admin-ui/src/features/system/DescribeSystemStep.tsx b/clients/admin-ui/src/features/system/DescribeSystemStep.tsx index 320eec639d..10355c1082 100644 --- a/clients/admin-ui/src/features/system/DescribeSystemStep.tsx +++ b/clients/admin-ui/src/features/system/DescribeSystemStep.tsx @@ -5,14 +5,13 @@ import { Form, Formik } from "formik"; import { useMemo, useState } from "react"; import * as Yup from "yup"; -import { useAppDispatch, useAppSelector } from "~/app/hooks"; +import { useAppSelector } from "~/app/hooks"; import { CustomCreatableMultiSelect, CustomMultiSelect, CustomTextInput, } from "~/features/common/form/inputs"; import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; -import { changeStep } from "~/features/config-wizard/config-wizard.slice"; import DescribeSystemsFormExtension from "~/features/system/DescribeSystemsFormExtension"; import { defaultInitialValues, @@ -47,12 +46,14 @@ const SystemHeading = ({ system }: { system?: System }) => { interface Props { onSuccess: (system: System) => void; + onCancel: () => void; abridged?: boolean; system?: System; } const DescribeSystemStep = ({ onSuccess, + onCancel, abridged, system: passedInSystem, }: Props) => { @@ -66,7 +67,6 @@ const DescribeSystemStep = ({ const [createSystem] = useCreateSystemMutation(); const [updateSystem] = useUpdateSystemMutation(); const [isLoading, setIsLoading] = useState(false); - const dispatch = useAppDispatch(); const systems = useAppSelector(selectAllSystems); const systemOptions = systems ? systems.map((s) => ({ label: s.name ?? s.fides_key, value: s.fides_key })) @@ -82,10 +82,6 @@ const DescribeSystemStep = ({ const toast = useToast(); - const handleBack = () => { - dispatch(changeStep(2)); - }; - const handleSubmit = async (values: FormValues) => { const systemBody = transformFormValuesToSystem(values); @@ -192,13 +188,13 @@ const DescribeSystemStep = ({ {/* TODO FUTURE: This button doesn't do any registering yet until data maps are added */} From 36ac56347da3476e4ac86ffd06d5f3f41f4d95f7 Mon Sep 17 00:00:00 2001 From: Allison King Date: Thu, 16 Feb 2023 12:51:58 -0500 Subject: [PATCH 13/16] Describe system update (#2590) --- clients/admin-ui/cypress/e2e/systems.cy.ts | 131 +++++------ .../admin-ui/src/features/common/toast.tsx | 2 +- .../src/features/system/DescribeSystem.tsx | 10 - .../system/DescribeSystemsFormExtension.tsx | 82 ------- ...anualSystemFlow.tsx => EditSystemFlow.tsx} | 66 +----- .../system/PrivacyDeclarationStep.tsx | 4 +- .../src/features/system/ReviewSystemStep.tsx | 105 --------- .../src/features/system/SystemFormTabs.tsx | 208 ++++++++++++++++++ ...stemStep.tsx => SystemInformationForm.tsx} | 161 +++++++------- .../system/SystemInformationFormExtension.tsx | 115 ++++++++++ .../src/features/system/UnmountWarning.tsx | 54 +++++ .../admin-ui/src/pages/add-systems/new.tsx | 14 +- .../admin-ui/src/pages/system/configure.tsx | 4 +- 13 files changed, 523 insertions(+), 433 deletions(-) delete mode 100644 clients/admin-ui/src/features/system/DescribeSystem.tsx delete mode 100644 clients/admin-ui/src/features/system/DescribeSystemsFormExtension.tsx rename clients/admin-ui/src/features/system/{ManualSystemFlow.tsx => EditSystemFlow.tsx} (65%) delete mode 100644 clients/admin-ui/src/features/system/ReviewSystemStep.tsx create mode 100644 clients/admin-ui/src/features/system/SystemFormTabs.tsx rename clients/admin-ui/src/features/system/{DescribeSystemStep.tsx => SystemInformationForm.tsx} (50%) create mode 100644 clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx create mode 100644 clients/admin-ui/src/features/system/UnmountWarning.tsx diff --git a/clients/admin-ui/cypress/e2e/systems.cy.ts b/clients/admin-ui/cypress/e2e/systems.cy.ts index b050ab5d8f..424ebbbb9c 100644 --- a/clients/admin-ui/cypress/e2e/systems.cy.ts +++ b/clients/admin-ui/cypress/e2e/systems.cy.ts @@ -96,27 +96,20 @@ describe("System management page", () => { cy.getByTestId("no-systems-found"); }); - // Skip while manual system creation is being redone - it.skip("Can step through the flow", () => { + it("Can step through the flow", () => { cy.fixture("system.json").then((system) => { // Fill in the describe form based on fixture data cy.visit("/add-systems"); cy.getByTestId("manual-btn").click(); - cy.url().should("contain", "/add-systems"); + cy.url().should("contain", "/add-systems/new"); cy.wait("@getSystems"); + cy.wait("@getConnectionTypes"); + cy.getByTestId("create-system-btn").click(); cy.getByTestId("input-name").type(system.name); cy.getByTestId("input-fides_key").type(system.fides_key); cy.getByTestId("input-description").type(system.description); - cy.getByTestId("input-system_type").type(system.system_type); - system.tags.forEach((tag) => { - cy.getByTestId("input-tags").type(`${tag}{enter}`); - }); - cy.getByTestId("input-system_dependencies").click(); - cy.getByTestId("input-system_dependencies").within(() => { - cy.contains("Demo Analytics System").click(); - }); - cy.getByTestId("confirm-btn").click(); + cy.getByTestId("save-btn").click(); cy.wait("@postSystem").then((interception) => { const { body } = interception.request; expect(body).to.eql({ @@ -124,15 +117,16 @@ describe("System management page", () => { organization_fides_key: system.organization_fides_key, fides_key: system.fides_key, description: system.description, - system_type: system.system_type, - tags: system.tags, + system_type: "", + tags: [], privacy_declarations: [], third_country_transfers: [], - system_dependencies: ["demo_analytics_system"], + system_dependencies: [], }); }); // Fill in the privacy declaration form + cy.getByTestId("tab-Privacy declarations").click(); cy.wait("@getDataCategories"); cy.wait("@getDataQualifiers"); cy.wait("@getDataSubjects"); @@ -156,7 +150,7 @@ describe("System management page", () => { cy.contains(declaration.data_qualifier).click(); }); cy.getByTestId("add-btn").click(); - cy.getByTestId("next-btn").click(); + cy.getByTestId("save-btn").click(); cy.wait("@putSystem").then((interception) => { const { body } = interception.request; expect(body.privacy_declarations[0]).to.eql({ @@ -164,50 +158,41 @@ describe("System management page", () => { dataset_references: [], }); }); - - // Now at the Review stage - cy.getByTestId("review-heading"); - cy.getByTestId("review-System name").contains(system.name); - cy.getByTestId("review-System key").contains(system.fides_key); - cy.getByTestId("review-System description").contains( - system.description - ); - cy.getByTestId("review-System type").contains(system.system_type); - system.tags.forEach((tag) => { - cy.getByTestId("review-System tags").contains(tag); - }); - system.system_dependencies.forEach((dep) => { - cy.getByTestId("review-System dependencies").contains(dep); - }); - // Open up the privacy declaration - cy.getByTestId( - "declaration-Analyze customer behaviour for improvements." - ).click(); - const reviewDeclaration = system.privacy_declarations[0]; - reviewDeclaration.data_categories.forEach((dc) => { - cy.getByTestId("declaration-Data categories").contains(dc); - }); - cy.getByTestId("declaration-Data use").contains( - reviewDeclaration.data_use - ); - reviewDeclaration.data_subjects.forEach((ds) => { - cy.getByTestId("declaration-Data subjects").contains(ds); - }); - cy.getByTestId("declaration-Data qualifier").contains( - reviewDeclaration.data_qualifier - ); - - cy.getByTestId("confirm-btn").click(); - - // Success page - cy.getByTestId("success-page-heading").should( - "contain", - `${system.name} successfully registered` - ); - cy.getByTestId("finish-btn").click(); - cy.url().should("match", /system$/); }); }); + + it("can render a warning when there is unsaved data", () => { + cy.visit("/add-systems/new"); + cy.wait("@getSystems"); + cy.wait("@getConnectionTypes"); + cy.getByTestId("create-system-btn").click(); + cy.getByTestId("input-name").type("test"); + cy.getByTestId("input-fides_key").type("test"); + cy.getByTestId("save-btn").click(); + cy.wait("@postSystem"); + + // start typing a description + const description = "half formed thought"; + cy.getByTestId("input-description").type(description); + // then try navigating to the privacy declarations tab + cy.getByTestId("tab-Privacy declarations").click(); + cy.getByTestId("confirmation-modal"); + // make sure canceling works + cy.getByTestId("cancel-btn").click(); + cy.getByTestId("input-description").should("have.value", description); + // now actually discard + cy.getByTestId("tab-Privacy declarations").click(); + cy.getByTestId("continue-btn").click(); + // should load the privacy declarations page + cy.wait("@getDataCategories"); + cy.wait("@getDataQualifiers"); + cy.wait("@getDataSubjects"); + cy.wait("@getDataUses"); + cy.getByTestId("privacy-declaration-form"); + // navigate back + cy.getByTestId("tab-System information").click(); + cy.getByTestId("input-description").should("have.value", ""); + }); }); }); @@ -275,7 +260,6 @@ describe("System management page", () => { "have.value", "Software that functionally applies Fides." ); - cy.getByTestId("input-system_type").should("have.value", "Service"); cy.getByTestId("input-data_responsibility_title").should( "contain", "Controller" @@ -287,13 +271,14 @@ describe("System management page", () => { // add something for joint controller const controllerName = "Sally Controller"; cy.getByTestId("input-joint_controller.name").type(controllerName); - cy.getByTestId("confirm-btn").click(); + cy.getByTestId("save-btn").click(); cy.wait("@putSystem").then((interception) => { const { body } = interception.request; expect(body.joint_controller.name).to.eql(controllerName); }); // add another privacy declaration + cy.getByTestId("tab-Privacy declarations").click(); const secondName = "Second declaration"; cy.getByTestId("privacy-declaration-form"); cy.getByTestId("input-name").type(secondName); @@ -314,7 +299,7 @@ describe("System management page", () => { cy.getByTestId(`declaration-${newName}`); cy.getByTestId(`declaration-${secondName}`); - cy.getByTestId("next-btn").click(); + cy.getByTestId("save-btn").click(); cy.wait("@putSystem").then((interception) => { const { body } = interception.request; expect(body.privacy_declarations.length).to.eql(2); @@ -378,7 +363,7 @@ describe("System management page", () => { system.data_protection_impact_assessment.link ); - cy.getByTestId("confirm-btn").click(); + cy.getByTestId("save-btn").click(); cy.wait("@putSystem").then((interception) => { const { body } = interception.request; const { @@ -408,6 +393,7 @@ describe("System management page", () => { }); // Add privacy declaration form + cy.getByTestId("tab-Privacy declarations").click(); cy.wait("@getDataCategories"); cy.wait("@getDataQualifiers"); cy.wait("@getDataSubjects"); @@ -434,30 +420,11 @@ describe("System management page", () => { cy.contains("Demo Users Dataset 2").click(); }); cy.getByTestId("add-btn").click(); - cy.getByTestId("next-btn").click(); + cy.getByTestId("save-btn").click(); cy.wait("@putSystem").then((interception) => { const { body } = interception.request; expect(body.privacy_declarations[1]).to.eql(declaration); }); - - // Now at the Review stage - cy.getByTestId("review-heading"); - cy.getByTestId("review-Data responsibility title").contains("Controller"); - cy.getByTestId("review-Administrating department").contains( - "Engineering" - ); - cy.getByTestId("review-Geographic location").contains("USA"); - cy.getByTestId("review-Geographic location").contains("CAN"); - cy.getByTestId("review-Joint controller").within(() => { - cy.getByTestId("review-Name").contains("Sally Controller"); - }); - cy.getByTestId("review-Data protection impact assessment").within(() => { - cy.getByTestId("review-Is required").contains("Yes"); - cy.getByTestId("review-Progress").contains("Complete"); - cy.getByTestId("review-Link").contains( - "https://example.org/analytics_system_data_protection_impact_assessment" - ); - }); }); }); }); diff --git a/clients/admin-ui/src/features/common/toast.tsx b/clients/admin-ui/src/features/common/toast.tsx index 64c31ff2e4..25f986c5b9 100644 --- a/clients/admin-ui/src/features/common/toast.tsx +++ b/clients/admin-ui/src/features/common/toast.tsx @@ -12,7 +12,7 @@ const ErrorMessage = ({ message }: { message: string }) => ( ); -const DEFAULT_TOAST_PARAMS: UseToastOptions = { +export const DEFAULT_TOAST_PARAMS: UseToastOptions = { variant: "subtle", position: "top", description: "", diff --git a/clients/admin-ui/src/features/system/DescribeSystem.tsx b/clients/admin-ui/src/features/system/DescribeSystem.tsx deleted file mode 100644 index 943a74e952..0000000000 --- a/clients/admin-ui/src/features/system/DescribeSystem.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Box } from "@fidesui/react"; - -/** - * This is named very similarly to DescribeSystemStep because both components - * do very similar things. In the future, these two flows may be consolidated, - * and at that point we should also consolidate the components. - */ -const DescribeSystem = () => TODO: tabs; - -export default DescribeSystem; diff --git a/clients/admin-ui/src/features/system/DescribeSystemsFormExtension.tsx b/clients/admin-ui/src/features/system/DescribeSystemsFormExtension.tsx deleted file mode 100644 index b3218ac665..0000000000 --- a/clients/admin-ui/src/features/system/DescribeSystemsFormExtension.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { Box, FormLabel, Stack } from "@fidesui/react"; - -import { YesNoOptions } from "~/features/common/constants"; -import { COUNTRY_OPTIONS } from "~/features/common/countries"; -import { - CustomRadioGroup, - CustomSelect, - CustomTextInput, -} from "~/features/common/form/inputs"; -import { enumToOptions } from "~/features/common/helpers"; -import QuestionTooltip from "~/features/common/QuestionTooltip"; -import { DataResponsibilityTitle } from "~/types/api"; - -import type { FormValues } from "./form"; - -const dataResponsibilityOptions = enumToOptions(DataResponsibilityTitle); - -const DescribeSystemsFormExtension = ({ values }: { values: FormValues }) => ( - <> - - - - - - Joint controller - - - - - - - - - - - - Data protection impact assessment - - - - - {values.data_protection_impact_assessment?.is_required === "true" ? ( - <> - - - - ) : null} - - - -); - -export default DescribeSystemsFormExtension; diff --git a/clients/admin-ui/src/features/system/ManualSystemFlow.tsx b/clients/admin-ui/src/features/system/EditSystemFlow.tsx similarity index 65% rename from clients/admin-ui/src/features/system/ManualSystemFlow.tsx rename to clients/admin-ui/src/features/system/EditSystemFlow.tsx index e362a8b84f..eb1973d2d2 100644 --- a/clients/admin-ui/src/features/system/ManualSystemFlow.tsx +++ b/clients/admin-ui/src/features/system/EditSystemFlow.tsx @@ -4,16 +4,15 @@ import { useRouter } from "next/router"; import { useCallback, useState } from "react"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; -import DataTabs, { TabData } from "~/features/common/DataTabs"; import { System } from "~/types/api"; -import DescribeSystemStep from "./DescribeSystemStep"; import PrivacyDeclarationStep from "./PrivacyDeclarationStep"; -import ReviewSystemStep from "./ReviewSystemStep"; import { selectActiveSystem, setActiveSystem } from "./system.slice"; +import SystemFormTabs from "./SystemFormTabs"; +import SystemInformationForm from "./SystemInformationForm"; import SystemRegisterSuccess from "./SystemRegisterSuccess"; -const STEPS = ["Describe", "Declare", "Review"]; +const STEPS = ["Describe", "Declare"]; interface ConfigureStepsProps { steps: string[]; @@ -44,7 +43,7 @@ const ConfigureSteps = ({ ); -const ManualSystemFlow = () => { +const EditSystemFlow = () => { const router = useRouter(); const dispatch = useAppDispatch(); const [currentStepIndex, setCurrentStepIndex] = useState(0); @@ -70,53 +69,11 @@ const ManualSystemFlow = () => { setCurrentStepIndex(currentStepIndex - 1); }; - const TABS: TabData[] = [ - { - label: STEPS[0], - content: ( - - ), - }, - { - label: STEPS[1], - content: activeSystem ? ( - - ) : null, - isDisabled: !activeSystem, - }, - { - label: STEPS[2], - content: activeSystem ? ( - - ) : null, - isDisabled: !activeSystem, - }, - ]; - return ( <> {navV2 && ( - + )} {!navV2 && ( @@ -133,10 +90,10 @@ const ManualSystemFlow = () => { {currentStepIndex === 0 ? ( - ) : null} {currentStepIndex === 1 && activeSystem ? ( @@ -147,13 +104,6 @@ const ManualSystemFlow = () => { /> ) : null} {currentStepIndex === 2 && activeSystem ? ( - setCurrentStepIndex(currentStepIndex + 1)} - /> - ) : null} - {currentStepIndex === 3 && activeSystem ? ( { ); }; -export default ManualSystemFlow; +export default EditSystemFlow; diff --git a/clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx b/clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx index 102ff98c13..01ab319626 100644 --- a/clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx +++ b/clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx @@ -157,10 +157,10 @@ const PrivacyDeclarationStep = ({ colorScheme="primary" size="sm" isLoading={isLoading} - data-testid="next-btn" + data-testid="save-btn" onClick={handleSubmit} > - Next + Save diff --git a/clients/admin-ui/src/features/system/ReviewSystemStep.tsx b/clients/admin-ui/src/features/system/ReviewSystemStep.tsx deleted file mode 100644 index 8500bbac0f..0000000000 --- a/clients/admin-ui/src/features/system/ReviewSystemStep.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { - Box, - Button, - Divider, - FormLabel, - Heading, - Stack, - Text, -} from "@fidesui/react"; -import { Form, Formik } from "formik"; -import { Fragment } from "react"; - -import ReviewSystemFormExtension from "~/features/system/ReviewSystemFormExtension"; -import { System } from "~/types/api"; - -import TaxonomyEntityTag from "../taxonomy/TaxonomyEntityTag"; -import { ReviewItem } from "./form-layout"; -import PrivacyDeclarationAccordion from "./PrivacyDeclarationAccordion"; - -interface Props { - system: System; - onSuccess: () => void; - onCancel: () => void; - abridged?: boolean; -} - -const ReviewSystemStep = ({ system, onSuccess, onCancel, abridged }: Props) => { - const handleSubmit = () => { - onSuccess(); - }; - - return ( - -
- - - {/* TODO FUTURE: Path when describing system from infra scanning */} - Review declaration for manual system - - - Let’s quickly review our declaration before registering - - - - {system.name} - - - {system.fides_key} - - - {system.description} - - - {system.system_type} - - - {system.tags?.map((tag) => ( - - ))} - - - {system.system_dependencies?.map((dep) => ( - - ))} - - {!abridged ? : null} - - Privacy declarations: - {system.privacy_declarations.map((declaration) => ( - - - - - ))} - - - - {/* TODO FUTURE: This button doesn't do any registering yet until data maps are added */} - - - -
-
- ); -}; -export default ReviewSystemStep; diff --git a/clients/admin-ui/src/features/system/SystemFormTabs.tsx b/clients/admin-ui/src/features/system/SystemFormTabs.tsx new file mode 100644 index 0000000000..f1fa91718d --- /dev/null +++ b/clients/admin-ui/src/features/system/SystemFormTabs.tsx @@ -0,0 +1,208 @@ +import { Box, Button, Text, useToast } from "@fidesui/react"; +import NextLink from "next/link"; +import { useRouter } from "next/router"; +import React, { useCallback, useEffect, useState } from "react"; + +import { useAppDispatch, useAppSelector } from "~/app/hooks"; +import DataTabs, { type TabData } from "~/features/common/DataTabs"; +import { useInterzoneNav } from "~/features/common/hooks/useInterzoneNav"; +import { DEFAULT_TOAST_PARAMS } from "~/features/common/toast"; +import { System } from "~/types/api"; + +import PrivacyDeclarationStep from "./PrivacyDeclarationStep"; +import { selectActiveSystem, setActiveSystem } from "./system.slice"; +import SystemInformationForm from "./SystemInformationForm"; +import UnmountWarning from "./UnmountWarning"; + +// The toast doesn't seem to handle next links well, so use buttons with onClick +// handlers instead +const ToastMessage = ({ + onViewDatamap, + onAddPrivacyDeclaration, +}: { + onViewDatamap: () => void; + onAddPrivacyDeclaration: () => void; +}) => { + const linkButtonProps = { + variant: "link", + textDecor: "underline", + textColor: "gray.700", + fontWeight: "medium", + // allow lines to wrap + display: "initial", + cursor: "pointer", + }; + return ( + + System has been saved successfully + + Your system has been added to your data map. You can{" "} + {" "} + and come back to finish this setup when you’re ready. Or you can + progress to{" "} + + . + + + ); +}; + +const SystemFormTabs = ({ + isCreate, +}: { + /** If true, then some editing features will not be enabled */ + isCreate?: boolean; +}) => { + const [tabIndex, setTabIndex] = useState(0); + const [queuedIndex, setQueuedIndex] = useState(undefined); + const [showSaveMessage, setShowSaveMessage] = useState(false); + const { systemOrDatamapRoute } = useInterzoneNav(); + const router = useRouter(); + const toast = useToast(); + const dispatch = useAppDispatch(); + const activeSystem = useAppSelector(selectActiveSystem); + + const goBack = useCallback(() => { + router.back(); + dispatch(setActiveSystem(undefined)); + }, [dispatch, router]); + + const handleSuccess = (system: System) => { + // show a save message if this is the first time the system was saved + if (activeSystem === undefined) { + setShowSaveMessage(true); + } + dispatch(setActiveSystem(system)); + const toastParams = { + ...DEFAULT_TOAST_PARAMS, + description: ( + { + router.push(systemOrDatamapRoute).then(() => { + toast.closeAll(); + }); + }} + onAddPrivacyDeclaration={() => { + setTabIndex(1); + toast.closeAll(); + }} + /> + ), + }; + toast({ ...toastParams }); + }; + + useEffect(() => { + /** + * The first time this component mounts, if it's a create, make sure we don't have an active system + * This can happen if the user was editing a system, then navigated away by typing in a new URL path + * When navigating not through a URL path, the return unmount should handle resetting the system + */ + if (isCreate) { + dispatch(setActiveSystem(undefined)); + } + return () => { + // on unmount, unset the active system + dispatch(setActiveSystem(undefined)); + }; + }, [dispatch, isCreate]); + + const checkTabChange = (index: number) => { + // While privacy declarations aren't updated yet, only apply the "unsaved changes" modal logic + // to the system information tab + if (index === 0) { + setTabIndex(index); + } else { + setQueuedIndex(index); + } + }; + + const continueTabChange = () => { + if (queuedIndex) { + setTabIndex(queuedIndex); + setQueuedIndex(undefined); + } + }; + + const tabData: TabData[] = [ + { + label: "System information", + content: ( + <> + + + setQueuedIndex(undefined)} + /> + + + {showSaveMessage ? ( + + + Now that you have saved this new system it is{" "} + + + ready to view in your data map + + + . You can return to this setup at any time to add privacy + declarations to this system. + + + ) : null} + + ), + }, + { + label: "Privacy declarations", + content: activeSystem ? ( + + + + ) : null, + isDisabled: !activeSystem, + }, + ]; + + return ( + + ); +}; + +export default SystemFormTabs; diff --git a/clients/admin-ui/src/features/system/DescribeSystemStep.tsx b/clients/admin-ui/src/features/system/SystemInformationForm.tsx similarity index 50% rename from clients/admin-ui/src/features/system/DescribeSystemStep.tsx rename to clients/admin-ui/src/features/system/SystemInformationForm.tsx index 01c836637b..bc3064363c 100644 --- a/clients/admin-ui/src/features/system/DescribeSystemStep.tsx +++ b/clients/admin-ui/src/features/system/SystemInformationForm.tsx @@ -1,18 +1,22 @@ -import { Box, Button, Heading, Stack, useToast } from "@fidesui/react"; +import { + Box, + Button, + Divider, + Heading, + Stack, + Text, + useToast, +} from "@fidesui/react"; import { SerializedError } from "@reduxjs/toolkit"; import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query/fetchBaseQuery"; -import { Form, Formik } from "formik"; +import { Form, Formik, FormikHelpers } from "formik"; import { useMemo, useState } from "react"; import * as Yup from "yup"; import { useAppSelector } from "~/app/hooks"; -import { - CustomCreatableMultiSelect, - CustomSelect, - CustomTextInput, -} from "~/features/common/form/inputs"; +import { CustomFieldsList } from "~/features/common/custom-fields"; +import { CustomTextInput } from "~/features/common/form/inputs"; import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; -import DescribeSystemsFormExtension from "~/features/system/DescribeSystemsFormExtension"; import { defaultInitialValues, FormValues, @@ -24,13 +28,11 @@ import { useCreateSystemMutation, useUpdateSystemMutation, } from "~/features/system/system.slice"; +import SystemInformationFormExtension from "~/features/system/SystemInformationFormExtension"; import { ResourceTypes, System } from "~/types/api"; -import { CustomFieldsList } from "../common/custom-fields"; - const ValidationSchema = Yup.object().shape({ fides_key: Yup.string().required().label("System key"), - system_type: Yup.string().required().label("System type"), }); const SystemHeading = ({ system }: { system?: System }) => { @@ -48,16 +50,18 @@ const SystemHeading = ({ system }: { system?: System }) => { interface Props { onSuccess: (system: System) => void; - onCancel: () => void; abridged?: boolean; system?: System; + withHeader?: boolean; + children?: React.ReactNode; } -const DescribeSystemStep = ({ +const SystemInformationForm = ({ onSuccess, - onCancel, abridged, system: passedInSystem, + withHeader, + children, }: Props) => { const initialValues = useMemo( () => @@ -70,9 +74,6 @@ const DescribeSystemStep = ({ const [updateSystem] = useUpdateSystemMutation(); const [isLoading, setIsLoading] = useState(false); const systems = useAppSelector(selectAllSystems); - const systemOptions = systems - ? systems.map((s) => ({ label: s.name ?? s.fides_key, value: s.fides_key })) - : []; const isEditing = useMemo( () => Boolean( @@ -84,7 +85,10 @@ const DescribeSystemStep = ({ const toast = useToast(); - const handleSubmit = async (values: FormValues) => { + const handleSubmit = async ( + values: FormValues, + formikHelpers: FormikHelpers + ) => { const systemBody = transformFormValuesToSystem(values); const handleResult = ( @@ -102,6 +106,8 @@ const DescribeSystemStep = ({ }); } else { toast.closeAll(); + // Reset state such that isDirty will be checked again before next save + formikHelpers.resetForm({ values }); onSuccess(systemBody); } }; @@ -126,65 +132,58 @@ const DescribeSystemStep = ({ onSubmit={handleSubmit} validationSchema={ValidationSchema} > - {({ dirty, values }) => ( + {({ dirty, values, isValid }) => (
- - -
+ + {withHeader ? : null} + + By providing a small amount of additional context for each system we can make reporting and understanding our tech stack much easier for everyone from engineering to legal teams. So let’s do this now. -
+ + + System details + - - - - - ({ - value: s, - label: s, - })) - : [] - } - tooltip="Provide one or more tags to group the system. Tags are important as they allow you to filter and group systems for reporting and later review. Tags provide tremendous value as you scale - imagine you have thousands of systems, you’re going to thank us later for tagging!" - /> - + {/* While we support both designs of extra form items existing, change the width only + when there are extra form items. When we move to only supporting one design, + the parent container should control the width */} + + + + + {!abridged ? ( - + <> + + + + + ) : null} {isEditing && ( - + {children} )} ); }; -export default DescribeSystemStep; +export default SystemInformationForm; diff --git a/clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx b/clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx new file mode 100644 index 0000000000..111ce4afe5 --- /dev/null +++ b/clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx @@ -0,0 +1,115 @@ +import { Box, FormLabel, Stack } from "@fidesui/react"; +import { useFormikContext } from "formik"; + +import { useAppSelector } from "~/app/hooks"; +import { YesNoOptions } from "~/features/common/constants"; +import { COUNTRY_OPTIONS } from "~/features/common/countries"; +import { + CustomCreatableMultiSelect, + CustomRadioGroup, + CustomSelect, + CustomTextInput, +} from "~/features/common/form/inputs"; +import { enumToOptions } from "~/features/common/helpers"; +import QuestionTooltip from "~/features/common/QuestionTooltip"; +import { DataResponsibilityTitle } from "~/types/api"; + +import type { FormValues } from "./form"; +import { selectAllSystems } from "./system.slice"; + +const dataResponsibilityOptions = enumToOptions(DataResponsibilityTitle); + +const DescribeSystemsFormExtension = ({ values }: { values: FormValues }) => { + const { initialValues } = useFormikContext(); + const systems = useAppSelector(selectAllSystems); + const systemOptions = systems + ? systems.map((s) => ({ label: s.name ?? s.fides_key, value: s.fides_key })) + : []; + + return ( + <> + ({ + value: s, + label: s, + })) + : [] + } + tooltip="Provide one or more tags to group the system. Tags are important as they allow you to filter and group systems for reporting and later review. Tags provide tremendous value as you scale - imagine you have thousands of systems, you’re going to thank us later for tagging!" + /> + + + + + + + Joint controller + + + + + + + + + + + + Data protection impact assessment + + + + + {values.data_protection_impact_assessment?.is_required === "true" ? ( + <> + + + + ) : null} + + + + ); +}; + +export default DescribeSystemsFormExtension; diff --git a/clients/admin-ui/src/features/system/UnmountWarning.tsx b/clients/admin-ui/src/features/system/UnmountWarning.tsx new file mode 100644 index 0000000000..10920017f6 --- /dev/null +++ b/clients/admin-ui/src/features/system/UnmountWarning.tsx @@ -0,0 +1,54 @@ +import { Text, useDisclosure } from "@fidesui/react"; +import { useFormikContext } from "formik"; +import { useEffect } from "react"; + +import ConfirmationModal from "~/features/common/ConfirmationModal"; + +/** + * Renders a confirmation modal if it detects the parent form has a dirty state + * and it is unmounting + */ +const UnmountWarning = ({ + isUnmounting, + onContinue, + onCancel, +}: { + isUnmounting: boolean; + onContinue: () => void; + onCancel: () => void; +}) => { + const { dirty } = useFormikContext(); + const { isOpen, onClose, onOpen } = useDisclosure(); + + useEffect(() => { + if (isUnmounting && dirty) { + onOpen(); + } else { + onContinue(); + } + }, [isUnmounting, dirty, onOpen, onContinue]); + + const handleCancel = () => { + onCancel(); + onClose(); + }; + + return ( + + You have unsaved changes, are you sure you want to discard? + + } + /> + ); +}; + +export default UnmountWarning; diff --git a/clients/admin-ui/src/pages/add-systems/new.tsx b/clients/admin-ui/src/pages/add-systems/new.tsx index a0e5fd02b7..eee9fb9b4b 100644 --- a/clients/admin-ui/src/pages/add-systems/new.tsx +++ b/clients/admin-ui/src/pages/add-systems/new.tsx @@ -7,8 +7,8 @@ import React, { useMemo } from "react"; import { useInterzoneNav } from "~/features/common/hooks/useInterzoneNav"; import Layout from "~/features/common/Layout"; import ConnectionTypeLogo from "~/features/datastore-connections/ConnectionTypeLogo"; -import DescribeSystem from "~/features/system/DescribeSystem"; import SystemCatalog from "~/features/system/SystemCatalog"; +import SystemFormTabs from "~/features/system/SystemFormTabs"; import { ConnectionSystemTypeMap } from "~/types/api"; const CHOOSE_SYSTEM_COPY = @@ -67,7 +67,7 @@ const NewSystem: NextPage = () => {
- + { ) : null} - + + + {currentStep === "choose-system" ? CHOOSE_SYSTEM_COPY : DESCRIBE_SYSTEM_COPY} + {currentStep === "choose-system" ? ( + + ) : ( + + )} - {currentStep === "choose-system" ? : } ); }; diff --git a/clients/admin-ui/src/pages/system/configure.tsx b/clients/admin-ui/src/pages/system/configure.tsx index 47595b8296..e3f35d5823 100644 --- a/clients/admin-ui/src/pages/system/configure.tsx +++ b/clients/admin-ui/src/pages/system/configure.tsx @@ -3,7 +3,7 @@ import type { NextPage } from "next"; import NextLink from "next/link"; import Layout from "~/features/common/Layout"; -import ManualSystemFlow from "~/features/system/ManualSystemFlow"; +import EditSystemFlow from "~/features/system/EditSystemFlow"; const ConfigureSystem: NextPage = () => ( @@ -20,7 +20,7 @@ const ConfigureSystem: NextPage = () => ( - + ); From 79ac1dd48b5a62e3ee3f8c1671b358c267971026 Mon Sep 17 00:00:00 2001 From: Allison King Date: Tue, 21 Feb 2023 15:56:28 -0500 Subject: [PATCH 14/16] Fix form extension --- .../src/features/system/SystemInformationFormExtension.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx b/clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx index 111ce4afe5..6d3381a3f0 100644 --- a/clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx +++ b/clients/admin-ui/src/features/system/SystemInformationFormExtension.tsx @@ -5,7 +5,7 @@ import { useAppSelector } from "~/app/hooks"; import { YesNoOptions } from "~/features/common/constants"; import { COUNTRY_OPTIONS } from "~/features/common/countries"; import { - CustomCreatableMultiSelect, + CustomCreatableSelect, CustomRadioGroup, CustomSelect, CustomTextInput, @@ -28,7 +28,7 @@ const DescribeSystemsFormExtension = ({ values }: { values: FormValues }) => { return ( <> - { : [] } tooltip="Provide one or more tags to group the system. Tags are important as they allow you to filter and group systems for reporting and later review. Tags provide tremendous value as you scale - imagine you have thousands of systems, you’re going to thank us later for tagging!" + isMulti /> Date: Wed, 22 Feb 2023 11:14:29 -0500 Subject: [PATCH 15/16] Updated privacy declarations (#2648) --- .../cypress/e2e/systems-classify.cy.ts | 18 +- clients/admin-ui/cypress/e2e/systems.cy.ts | 178 +++++++------ .../fixtures/{ => systems}/system.json | 0 .../fixtures/{ => systems}/systems.json | 0 .../systems/systems_with_data_uses.json | 127 +++++++++ clients/admin-ui/cypress/support/stubs.ts | 12 +- .../src/features/common/form/inputs.tsx | 28 +- .../src/features/data-use/data-use.slice.ts | 4 + .../src/features/system/EditSystemFlow.tsx | 12 +- .../system/PrivacyDeclarationAccordion.tsx | 131 ---------- .../system/PrivacyDeclarationForm.tsx | 171 ------------- .../PrivacyDeclarationFormExtension.tsx | 24 -- .../system/PrivacyDeclarationStep.tsx | 170 ------------ .../system/ReviewSystemFormExtension.tsx | 69 ----- .../src/features/system/SystemFormTabs.tsx | 19 +- .../src/features/system/form-layout.tsx | 34 --- .../PrivacyDeclarationAccordion.tsx | 106 ++++++++ .../PrivacyDeclarationForm.tsx | 241 ++++++++++++++++++ .../PrivacyDeclarationManager.tsx | 147 +++++++++++ .../PrivacyDeclarationStep.tsx | 98 +++++++ .../src/features/system/system.slice.ts | 25 -- 21 files changed, 870 insertions(+), 744 deletions(-) rename clients/admin-ui/cypress/fixtures/{ => systems}/system.json (100%) rename clients/admin-ui/cypress/fixtures/{ => systems}/systems.json (100%) create mode 100644 clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json delete mode 100644 clients/admin-ui/src/features/system/PrivacyDeclarationAccordion.tsx delete mode 100644 clients/admin-ui/src/features/system/PrivacyDeclarationForm.tsx delete mode 100644 clients/admin-ui/src/features/system/PrivacyDeclarationFormExtension.tsx delete mode 100644 clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx delete mode 100644 clients/admin-ui/src/features/system/ReviewSystemFormExtension.tsx delete mode 100644 clients/admin-ui/src/features/system/form-layout.tsx create mode 100644 clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx create mode 100644 clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx create mode 100644 clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx create mode 100644 clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx diff --git a/clients/admin-ui/cypress/e2e/systems-classify.cy.ts b/clients/admin-ui/cypress/e2e/systems-classify.cy.ts index cd5ac5cf2d..329328159a 100644 --- a/clients/admin-ui/cypress/e2e/systems-classify.cy.ts +++ b/clients/admin-ui/cypress/e2e/systems-classify.cy.ts @@ -3,9 +3,9 @@ import { stubPlus, stubTaxonomyEntities } from "cypress/support/stubs"; describe("Classify systems page", () => { beforeEach(() => { cy.login(); - cy.intercept("GET", "/api/v1/system", { fixture: "systems.json" }).as( - "getSystems" - ); + cy.intercept("GET", "/api/v1/system", { + fixture: "systems/systems.json", + }).as("getSystems"); }); it("Should reroute if not in plus", () => { @@ -21,9 +21,9 @@ describe("Classify systems page", () => { cy.intercept("GET", "/api/v1/plus/classify*", { fixture: "classify/list-systems.json", }).as("getClassifyList"); - cy.intercept("GET", "/api/v1/system", { fixture: "systems.json" }).as( - "getSystems" - ); + cy.intercept("GET", "/api/v1/system", { + fixture: "systems/systems.json", + }).as("getSystems"); }); it("Should be accessible to plus users", () => { @@ -109,9 +109,9 @@ describe("Classify systems page", () => { cy.intercept("PUT", "/api/v1/plus/classify/*", { body: undefined }).as( "putClassifyInstance" ); - cy.intercept("PUT", "/api/v1/system*", { fixture: "system.json" }).as( - "putSystem" - ); + cy.intercept("PUT", "/api/v1/system*", { + fixture: "systems/system.json", + }).as("putSystem"); cy.visit("/classify-systems"); // Open up an ingress diff --git a/clients/admin-ui/cypress/e2e/systems.cy.ts b/clients/admin-ui/cypress/e2e/systems.cy.ts index 9eb94478ad..303eb174ee 100644 --- a/clients/admin-ui/cypress/e2e/systems.cy.ts +++ b/clients/admin-ui/cypress/e2e/systems.cy.ts @@ -3,9 +3,9 @@ import { stubSystemCrud, stubTaxonomyEntities } from "cypress/support/stubs"; describe("System management page", () => { beforeEach(() => { cy.login(); - cy.intercept("GET", "/api/v1/system", { fixture: "systems.json" }).as( - "getSystems" - ); + cy.intercept("GET", "/api/v1/system", { + fixture: "systems/systems.json", + }).as("getSystems"); }); // TODO: Update Cypress test to reflect the nav bar 2.0 @@ -97,7 +97,7 @@ describe("System management page", () => { }); it("Can step through the flow", () => { - cy.fixture("system.json").then((system) => { + cy.fixture("systems/system.json").then((system) => { // Fill in the describe form based on fixture data cy.visit("/add-systems"); cy.getByTestId("manual-btn").click(); @@ -126,38 +126,35 @@ describe("System management page", () => { }); // Fill in the privacy declaration form - cy.getByTestId("tab-Privacy declarations").click(); - cy.wait("@getDataCategories"); - cy.wait("@getDataQualifiers"); - cy.wait("@getDataSubjects"); - cy.wait("@getDataUses"); - cy.getByTestId("privacy-declaration-form"); + cy.getByTestId("tab-Data uses").click(); + cy.getByTestId("add-btn").click(); + cy.wait(["@getDataCategories", "@getDataSubjects", "@getDataUses"]); + cy.getByTestId("new-declaration-form"); const declaration = system.privacy_declarations[0]; - cy.getByTestId("input-name").type(declaration.name); - declaration.data_categories.forEach((dc) => { - cy.getByTestId("input-data_categories").type(`${dc}{enter}`); - }); cy.getByTestId("input-data_use").click(); cy.getByTestId("input-data_use").within(() => { cy.contains(declaration.data_use).click(); }); - + declaration.data_categories.forEach((dc) => { + cy.getByTestId("input-data_categories").type(`${dc}{enter}`); + }); declaration.data_subjects.forEach((ds) => { cy.getByTestId("input-data_subjects").type(`${ds}{enter}`); }); - cy.getByTestId("input-data_qualifier").click(); - cy.getByTestId("input-data_qualifier").within(() => { - cy.contains(declaration.data_qualifier).click(); - }); - cy.getByTestId("add-btn").click(); + cy.getByTestId("save-btn").click(); cy.wait("@putSystem").then((interception) => { const { body } = interception.request; expect(body.privacy_declarations[0]).to.eql({ - ...declaration, - dataset_references: [], + name: "", + data_use: declaration.data_use, + data_categories: declaration.data_categories, + data_subjects: declaration.data_subjects, }); }); + cy.getByTestId("new-declaration-form").within(() => { + cy.getByTestId("header").contains("System"); + }); }); }); @@ -175,20 +172,16 @@ describe("System management page", () => { const description = "half formed thought"; cy.getByTestId("input-description").type(description); // then try navigating to the privacy declarations tab - cy.getByTestId("tab-Privacy declarations").click(); + cy.getByTestId("tab-Data uses").click(); cy.getByTestId("confirmation-modal"); // make sure canceling works cy.getByTestId("cancel-btn").click(); cy.getByTestId("input-description").should("have.value", description); // now actually discard - cy.getByTestId("tab-Privacy declarations").click(); + cy.getByTestId("tab-Data uses").click(); cy.getByTestId("continue-btn").click(); // should load the privacy declarations page - cy.wait("@getDataCategories"); - cy.wait("@getDataQualifiers"); - cy.wait("@getDataSubjects"); - cy.wait("@getDataUses"); - cy.getByTestId("privacy-declaration-form"); + cy.getByTestId("privacy-declaration-step"); // navigate back cy.getByTestId("tab-System information").click(); cy.getByTestId("input-description").should("have.value", ""); @@ -278,34 +271,36 @@ describe("System management page", () => { }); // add another privacy declaration - cy.getByTestId("tab-Privacy declarations").click(); - const secondName = "Second declaration"; - cy.getByTestId("privacy-declaration-form"); - cy.getByTestId("input-name").type(secondName); - cy.getByTestId("input-data_categories").type(`user.biometric{enter}`); - cy.getByTestId("input-data_use").type(`advertising{enter}`); - cy.getByTestId("input-data_subjects").type(`anonymous{enter}`); + cy.getByTestId("tab-Data uses").click(); + const secondDataUse = "advertising"; + cy.getByTestId("tab-Data uses").click(); cy.getByTestId("add-btn").click(); + cy.wait(["@getDataCategories", "@getDataSubjects", "@getDataUses"]); + cy.getByTestId("new-declaration-form").within(() => { + cy.getByTestId("input-data_use").type(`${secondDataUse}{enter}`); + cy.getByTestId("input-data_categories").type(`user.biometric{enter}`); + cy.getByTestId("input-data_subjects").type(`anonymous{enter}`); + cy.getByTestId("save-btn").click(); + }); + cy.wait("@putSystem").then((interception) => { + const { body } = interception.request; + expect(body.privacy_declarations.length).to.eql(2); + expect(body.privacy_declarations[1].data_use).to.eql(secondDataUse); + }); // edit the existing declaration - const newName = "Store a lot of system data"; - cy.getByTestId("declaration-Store system data.") - .click() - .within(() => { - cy.getByTestId("edit-declaration-btn").click(); - cy.getByTestId("input-name").clear().type(newName); - cy.getByTestId("confirm-edit-btn").click(); - }); - cy.getByTestId(`declaration-${newName}`); - cy.getByTestId(`declaration-${secondName}`); - - cy.getByTestId("save-btn").click(); + const newDataUse = "collect"; + cy.getByTestId("accordion-header-improve.system").click(); + cy.getByTestId("improve.system-form").within(() => { + cy.getByTestId("input-data_use").type(`${newDataUse}{enter}`); + cy.getByTestId("save-btn").click(); + }); cy.wait("@putSystem").then((interception) => { const { body } = interception.request; - expect(body.privacy_declarations.length).to.eql(2); - expect(body.privacy_declarations[0].name).to.eql(newName); - expect(body.privacy_declarations[1].name).to.eql(secondName); + expect(body.privacy_declarations.length).to.eql(1); + expect(body.privacy_declarations[0].data_use).to.eql(newDataUse); }); + cy.getByTestId("saved-indicator"); }); it("Can render and edit extended form fields", () => { @@ -391,40 +386,65 @@ describe("System management page", () => { system.data_protection_impact_assessment, }); }); + }); + }); - // Add privacy declaration form - cy.getByTestId("tab-Privacy declarations").click(); - cy.wait("@getDataCategories"); - cy.wait("@getDataQualifiers"); - cy.wait("@getDataSubjects"); - cy.wait("@getDataUses"); - cy.wait("@getDatasets"); - cy.getByTestId("privacy-declaration-form"); - const declaration = { - name: "my declaration", - data_categories: ["user.biometric", "user.contact"], - data_use: "advertising", - data_subjects: ["citizen_voter", "consultant"], - dataset_references: ["demo_users_dataset_2"], - }; - cy.getByTestId("input-name").type(declaration.name); - declaration.data_categories.forEach((dc) => { - cy.getByTestId("input-data_categories").type(`${dc}{enter}`); + describe("Data uses", () => { + beforeEach(() => { + stubSystemCrud(); + stubTaxonomyEntities(); + }); + + it("warns when a data use is being added that is already used", () => { + cy.visit("/system"); + cy.getByTestId("system-fidesctl_system").within(() => { + cy.getByTestId("more-btn").click(); + cy.getByTestId("edit-btn").click(); }); - cy.getByTestId("input-data_use").type(`${declaration.data_use}{enter}`); - declaration.data_subjects.forEach((ds) => { - cy.getByTestId("input-data_subjects").type(`${ds}{enter}`); + // "improve.system" is already being used + cy.getByTestId("tab-Data uses").click(); + cy.getByTestId("add-btn").click(); + cy.wait(["@getDataCategories", "@getDataSubjects", "@getDataUses"]); + cy.getByTestId("new-declaration-form").within(() => { + cy.getByTestId("input-data_use").type(`improve.system{enter}`); + cy.getByTestId("input-data_categories").type(`user.biometric{enter}`); + cy.getByTestId("input-data_subjects").type(`anonymous{enter}`); + cy.getByTestId("save-btn").click(); }); - cy.getByTestId("input-dataset_references").click(); - cy.getByTestId("input-dataset_references").within(() => { - cy.contains("Demo Users Dataset 2").click(); + cy.getByTestId("toast-error-msg"); + + // changing to a different data use should go through + cy.getByTestId("new-declaration-form").within(() => { + cy.getByTestId("input-data_use").type(`collect{enter}`); + cy.getByTestId("save-btn").click(); }); + cy.getByTestId("toast-success-msg"); + }); + + it("warns when a data use is being edited to one that is already used", () => { + cy.intercept("GET", "/api/v1/system", { + fixture: "systems/systems_with_data_uses.json", + }).as("getSystemsWithDataUses"); + cy.visit("/system"); + cy.wait("@getSystemsWithDataUses"); + cy.getByTestId("system-fidesctl_system").within(() => { + cy.getByTestId("more-btn").click(); + cy.getByTestId("edit-btn").click(); + }); + + cy.getByTestId("tab-Data uses").click(); cy.getByTestId("add-btn").click(); - cy.getByTestId("save-btn").click(); - cy.wait("@putSystem").then((interception) => { - const { body } = interception.request; - expect(body.privacy_declarations[1]).to.eql(declaration); + cy.wait(["@getDataCategories", "@getDataSubjects", "@getDataUses"]); + + cy.getByTestId(`accordion-header-improve.system`); + cy.getByTestId(`accordion-header-advertising`).click(); + + // try to change 'advertising' to 'improve.system' + cy.getByTestId("advertising-form").within(() => { + cy.getByTestId("input-data_use").type(`improve.system{enter}`); + cy.getByTestId("save-btn").click(); }); + cy.getByTestId("toast-error-msg"); }); }); }); diff --git a/clients/admin-ui/cypress/fixtures/system.json b/clients/admin-ui/cypress/fixtures/systems/system.json similarity index 100% rename from clients/admin-ui/cypress/fixtures/system.json rename to clients/admin-ui/cypress/fixtures/systems/system.json diff --git a/clients/admin-ui/cypress/fixtures/systems.json b/clients/admin-ui/cypress/fixtures/systems/systems.json similarity index 100% rename from clients/admin-ui/cypress/fixtures/systems.json rename to clients/admin-ui/cypress/fixtures/systems/systems.json diff --git a/clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json b/clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json new file mode 100644 index 0000000000..885e79a2d8 --- /dev/null +++ b/clients/admin-ui/cypress/fixtures/systems/systems_with_data_uses.json @@ -0,0 +1,127 @@ +[ + { + "fides_key": "fidesctl_system", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Fidesctl System", + "description": "Software that functionally applies Fides.", + "registry_id": null, + "meta": null, + "fidesctl_meta": null, + "system_type": "Service", + "data_responsibility_title": "Controller", + "privacy_declarations": [ + { + "name": "Store system data.", + "data_categories": ["system.operations", "user.contact"], + "data_use": "improve.system", + "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", + "data_subjects": ["anonymous_user"], + "dataset_references": ["public"] + }, + { + "name": "Collect data for marketing", + "data_categories": ["user.device.cookie_id"], + "data_use": "advertising", + "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", + "data_subjects": ["customer"], + "dataset_references": null, + "egress": null, + "ingress": null + } + ], + "system_dependencies": null, + "joint_controller": null, + "third_country_transfers": null, + "administrating_department": "Not defined", + "data_protection_impact_assessment": { + "is_required": false, + "progress": null, + "link": null + }, + "ingress": [], + "egress": [] + }, + { + "fides_key": "demo_analytics_system", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Demo Analytics System", + "description": "A system used for analyzing customer behaviour.", + "registry_id": null, + "meta": null, + "fidesctl_meta": null, + "system_type": "Service", + "data_responsibility_title": "Controller", + "egress": null, + "ingress": [ + { + "fides_key": "demo_marketing_system", + "type": "system", + "data_categories": null + } + ], + "privacy_declarations": [ + { + "name": "Analyze customer behaviour for improvements.", + "data_categories": ["user.contact", "user.device.cookie_id"], + "data_use": "improve.system", + "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", + "data_subjects": ["customer"], + "dataset_references": ["demo_users_dataset"], + "egress": null, + "ingress": null + } + ], + "system_dependencies": null, + "joint_controller": null, + "third_country_transfers": ["USA", "CAN"], + "administrating_department": "Engineering", + "data_protection_impact_assessment": { + "is_required": true, + "progress": "Complete", + "link": "https://example.org/analytics_system_data_protection_impact_assessment" + } + }, + { + "fides_key": "demo_marketing_system", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Demo Marketing System", + "description": "Collect data about our users for marketing.", + "registry_id": null, + "meta": null, + "fidesctl_meta": null, + "system_type": "Service", + "data_responsibility_title": "Processor", + "egress": [ + { + "fides_key": "demo_analytics_system", + "type": "system", + "data_categories": null + } + ], + "ingress": null, + "privacy_declarations": [ + { + "name": "Collect data for marketing", + "data_categories": ["user.device.cookie_id"], + "data_use": "advertising", + "data_qualifier": "aggregated.anonymized.unlinked_pseudonymized.pseudonymized.identified", + "data_subjects": ["customer"], + "dataset_references": null, + "egress": null, + "ingress": null + } + ], + "system_dependencies": ["demo_analytics_system"], + "joint_controller": null, + "third_country_transfers": null, + "administrating_department": "Marketing", + "data_protection_impact_assessment": { + "is_required": false, + "progress": null, + "link": null + } + } +] diff --git a/clients/admin-ui/cypress/support/stubs.ts b/clients/admin-ui/cypress/support/stubs.ts index eb99aa9206..4dc4164831 100644 --- a/clients/admin-ui/cypress/support/stubs.ts +++ b/clients/admin-ui/cypress/support/stubs.ts @@ -17,16 +17,16 @@ export const stubTaxonomyEntities = () => { }; export const stubSystemCrud = () => { - cy.intercept("POST", "/api/v1/system", { fixture: "system.json" }).as( + cy.intercept("POST", "/api/v1/system", { fixture: "systems/system.json" }).as( "postSystem" ); - cy.intercept("GET", "/api/v1/system/*", { fixture: "system.json" }).as( - "getSystem" - ); - cy.intercept("PUT", "/api/v1/system*", { fixture: "system.json" }).as( + cy.intercept("GET", "/api/v1/system/*", { + fixture: "systems/system.json", + }).as("getSystem"); + cy.intercept("PUT", "/api/v1/system*", { fixture: "systems/system.json" }).as( "putSystem" ); - cy.fixture("system.json").then((system) => { + cy.fixture("systems/system.json").then((system) => { cy.intercept("DELETE", "/api/v1/system/*", { body: { message: "resource deleted", diff --git a/clients/admin-ui/src/features/common/form/inputs.tsx b/clients/admin-ui/src/features/common/form/inputs.tsx index c9c065872c..a6186266e6 100644 --- a/clients/admin-ui/src/features/common/form/inputs.tsx +++ b/clients/admin-ui/src/features/common/form/inputs.tsx @@ -137,6 +137,11 @@ interface SelectProps { isMulti?: boolean; variant?: Variant; menuPosition?: MenuPosition; + /** + * If true, when isMulti=false, the selected value will be rendered as a block, + * similar to how the multi values are rendered + */ + singleValueBlock?: boolean; } const SelectInput = ({ options, @@ -145,6 +150,7 @@ const SelectInput = ({ isSearchable, isClearable, isMulti = false, + singleValueBlock, isDisabled = false, menuPosition = "absolute", }: { fieldName: string; isMulti?: boolean } & Omit) => { @@ -193,7 +199,12 @@ const SelectInput = ({ size={size} classNamePrefix="custom-select" chakraStyles={{ - container: (provided) => ({ ...provided, mr: 2, flexGrow: 1 }), + container: (provided) => ({ + ...provided, + mr: 2, + flexGrow: 1, + backgroundColor: "white", + }), dropdownIndicator: (provided) => ({ ...provided, bg: "transparent", @@ -209,6 +220,16 @@ const SelectInput = ({ background: "primary.400", color: "white", }), + singleValue: singleValueBlock + ? (provided) => ({ + ...provided, + background: "primary.400", + color: "white", + borderRadius: ".375rem", + fontSize: ".75rem", + paddingX: ".5rem", + }) + : undefined, }} components={components} isSearchable={isSearchable} @@ -381,6 +402,7 @@ export const CustomSelect = ({ size = "sm", isMulti, variant = "inline", + singleValueBlock, ...props }: SelectProps & StringField) => { const [field, meta] = useField(props); @@ -404,6 +426,7 @@ export const CustomSelect = ({ isSearchable={isSearchable === undefined ? isMulti : isSearchable} isClearable={isClearable} isMulti={isMulti} + singleValueBlock={singleValueBlock} isDisabled={isDisabled} menuPosition={props.menuPosition} /> @@ -433,7 +456,7 @@ export const CustomSelect = ({ {tooltip ? : null} - + diff --git a/clients/admin-ui/src/features/data-use/data-use.slice.ts b/clients/admin-ui/src/features/data-use/data-use.slice.ts index 3d3437f82b..d5e202596b 100644 --- a/clients/admin-ui/src/features/data-use/data-use.slice.ts +++ b/clients/admin-ui/src/features/data-use/data-use.slice.ts @@ -24,6 +24,9 @@ export const dataUseApi = createApi({ transformResponse: (uses: DataUse[]) => uses.sort((a, b) => a.fides_key.localeCompare(b.fides_key)), }), + getDataUseByKey: build.query({ + query: (fidesKey) => ({ url: `data_use/${fidesKey}` }), + }), updateDataUse: build.mutation< DataUse, Partial & Pick @@ -57,6 +60,7 @@ export const dataUseApi = createApi({ export const { useGetAllDataUsesQuery, + useGetDataUseByKeyQuery, useUpdateDataUseMutation, useCreateDataUseMutation, useDeleteDataUseMutation, diff --git a/clients/admin-ui/src/features/system/EditSystemFlow.tsx b/clients/admin-ui/src/features/system/EditSystemFlow.tsx index eb1973d2d2..ff218f570b 100644 --- a/clients/admin-ui/src/features/system/EditSystemFlow.tsx +++ b/clients/admin-ui/src/features/system/EditSystemFlow.tsx @@ -4,9 +4,9 @@ import { useRouter } from "next/router"; import { useCallback, useState } from "react"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; +import PrivacyDeclarationStep from "~/features/system/privacy-declarations/PrivacyDeclarationStep"; import { System } from "~/types/api"; -import PrivacyDeclarationStep from "./PrivacyDeclarationStep"; import { selectActiveSystem, setActiveSystem } from "./system.slice"; import SystemFormTabs from "./SystemFormTabs"; import SystemInformationForm from "./SystemInformationForm"; @@ -65,10 +65,6 @@ const EditSystemFlow = () => { [currentStepIndex, dispatch] ); - const decrementStep = () => { - setCurrentStepIndex(currentStepIndex - 1); - }; - return ( <> {navV2 && ( @@ -97,11 +93,7 @@ const EditSystemFlow = () => { /> ) : null} {currentStepIndex === 1 && activeSystem ? ( - + ) : null} {currentStepIndex === 2 && activeSystem ? ( ( - - - {declaration.data_categories.map((category) => ( - - ))} - - - - - - {declaration.data_subjects.map((subject) => ( - - ))} - - - {declaration.data_qualifier ? ( - - ) : ( - "None" - )} - - {!abridged ? ( - - {declaration.dataset_references ? ( - {declaration.dataset_references.join(", ")} - ) : ( - "None" - )} - - ) : null} - -); - -interface Props { - privacyDeclaration: PrivacyDeclaration; - onEdit?: (declaration: PrivacyDeclaration) => void; - abridged?: boolean; -} -const PrivacyDeclarationAccordion = ({ - privacyDeclaration, - onEdit, - abridged, -}: Props) => { - const [isEditing, setIsEditing] = useState(false); - const handleEdit = (newValues: PrivacyDeclaration) => { - if (onEdit) { - onEdit(newValues); - setIsEditing(false); - } - }; - const showEditButton = onEdit && !isEditing; - - return ( - - - <> - - - {privacyDeclaration.name} - - - - - - {isEditing ? ( - setIsEditing(false)} - initialValues={privacyDeclaration} - abridged={abridged} - /> - ) : ( - - )} - - {showEditButton ? ( - - ) : null} - - - - - ); -}; - -export default PrivacyDeclarationAccordion; diff --git a/clients/admin-ui/src/features/system/PrivacyDeclarationForm.tsx b/clients/admin-ui/src/features/system/PrivacyDeclarationForm.tsx deleted file mode 100644 index 939d9132de..0000000000 --- a/clients/admin-ui/src/features/system/PrivacyDeclarationForm.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { AddIcon, Box, Button, ButtonGroup, Stack } from "@fidesui/react"; -import { Form, Formik, FormikHelpers } from "formik"; -import * as Yup from "yup"; - -import { useAppSelector } from "~/app/hooks"; -import { CustomSelect, CustomTextInput } from "~/features/common/form/inputs"; -import { - selectDataQualifiers, - useGetAllDataQualifiersQuery, -} from "~/features/data-qualifier/data-qualifier.slice"; -import { - selectDataSubjects, - useGetAllDataSubjectsQuery, -} from "~/features/data-subjects/data-subject.slice"; -import { - selectDataUses, - useGetAllDataUsesQuery, -} from "~/features/data-use/data-use.slice"; -import { - selectDataCategories, - useGetAllDataCategoriesQuery, -} from "~/features/taxonomy/taxonomy.slice"; -import { PrivacyDeclaration } from "~/types/api"; - -import PrivacyDeclarationFormExtension from "./PrivacyDeclarationFormExtension"; - -const ValidationSchema = Yup.object().shape({ - name: Yup.string().required().label("Declaration name"), - data_categories: Yup.array(Yup.string()) - .min(1, "Must assign at least one data category") - .label("Data categories"), - data_use: Yup.string().required().label("Data use"), - data_subjects: Yup.array(Yup.string()) - .min(1, "Must assign at least one data subject") - .label("Data subjects"), -}); - -const defaultInitialValues: PrivacyDeclaration = { - name: "", - data_categories: [], - data_subjects: [], - data_use: "", - data_qualifier: "", - dataset_references: [], -}; - -interface Props { - onSubmit: ( - values: PrivacyDeclaration, - formikHelpers: FormikHelpers - ) => void; - onCancel?: () => void; - abridged?: boolean; - initialValues?: PrivacyDeclaration; -} -const PrivacyDeclarationForm = ({ - onSubmit, - onCancel, - abridged, - initialValues: passedInInitialValues, -}: Props) => { - const isEditing = !!passedInInitialValues; - const initialValues = passedInInitialValues ?? defaultInitialValues; - - // Query subscriptions: - useGetAllDataCategoriesQuery(); - useGetAllDataSubjectsQuery(); - useGetAllDataQualifiersQuery(); - useGetAllDataUsesQuery(); - - const allDataCategories = useAppSelector(selectDataCategories); - const allDataSubjects = useAppSelector(selectDataSubjects); - const allDataUses = useAppSelector(selectDataUses); - const allDataQualifiers = useAppSelector(selectDataQualifiers); - - return ( - - {({ dirty }) => ( -
- - - ({ - value: data.fides_key, - label: data.fides_key, - }))} - tooltip="What type of data is your system processing? This could be various types of user or system data." - isMulti - /> - ({ - value: data.fides_key, - label: data.fides_key, - }))} - tooltip="What is the system using the data for. For example, is it for third party advertising or perhaps simply providing system operations." - /> - ({ - value: data.fides_key, - label: data.fides_key, - }))} - tooltip="Whose data are you processing? This could be customers, employees or any other type of user in your system." - isMulti - /> - ({ - value: data.fides_key, - label: data.fides_key, - }))} - tooltip="How identifiable is the user in the data in this system? For instance, is it anonymized data where the user is truly unknown/unidentifiable, or it is partially identifiable data?" - /> - {!abridged ? : null} - - - {isEditing ? ( - - {onCancel && ( - - )} - - - ) : ( - - )} - -
- )} -
- ); -}; - -export default PrivacyDeclarationForm; diff --git a/clients/admin-ui/src/features/system/PrivacyDeclarationFormExtension.tsx b/clients/admin-ui/src/features/system/PrivacyDeclarationFormExtension.tsx deleted file mode 100644 index 5142a8e44c..0000000000 --- a/clients/admin-ui/src/features/system/PrivacyDeclarationFormExtension.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { CustomSelect } from "../common/form/inputs"; -import { useGetAllDatasetsQuery } from "../dataset"; - -const PrivacyDeclarationFormExtension = () => { - const { data: datasets } = useGetAllDatasetsQuery(); - const datasetOptions = datasets - ? datasets.map((d) => ({ - label: d.name ?? d.fides_key, - value: d.fides_key, - })) - : []; - - return ( - - ); -}; - -export default PrivacyDeclarationFormExtension; diff --git a/clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx b/clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx deleted file mode 100644 index 01ab319626..0000000000 --- a/clients/admin-ui/src/features/system/PrivacyDeclarationStep.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { Box, Button, Divider, Heading, Stack, useToast } from "@fidesui/react"; -import { SerializedError } from "@reduxjs/toolkit"; -import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query/fetchBaseQuery"; -import { FormikHelpers } from "formik"; -import { Fragment, useState } from "react"; - -import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; -import { PrivacyDeclaration, System } from "~/types/api"; - -import PrivacyDeclarationAccordion from "./PrivacyDeclarationAccordion"; -import PrivacyDeclarationForm from "./PrivacyDeclarationForm"; -import { useUpdateSystemMutation } from "./system.slice"; - -type FormValues = PrivacyDeclaration; - -const transformFormValuesToDeclaration = ( - formValues: FormValues -): PrivacyDeclaration => ({ - ...formValues, - data_qualifier: - formValues.data_qualifier === "" ? undefined : formValues.data_qualifier, -}); - -interface Props { - system: System; - onSuccess: (system: System) => void; - onCancel: () => void; - abridged?: boolean; -} - -const PrivacyDeclarationStep = ({ - system, - onSuccess, - onCancel, - abridged, -}: Props) => { - const toast = useToast(); - const [formDeclarations, setFormDeclarations] = useState< - PrivacyDeclaration[] - >(system?.privacy_declarations ? [...system.privacy_declarations] : []); - const [updateSystem] = useUpdateSystemMutation(); - const [isLoading, setIsLoading] = useState(false); - - const handleSubmit = async () => { - const systemBodyWithDeclaration = { - ...system, - privacy_declarations: formDeclarations, - }; - - const handleResult = ( - result: - | { data: System } - | { error: FetchBaseQueryError | SerializedError } - ) => { - if (isErrorResult(result)) { - const errorMsg = getErrorMessage( - result.error, - "An unexpected error occurred while updating the system. Please try again." - ); - - toast({ - status: "error", - description: errorMsg, - }); - } else { - toast.closeAll(); - onSuccess(result.data); - } - }; - - setIsLoading(true); - - const updateSystemResult = await updateSystem(systemBodyWithDeclaration); - - handleResult(updateSystemResult); - setIsLoading(false); - }; - - const handleEditDeclaration = ( - oldDeclaration: PrivacyDeclaration, - newDeclaration: PrivacyDeclaration - ) => { - // Because the name can change, we also need a reference to the old declaration in order to - // make sure we are replacing the proper one - setFormDeclarations( - formDeclarations.map((dec) => - dec.name === oldDeclaration.name ? newDeclaration : dec - ) - ); - }; - - const addDeclaration = ( - values: PrivacyDeclaration, - formikHelpers: FormikHelpers - ) => { - const { resetForm } = formikHelpers; - if (formDeclarations.filter((d) => d.name === values.name).length > 0) { - toast({ - status: "error", - description: - "A declaration already exists with that name in this system. Please use a different name.", - }); - } else { - toast.closeAll(); - setFormDeclarations([ - ...formDeclarations, - transformFormValuesToDeclaration(values), - ]); - resetForm({ - values: { - name: "", - data_subjects: [], - data_categories: [], - data_use: "", - data_qualifier: "", - dataset_references: [], - }, - }); - } - }; - - return ( - - - {/* TODO FUTURE: Path when describing system from infra scanning */} - Privacy Declaration for {system.name} - -
- Now we’re going to declare our system’s privacy characteristics. Think - of this as explaining who’s data the system is processing, what kind of - data it’s processing and for what purpose it’s using that data and - finally, how identifiable is the user with this data. -
- {formDeclarations.map((declaration) => ( - - { - handleEditDeclaration(declaration, newValues); - }} - /> - - - ))} - - - - - -
- ); -}; - -export default PrivacyDeclarationStep; diff --git a/clients/admin-ui/src/features/system/ReviewSystemFormExtension.tsx b/clients/admin-ui/src/features/system/ReviewSystemFormExtension.tsx deleted file mode 100644 index 6e4ead4774..0000000000 --- a/clients/admin-ui/src/features/system/ReviewSystemFormExtension.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Box, FormLabel, Text } from "@fidesui/react"; - -import TaxonomyEntityTag from "~/features/taxonomy/TaxonomyEntityTag"; -import { System } from "~/types/api"; - -import { ReviewItem } from "./form-layout"; - -const ReviewSystemFormExtension = ({ system }: { system: System }) => ( - <> - - {system.data_responsibility_title} - - - {system.administrating_department} - - - {system.third_country_transfers?.map((country) => ( - - ))} - - {system.joint_controller ? ( - - Joint controller - - - {system.joint_controller.name} - - - {system.joint_controller.address} - - - {system.joint_controller.email} - - - {system.joint_controller.phone} - - - - ) : ( - None - )} - {system.data_protection_impact_assessment ? ( - - - Data protection impact assessment - - - - - {system.data_protection_impact_assessment.is_required - ? "Yes" - : "No"} - - - - {system.data_protection_impact_assessment.progress} - - - {system.data_protection_impact_assessment.link} - - - - ) : ( - None - )} - -); - -export default ReviewSystemFormExtension; diff --git a/clients/admin-ui/src/features/system/SystemFormTabs.tsx b/clients/admin-ui/src/features/system/SystemFormTabs.tsx index f1fa91718d..80500a8237 100644 --- a/clients/admin-ui/src/features/system/SystemFormTabs.tsx +++ b/clients/admin-ui/src/features/system/SystemFormTabs.tsx @@ -1,15 +1,15 @@ import { Box, Button, Text, useToast } from "@fidesui/react"; import NextLink from "next/link"; import { useRouter } from "next/router"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; import DataTabs, { type TabData } from "~/features/common/DataTabs"; import { useInterzoneNav } from "~/features/common/hooks/useInterzoneNav"; import { DEFAULT_TOAST_PARAMS } from "~/features/common/toast"; +import PrivacyDeclarationStep from "~/features/system/privacy-declarations/PrivacyDeclarationStep"; import { System } from "~/types/api"; -import PrivacyDeclarationStep from "./PrivacyDeclarationStep"; import { selectActiveSystem, setActiveSystem } from "./system.slice"; import SystemInformationForm from "./SystemInformationForm"; import UnmountWarning from "./UnmountWarning"; @@ -77,11 +77,6 @@ const SystemFormTabs = ({ const dispatch = useAppDispatch(); const activeSystem = useAppSelector(selectActiveSystem); - const goBack = useCallback(() => { - router.back(); - dispatch(setActiveSystem(undefined)); - }, [dispatch, router]); - const handleSuccess = (system: System) => { // show a save message if this is the first time the system was saved if (activeSystem === undefined) { @@ -179,14 +174,10 @@ const SystemFormTabs = ({ ), }, { - label: "Privacy declarations", + label: "Data uses", content: activeSystem ? ( - - + + ) : null, isDisabled: !activeSystem, diff --git a/clients/admin-ui/src/features/system/form-layout.tsx b/clients/admin-ui/src/features/system/form-layout.tsx deleted file mode 100644 index 4de4273087..0000000000 --- a/clients/admin-ui/src/features/system/form-layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { FormLabel, Grid, GridItem, Text } from "@fidesui/react"; -import React, { ReactNode } from "react"; - -export const ReviewItem = ({ - label, - children, -}: { - label: string; - children: ReactNode; -}) => ( - - - - {label}: - - - {children} - -); - -export const DeclarationItem = ({ - label, - children, -}: { - label: string; - children: ReactNode; -}) => ( - - - {label} - - {children} - -); diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx new file mode 100644 index 0000000000..0be8d5b015 --- /dev/null +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx @@ -0,0 +1,106 @@ +import { + Accordion, + AccordionButton, + AccordionIcon, + AccordionItem, + AccordionPanel, + Stack, +} from "@fidesui/react"; +import { Form, Formik } from "formik"; + +import { PrivacyDeclaration } from "~/types/api"; + +import { + DataProps, + PrivacyDeclarationFormComponents, + usePrivacyDeclarationForm, + ValidationSchema, +} from "./PrivacyDeclarationForm"; + +interface AccordionProps extends DataProps { + privacyDeclarations: PrivacyDeclaration[]; + onEdit: ( + oldDeclaration: PrivacyDeclaration, + newDeclaration: PrivacyDeclaration + ) => Promise; +} + +const PrivacyDeclarationAccordionItem = ({ + privacyDeclaration, + onEdit, + ...dataProps +}: { privacyDeclaration: PrivacyDeclaration } & Omit< + AccordionProps, + "privacyDeclarations" +>) => { + const handleEdit = (newValues: PrivacyDeclaration) => + onEdit(privacyDeclaration, newValues); + + const { initialValues, renderHeader, handleSubmit } = + usePrivacyDeclarationForm({ + initialValues: privacyDeclaration, + onSubmit: handleEdit, + ...dataProps, + }); + + return ( + + {({ isExpanded }) => ( + + {({ dirty }) => ( +
+ + {renderHeader({ + dirty, + boxProps: { flex: "1", textAlign: "left" }, + hideSaved: !isExpanded, + })} + + + + + + + +
+ )} +
+ )} +
+ ); +}; + +const PrivacyDeclarationAccordion = ({ + privacyDeclarations, + ...props +}: AccordionProps) => ( + + {privacyDeclarations.map((dec, i) => ( + + ))} + +); + +export default PrivacyDeclarationAccordion; diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx new file mode 100644 index 0000000000..67a954be9c --- /dev/null +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx @@ -0,0 +1,241 @@ +/** + * Exports various parts of the privacy declaration form for flexibility + */ + +import { + Box, + BoxProps, + Button, + ButtonGroup, + GreenCheckCircleIcon, + Heading, + Stack, + Text, +} from "@fidesui/react"; +import { Form, Formik, FormikHelpers, useFormikContext } from "formik"; +import { useMemo, useState } from "react"; +import * as Yup from "yup"; + +import { useAppSelector } from "~/app/hooks"; +import { CustomSelect } from "~/features/common/form/inputs"; +import { + selectDataSubjects, + useGetAllDataSubjectsQuery, +} from "~/features/data-subjects/data-subject.slice"; +import { + selectDataUses, + useGetAllDataUsesQuery, +} from "~/features/data-use/data-use.slice"; +import { + selectDataCategories, + useGetAllDataCategoriesQuery, +} from "~/features/taxonomy/taxonomy.slice"; +import { + DataCategory, + DataSubject, + DataUse, + PrivacyDeclaration, +} from "~/types/api"; + +export const ValidationSchema = Yup.object().shape({ + data_categories: Yup.array(Yup.string()) + .min(1, "Must assign at least one data category") + .label("Data categories"), + data_use: Yup.string().required().label("Data use"), + data_subjects: Yup.array(Yup.string()) + .min(1, "Must assign at least one data subject") + .label("Data subjects"), +}); + +const defaultInitialValues: PrivacyDeclaration = { + data_categories: [], + data_subjects: [], + data_use: "", +}; + +type FormValues = typeof defaultInitialValues; + +export interface DataProps { + allDataCategories: DataCategory[]; + allDataUses: DataUse[]; + allDataSubjects: DataSubject[]; +} + +export const PrivacyDeclarationFormComponents = ({ + allDataUses, + allDataCategories, + allDataSubjects, +}: DataProps) => { + const { dirty, isSubmitting, isValid } = useFormikContext(); + return ( + + ({ + value: data.fides_key, + label: data.fides_key, + }))} + tooltip="What is the system using the data for. For example, is it for third party advertising or perhaps simply providing system operations." + variant="stacked" + singleValueBlock + /> + ({ + value: data.fides_key, + label: data.fides_key, + }))} + tooltip="What type of data is your system processing? This could be various types of user or system data." + isMulti + variant="stacked" + /> + ({ + value: data.fides_key, + label: data.fides_key, + }))} + tooltip="Whose data are you processing? This could be customers, employees or any other type of user in your system." + isMulti + variant="stacked" + /> + + + + + + ); +}; + +/** + * Set up subscriptions to all taxonomy resources + */ +export const useTaxonomyData = () => { + // Query subscriptions: + const { isLoading: isLoadingDataCategories } = useGetAllDataCategoriesQuery(); + const { isLoading: isLoadingDataSubjects } = useGetAllDataSubjectsQuery(); + const { isLoading: isLoadingDataUses } = useGetAllDataUsesQuery(); + + const allDataCategories = useAppSelector(selectDataCategories); + const allDataSubjects = useAppSelector(selectDataSubjects); + const allDataUses = useAppSelector(selectDataUses); + + const isLoading = + isLoadingDataCategories || isLoadingDataSubjects || isLoadingDataUses; + + return { allDataCategories, allDataSubjects, allDataUses, isLoading }; +}; + +/** + * Hook to supply all data needed for the privacy declaration form + * Purposefully excludes redux queries so that this can be used across apps + */ +export const usePrivacyDeclarationForm = ({ + onSubmit, + initialValues: passedInInitialValues, + allDataUses, +}: Props & Pick) => { + const initialValues = passedInInitialValues ?? defaultInitialValues; + const [showSaved, setShowSaved] = useState(false); + + const title = useMemo(() => { + const thisDataUse = allDataUses.filter( + (du) => du.fides_key === initialValues.data_use + )[0]; + if (thisDataUse) { + return thisDataUse.name; + } + return undefined; + }, [allDataUses, initialValues]); + + const handleSubmit = async ( + values: FormValues, + formikHelpers: FormikHelpers + ) => { + const success = await onSubmit(values, formikHelpers); + if (success) { + // Reset state such that isDirty will be checked again before next save + formikHelpers.resetForm({ values }); + setShowSaved(true); + } + }; + + const renderHeader = ({ + dirty, + boxProps, + /** Allow overriding showing the saved indicator */ + hideSaved, + }: { + dirty: boolean; + hideSaved?: boolean; + boxProps?: BoxProps; + }) => ( + + {title ? ( + + {title} + + ) : null} + {!hideSaved && showSaved && !dirty && initialValues.data_use ? ( + + Saved + + ) : null} + + ); + + return { handleSubmit, renderHeader, initialValues }; +}; + +interface Props { + onSubmit: ( + values: PrivacyDeclaration, + formikHelpers: FormikHelpers + ) => Promise; + initialValues?: PrivacyDeclaration; +} + +export const PrivacyDeclarationForm = ({ + onSubmit, + initialValues: passedInInitialValues, + ...dataProps +}: Props & DataProps) => { + const { handleSubmit, renderHeader, initialValues } = + usePrivacyDeclarationForm({ + onSubmit, + initialValues: passedInInitialValues, + allDataUses: dataProps.allDataUses, + }); + + return ( + + {({ dirty }) => ( +
+ + {renderHeader({ dirty })} + + +
+ )} +
+ ); +}; diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx new file mode 100644 index 0000000000..f297179eb7 --- /dev/null +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationManager.tsx @@ -0,0 +1,147 @@ +import { Box, Button, Stack, Tooltip, useToast } from "@fidesui/react"; +import { useMemo, useState } from "react"; + +import { PrivacyDeclaration, System } from "~/types/api"; + +import PrivacyDeclarationAccordion from "./PrivacyDeclarationAccordion"; +import { DataProps, PrivacyDeclarationForm } from "./PrivacyDeclarationForm"; + +type FormValues = PrivacyDeclaration; + +const transformFormValuesToDeclaration = ( + formValues: FormValues +): PrivacyDeclaration => ({ + ...formValues, + // Fill in an empty string for name because of https://github.com/ethyca/fideslang/issues/98 + name: formValues.name ?? "", +}); + +interface Props { + system: System; + onCollision: () => void; + onSave: (privacyDeclarations: PrivacyDeclaration[]) => Promise; +} + +const PrivacyDeclarationManager = ({ + system, + onCollision, + onSave, + ...dataProps +}: Props & DataProps) => { + const toast = useToast(); + + const [showNewForm, setShowNewForm] = useState(false); + const [newDeclaration, setNewDeclaration] = useState< + PrivacyDeclaration | undefined + >(undefined); + + const accordionDeclarations = useMemo(() => { + if (!newDeclaration) { + return system.privacy_declarations; + } + return system.privacy_declarations.filter( + (pd) => pd.data_use !== newDeclaration.data_use + ); + }, [newDeclaration, system]); + + const checkAlreadyExists = (values: PrivacyDeclaration) => { + if ( + accordionDeclarations.filter((d) => d.data_use === values.data_use) + .length > 0 + ) { + onCollision(); + return true; + } + return false; + }; + + const handleSave = async (updatedDeclarations: PrivacyDeclaration[]) => { + const transformedDeclarations = updatedDeclarations.map((d) => + transformFormValuesToDeclaration(d) + ); + const success = await onSave(transformedDeclarations); + return success; + }; + + const handleEditDeclaration = async ( + oldDeclaration: PrivacyDeclaration, + updatedDeclaration: PrivacyDeclaration + ) => { + // Do not allow editing a privacy declaration to have the same data use as one that already exists + if ( + updatedDeclaration.data_use !== oldDeclaration.data_use && + checkAlreadyExists(updatedDeclaration) + ) { + return false; + } + // Because the data use can change, we also need a reference to the old declaration in order to + // make sure we are replacing the proper one + const updatedDeclarations = accordionDeclarations.map((dec) => + dec.data_use === oldDeclaration.data_use ? updatedDeclaration : dec + ); + const success = await handleSave(updatedDeclarations); + return success; + }; + + const saveNewDeclaration = async (values: PrivacyDeclaration) => { + if (checkAlreadyExists(values)) { + return false; + } + + toast.closeAll(); + setNewDeclaration(values); + const updatedDeclarations = [...accordionDeclarations, values]; + const success = await handleSave(updatedDeclarations); + return success; + }; + + const handleShowNewForm = () => { + setShowNewForm(true); + setNewDeclaration(undefined); + }; + + const showAddDataUseButton = + system.privacy_declarations.length > 0 || + (system.privacy_declarations.length === 0 && !showNewForm); + + return ( + + + {showNewForm ? ( + + + + ) : null} + {showAddDataUseButton ? ( + + + + + + ) : null} + + ); +}; + +export default PrivacyDeclarationManager; diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx new file mode 100644 index 0000000000..02691ece7d --- /dev/null +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationStep.tsx @@ -0,0 +1,98 @@ +import { Heading, Spinner, Stack, Text, useToast } from "@fidesui/react"; +import { SerializedError } from "@reduxjs/toolkit"; +import { FetchBaseQueryError } from "@reduxjs/toolkit/dist/query/fetchBaseQuery"; +import NextLink from "next/link"; + +import { useAppDispatch } from "~/app/hooks"; +import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; +import { errorToastParams, successToastParams } from "~/features/common/toast"; +import { setActiveSystem, useUpdateSystemMutation } from "~/features/system"; +import { PrivacyDeclaration, System } from "~/types/api"; + +import { useTaxonomyData } from "./PrivacyDeclarationForm"; +import PrivacyDeclarationManager from "./PrivacyDeclarationManager"; + +interface Props { + system: System; +} + +const PrivacyDeclarationStep = ({ system }: Props) => { + const toast = useToast(); + const dispatch = useAppDispatch(); + const [updateSystemMutationTrigger] = useUpdateSystemMutation(); + const { isLoading, ...dataProps } = useTaxonomyData(); + + const handleSave = async (updatedDeclarations: PrivacyDeclaration[]) => { + const systemBodyWithDeclaration = { + ...system, + privacy_declarations: updatedDeclarations, + }; + + const handleResult = ( + result: + | { data: System } + | { error: FetchBaseQueryError | SerializedError } + ) => { + if (isErrorResult(result)) { + const errorMsg = getErrorMessage( + result.error, + "An unexpected error occurred while updating the system. Please try again." + ); + + toast(errorToastParams(errorMsg)); + return false; + } + toast.closeAll(); + toast(successToastParams("Data use case saved")); + dispatch(setActiveSystem(result.data)); + return true; + }; + + const updateSystemResult = await updateSystemMutationTrigger( + systemBodyWithDeclaration + ); + + return handleResult(updateSystemResult); + }; + + const collisionWarning = () => { + toast( + errorToastParams( + "A declaration already exists with that data use in this system. Please supply a different data use." + ) + ); + }; + + return ( + + + Data uses + + + Data Uses describe the business purpose for which the personal data is + processed or collected. Within a Data Use, you assign which categories + of personal information are collected for this purpose and for which + categories of data subjects. To update the available categories and + uses, please visit{" "} + + + Manage taxonomy + + + . + + {isLoading ? ( + + ) : ( + + )} + + ); +}; + +export default PrivacyDeclarationStep; diff --git a/clients/admin-ui/src/features/system/system.slice.ts b/clients/admin-ui/src/features/system/system.slice.ts index 191a7c821a..a274a61d1e 100644 --- a/clients/admin-ui/src/features/system/system.slice.ts +++ b/clients/admin-ui/src/features/system/system.slice.ts @@ -74,31 +74,6 @@ export const systemApi = createApi({ body: patch, }), invalidatesTags: ["System"], - // For optimistic updates - async onQueryStarted( - { fides_key, ...patch }, - { dispatch, queryFulfilled } - ) { - const patchResult = dispatch( - systemApi.util.updateQueryData( - "getSystemByFidesKey", - fides_key, - (draft) => { - Object.assign(draft, patch); - } - ) - ); - try { - await queryFulfilled; - } catch { - patchResult.undo(); - /** - * Alternatively, on failure you can invalidate the corresponding cache tags - * to trigger a re-fetch: - * dispatch(api.util.invalidateTags(['System'])) - */ - } - }, }), }), }); From b7709babb6ab4c7edfe92ee84b07a5cf1094ab33 Mon Sep 17 00:00:00 2001 From: Allison King Date: Fri, 24 Feb 2023 10:38:50 -0500 Subject: [PATCH 16/16] Delete privacy declarations (#2664) --- CHANGELOG.md | 1 + clients/admin-ui/cypress/e2e/systems.cy.ts | 50 +++++++++++++++++++ clients/admin-ui/package-lock.json | 32 +++++++----- clients/admin-ui/package.json | 2 +- .../PrivacyDeclarationAccordion.tsx | 19 ++++--- .../PrivacyDeclarationForm.tsx | 41 +++++++++++++-- .../PrivacyDeclarationManager.tsx | 36 ++++++++++--- .../PrivacyDeclarationStep.tsx | 11 +++- 8 files changed, 157 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f04561d228..da91e36971 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ The types of changes are: - Admin UI - Add flow for selecting system types when manually creating a system [#2530](https://github.com/ethyca/fides/pull/2530) - Updated forms for privacy declarations [#2648](https://github.com/ethyca/fides/pull/2648) + - Delete flow for privacy declarations [#2664](https://github.com/ethyca/fides/pull/2664) - Add warning to 'fides deploy' when installed outside of a virtual environment [#2641](https://github.com/ethyca/fides/pull/2641) diff --git a/clients/admin-ui/cypress/e2e/systems.cy.ts b/clients/admin-ui/cypress/e2e/systems.cy.ts index 303eb174ee..c4e5e732b0 100644 --- a/clients/admin-ui/cypress/e2e/systems.cy.ts +++ b/clients/admin-ui/cypress/e2e/systems.cy.ts @@ -446,5 +446,55 @@ describe("System management page", () => { }); cy.getByTestId("toast-error-msg"); }); + + describe("delete privacy declaration", () => { + beforeEach(() => { + cy.intercept("GET", "/api/v1/system", { + fixture: "systems/systems_with_data_uses.json", + }).as("getSystemsWithDataUses"); + cy.visit("/system"); + cy.wait("@getSystemsWithDataUses"); + cy.getByTestId("system-fidesctl_system").within(() => { + cy.getByTestId("more-btn").click(); + cy.getByTestId("edit-btn").click(); + }); + cy.getByTestId("tab-Data uses").click(); + }); + + it("deletes a new privacy declaration", () => { + cy.getByTestId("add-btn").click(); + cy.wait(["@getDataCategories", "@getDataSubjects", "@getDataUses"]); + + // new form's "delete" btn should be disabled until save + cy.getByTestId("new-declaration-form").within(() => { + cy.getByTestId("input-data_use").type(`collect{enter}`); + cy.getByTestId("input-data_categories").type(`user.biometric{enter}`); + cy.getByTestId("input-data_subjects").type(`anonymous{enter}`); + cy.getByTestId("delete-btn").should("be.disabled"); + cy.getByTestId("save-btn").click(); + cy.wait("@putSystem"); + cy.getByTestId("delete-btn").should("be.enabled"); + // now go through delete flow + cy.getByTestId("delete-btn").click(); + }); + cy.getByTestId("continue-btn").click(); + cy.wait("@putSystem"); + cy.getByTestId("toast-success-msg").contains("Data use case deleted"); + }); + + it("deletes an accordion privacy declaration", () => { + cy.getByTestId("accordion-header-improve.system").click(); + cy.getByTestId("improve.system-form").within(() => { + cy.getByTestId("delete-btn").click(); + }); + cy.getByTestId("continue-btn").click(); + cy.wait("@putSystem").then((interception) => { + const { body } = interception.request; + expect(body.privacy_declarations.length).to.eql(1); + expect(body.privacy_declarations[0].data_use !== "improve.system"); + }); + cy.getByTestId("toast-success-msg").contains("Data use case deleted"); + }); + }); }); }); diff --git a/clients/admin-ui/package-lock.json b/clients/admin-ui/package-lock.json index 2e3352770c..fc424f9b6e 100644 --- a/clients/admin-ui/package-lock.json +++ b/clients/admin-ui/package-lock.json @@ -48,7 +48,7 @@ "@typescript-eslint/parser": "^5.12.0", "babel-jest": "^27.5.1", "cross-env": "^7.0.3", - "cypress": "^10.3.0", + "cypress": "^12.6.0", "eslint": "^8.9.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", @@ -6043,10 +6043,11 @@ "license": "MIT" }, "node_modules/cypress": { - "version": "10.7.0", + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.6.0.tgz", + "integrity": "sha512-WdHSVaS1lumSd5XpVTslZd8ui9GIGphrzvXq9+3DtVhqjRZC5M70gu5SW/Y/SLPq3D1wiXGZoHC6HJ7ESVE2lw==", "dev": true, "hasInstallScript": true, - "license": "MIT", "dependencies": { "@cypress/request": "^2.88.10", "@cypress/xvfb": "^1.2.4", @@ -6065,9 +6066,9 @@ "commander": "^5.1.0", "common-tags": "^1.8.0", "dayjs": "^1.10.4", - "debug": "^4.3.2", + "debug": "^4.3.4", "enquirer": "^2.3.6", - "eventemitter2": "^6.4.3", + "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", "extract-zip": "2.0.1", @@ -6095,7 +6096,7 @@ "cypress": "bin/cypress" }, "engines": { - "node": ">=12.0.0" + "node": "^14.0.0 || ^16.0.0 || >=18.0.0" } }, "node_modules/cypress/node_modules/@types/node": { @@ -7116,9 +7117,10 @@ } }, "node_modules/eventemitter2": { - "version": "6.4.8", - "dev": true, - "license": "MIT" + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", + "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", + "dev": true }, "node_modules/events": { "version": "3.3.0", @@ -17414,7 +17416,9 @@ "version": "3.0.10" }, "cypress": { - "version": "10.7.0", + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-12.6.0.tgz", + "integrity": "sha512-WdHSVaS1lumSd5XpVTslZd8ui9GIGphrzvXq9+3DtVhqjRZC5M70gu5SW/Y/SLPq3D1wiXGZoHC6HJ7ESVE2lw==", "dev": true, "requires": { "@cypress/request": "^2.88.10", @@ -17434,9 +17438,9 @@ "commander": "^5.1.0", "common-tags": "^1.8.0", "dayjs": "^1.10.4", - "debug": "^4.3.2", + "debug": "^4.3.4", "enquirer": "^2.3.6", - "eventemitter2": "^6.4.3", + "eventemitter2": "6.4.7", "execa": "4.1.0", "executable": "^4.1.1", "extract-zip": "2.0.1", @@ -18131,7 +18135,9 @@ "dev": true }, "eventemitter2": { - "version": "6.4.8", + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", + "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", "dev": true }, "events": { diff --git a/clients/admin-ui/package.json b/clients/admin-ui/package.json index e2025d61fd..fce5ac0a93 100644 --- a/clients/admin-ui/package.json +++ b/clients/admin-ui/package.json @@ -67,7 +67,7 @@ "@typescript-eslint/parser": "^5.12.0", "babel-jest": "^27.5.1", "cross-env": "^7.0.3", - "cypress": "^10.3.0", + "cypress": "^12.6.0", "eslint": "^8.9.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx index 0be8d5b015..12c442086b 100644 --- a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationAccordion.tsx @@ -23,11 +23,13 @@ interface AccordionProps extends DataProps { oldDeclaration: PrivacyDeclaration, newDeclaration: PrivacyDeclaration ) => Promise; + onDelete: (declaration: PrivacyDeclaration) => Promise; } const PrivacyDeclarationAccordionItem = ({ privacyDeclaration, onEdit, + onDelete, ...dataProps }: { privacyDeclaration: PrivacyDeclaration } & Omit< AccordionProps, @@ -69,7 +71,10 @@ const PrivacyDeclarationAccordionItem = ({ - + @@ -89,13 +94,13 @@ const PrivacyDeclarationAccordion = ({ border="transparent" data-testid="privacy-declaration-accordion" > - {privacyDeclarations.map((dec, i) => ( + {privacyDeclarations.map((dec) => ( diff --git a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx index 67a954be9c..ea70fb4160 100644 --- a/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx +++ b/clients/admin-ui/src/features/system/privacy-declarations/PrivacyDeclarationForm.tsx @@ -11,12 +11,14 @@ import { Heading, Stack, Text, + useDisclosure, } from "@fidesui/react"; import { Form, Formik, FormikHelpers, useFormikContext } from "formik"; import { useMemo, useState } from "react"; import * as Yup from "yup"; import { useAppSelector } from "~/app/hooks"; +import ConfirmationModal from "~/features/common/ConfirmationModal"; import { CustomSelect } from "~/features/common/form/inputs"; import { selectDataSubjects, @@ -65,8 +67,19 @@ export const PrivacyDeclarationFormComponents = ({ allDataUses, allDataCategories, allDataSubjects, -}: DataProps) => { - const { dirty, isSubmitting, isValid } = useFormikContext(); + onDelete, +}: DataProps & Pick) => { + const { dirty, isSubmitting, isValid, initialValues } = + useFormikContext(); + const deleteModal = useDisclosure(); + + const handleDelete = async () => { + await onDelete(initialValues); + deleteModal.onClose(); + }; + + const deleteDisabled = initialValues.data_use === ""; + return ( -