diff --git a/clients/admin-ui/src/features/common/Icon/NextArrow.tsx b/clients/admin-ui/src/features/common/Icon/NextArrow.tsx new file mode 100644 index 0000000000..3a1093de7b --- /dev/null +++ b/clients/admin-ui/src/features/common/Icon/NextArrow.tsx @@ -0,0 +1,7 @@ +import { createIcon } from "@fidesui/react"; + +export default createIcon({ + displayName: "NextArrow", + viewBox: "0 0 12 12", + d: "M6.58584 5.99999L4.11084 3.52499L4.81784 2.81799L7.99984 5.99999L4.81784 9.18199L4.11084 8.47499L6.58584 5.99999Z", +}); diff --git a/clients/admin-ui/src/features/common/Icon/PrevArrow.tsx b/clients/admin-ui/src/features/common/Icon/PrevArrow.tsx new file mode 100644 index 0000000000..9953af8d48 --- /dev/null +++ b/clients/admin-ui/src/features/common/Icon/PrevArrow.tsx @@ -0,0 +1,7 @@ +import { createIcon } from "@fidesui/react"; + +export default createIcon({ + displayName: "PrevArrow", + viewBox: "0 0 12 12", + d: "M5.414 5.99999L7.889 8.47499L7.182 9.18199L4 5.99999L7.182 2.81799L7.889 3.52499L5.414 5.99999Z", +}); diff --git a/clients/admin-ui/src/features/plus/plus.slice.ts b/clients/admin-ui/src/features/plus/plus.slice.ts index 87ac7c9b58..fe0a7c9251 100644 --- a/clients/admin-ui/src/features/plus/plus.slice.ts +++ b/clients/admin-ui/src/features/plus/plus.slice.ts @@ -264,9 +264,15 @@ const plusApi = baseApi.injectEndpoints({ }), getSystemHistory: build.query< SystemHistoryResponse, - { system_key: string } + { system_key: string; page?: number; size?: number } >({ - query: (params) => ({ url: `plus/system/${params.system_key}/history` }), + query: (params) => ({ + url: `plus/system/${params.system_key}/history`, + params: { + page: params.page, + size: params.size, + }, + }), providesTags: () => ["System History"], }), }), diff --git a/clients/admin-ui/src/features/system/SystemFormTabs.tsx b/clients/admin-ui/src/features/system/SystemFormTabs.tsx index 100f8e1be2..68f1baf2da 100644 --- a/clients/admin-ui/src/features/system/SystemFormTabs.tsx +++ b/clients/admin-ui/src/features/system/SystemFormTabs.tsx @@ -255,7 +255,8 @@ const SystemFormTabs = ({ All changes to this system are tracked here in this audit table by - date and by user. + date and by user. You can inspect the changes by selecting any of + the events listed. diff --git a/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx b/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx index de75c6cb9c..4448b64ec0 100644 --- a/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx +++ b/clients/admin-ui/src/features/system/history/SystemHistoryTable.tsx @@ -1,11 +1,25 @@ -import { Table, Tbody, Td, Thead, Tr } from "@fidesui/react"; +import { + Button, + Flex, + Table, + Tbody, + Td, + Text, + Thead, + Tr, +} from "@fidesui/react"; import _ from "lodash"; -import React from "react"; +import React, { useState } from "react"; +import NextArrow from "~/features/common/Icon/NextArrow"; +import PrevArrow from "~/features/common/Icon/PrevArrow"; import { useGetSystemHistoryQuery } from "~/features/plus/plus.slice"; +import { PrivacyDeclaration } from "~/types/api"; import { SystemHistory } from "~/types/api/models/SystemHistory"; import { SystemResponse } from "~/types/api/models/SystemResponse"; +import SystemHistoryModal from "./modal/SystemHistoryModal"; + interface Props { system: SystemResponse; } @@ -34,14 +48,81 @@ const formatDateAndTime = (dateString: string) => { return { formattedTime, formattedDate }; }; +function alignArrays( + before: PrivacyDeclaration[], + after: PrivacyDeclaration[] +) { + const allNames = new Set([...before, ...after].map((item) => item.data_use)); + const alignedBefore: PrivacyDeclaration[] = []; + const alignedAfter: PrivacyDeclaration[] = []; + + allNames.forEach((data_use) => { + const firstItem = before.find((item) => item.data_use === data_use) || { + data_use: "", + data_categories: [], + }; + const secondItem = after.find((item) => item.data_use === data_use) || { + data_use: "", + data_categories: [], + }; + alignedBefore.push(firstItem); + alignedAfter.push(secondItem); + }); + + return [alignedBefore, alignedAfter]; +} + +const itemsPerPage = 10; + const SystemHistoryTable = ({ system }: Props) => { - // Fetch system history data + const [currentPage, setCurrentPage] = useState(1); const { data } = useGetSystemHistoryQuery({ system_key: system.fides_key, + page: currentPage, + size: itemsPerPage, }); + const [isModalOpen, setModalOpen] = useState(false); + const [selectedHistory, setSelectedHistory] = useState( + null + ); const systemHistories = data?.items || []; + const openModal = (history: SystemHistory) => { + // Align the privacy_declarations arrays + const beforePrivacyDeclarations = + history?.before?.privacy_declarations || []; + const afterPrivacyDeclarations = history?.after?.privacy_declarations || []; + const [alignedBefore, alignedAfter] = alignArrays( + beforePrivacyDeclarations, + afterPrivacyDeclarations + ); + + // Create new initialValues objects with the aligned arrays + const alignedBeforeInitialValues = { + ...history?.before, + privacy_declarations: alignedBefore, + }; + const alignedAfterInitialValues = { + ...history?.after, + privacy_declarations: alignedAfter, + }; + + setSelectedHistory({ + before: alignedBeforeInitialValues, + after: alignedAfterInitialValues, + edited_by: history.edited_by, + system_id: history.system_id, + created_at: history.created_at, + }); + setModalOpen(true); + }; + + const closeModal = () => { + setModalOpen(false); + setSelectedHistory(null); + }; + const describeSystemChange = (history: SystemHistory) => { // eslint-disable-next-line @typescript-eslint/naming-convention const { edited_by, before, after, created_at } = history; @@ -146,58 +227,106 @@ const SystemHistoryTable = ({ system }: Props) => { const { formattedTime, formattedDate } = formatDateAndTime(system.created_at); + const totalPages = data ? Math.ceil(data.total / itemsPerPage) : 0; + + const handleNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + const handlePrevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + return ( - - - - - - - - {systemHistories.map((history: SystemHistory, index: number) => { - const description = describeSystemChange(history); - return ( - +
- System created - {system.created_by && ( - <> - {" "} - by {system.created_by}{" "} - - )}{" "} - on {formattedDate} at {formattedTime} -
+ + + + + + + {systemHistories.map((history, index) => { + const description = describeSystemChange(history); + return ( + openModal(history)} + style={{ cursor: "pointer" }} > - {description} - - - ); - })} - -
- + {" "} + by {system.created_by}{" "} + + )}{" "} + on {formattedDate} at {formattedTime} +
+ + {description} + + + ); + })} + + + {(data?.total || 0) > 10 && ( + + + {(currentPage - 1) * itemsPerPage + 1} -{" "} + {Math.min(currentPage * itemsPerPage, data?.total || 0)} of{" "} + {data?.total || 0} + + + + + )} + + ); }; diff --git a/clients/admin-ui/src/features/system/history/modal/SelectedHistoryContext.tsx b/clients/admin-ui/src/features/system/history/modal/SelectedHistoryContext.tsx new file mode 100644 index 0000000000..6aece9a4f2 --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/SelectedHistoryContext.tsx @@ -0,0 +1,40 @@ +import React, { createContext, ReactNode, useContext, useMemo } from "react"; + +import { SystemHistory } from "~/types/api/models/SystemHistory"; + +type FormType = "before" | "after"; + +interface SelectedHistoryContextProps { + selectedHistory: SystemHistory | null; + formType: FormType; +} + +const SelectedHistoryContext = + createContext(null); + +export const useSelectedHistory = () => useContext(SelectedHistoryContext)!; + +interface SelectedHistoryProviderProps { + children: ReactNode; + selectedHistory: SystemHistory | null; + formType: FormType; +} + +const SelectedHistoryProvider: React.FC = ({ + children, + selectedHistory, + formType, +}) => { + const value = useMemo( + () => ({ selectedHistory, formType }), + [selectedHistory, formType] + ); + + return ( + + {children} + + ); +}; + +export default SelectedHistoryProvider; diff --git a/clients/admin-ui/src/features/system/history/modal/SystemDataForm.tsx b/clients/admin-ui/src/features/system/history/modal/SystemDataForm.tsx new file mode 100644 index 0000000000..557d0f94c2 --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/SystemDataForm.tsx @@ -0,0 +1,261 @@ +import { Stack } from "@fidesui/react"; +import { Form, Formik } from "formik"; +import React from "react"; + +import { useFeatures } from "~/features/common/features/features.slice"; +import { PrivacyDeclaration } from "~/types/api"; + +import SystemDataSwitch from "./fields/SystemDataSwitch"; +import SystemDataTags from "./fields/SystemDataTags"; +import SystemDataTextField from "./fields/SystemDataTextField"; +import SystemDataGroup from "./SystemDataGroup"; + +interface SystemDataFormProps { + initialValues: Record; +} + +const SystemDataForm: React.FC = ({ initialValues }) => { + const features = useFeatures(); + return ( + {}} + > + {() => ( +
+ + {/* System information */} + + {features.dictionaryService ? ( + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Data uses */} + {initialValues.privacy_declarations && + initialValues.privacy_declarations.map( + (_: PrivacyDeclaration, index: number) => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + ) + )} + {/* System flow */} + + + + + +
+ )} +
+ ); +}; + +export default SystemDataForm; diff --git a/clients/admin-ui/src/features/system/history/modal/SystemDataGroup.tsx b/clients/admin-ui/src/features/system/history/modal/SystemDataGroup.tsx new file mode 100644 index 0000000000..e07bc1d007 --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/SystemDataGroup.tsx @@ -0,0 +1,74 @@ +import { Box, Heading, Stack } from "@fidesui/react"; +import _ from "lodash"; +import React from "react"; + +import { useSelectedHistory } from "./SelectedHistoryContext"; + +const SystemDataGroup = ({ + heading, + children, +}: { + heading: string; + children?: React.ReactNode; +}) => { + const { selectedHistory } = useSelectedHistory(); + const childArray = React.Children.toArray(children); + + // Filter children based on whether their name prop exists in before or after of selectedHistory + const filteredChildren = childArray.filter((child) => { + if (React.isValidElement(child) && child.props.name) { + const { name } = child.props; + const beforeValue = _.get(selectedHistory?.before, name); + const afterValue = _.get(selectedHistory?.after, name); + const isBeforeValueEmpty = + typeof beforeValue === "boolean" || typeof beforeValue === "number" + ? false + : _.isEmpty(beforeValue); + const isAfterValueEmpty = + typeof afterValue === "boolean" || typeof afterValue === "number" + ? false + : _.isEmpty(afterValue); + + return !isBeforeValueEmpty || !isAfterValueEmpty; + } + return false; + }); + + // If no children should be rendered, return null + if (filteredChildren.length === 0) { + return null; + } + + return ( + + + + + {heading} + + + + {filteredChildren} + + + ); +}; + +export default SystemDataGroup; diff --git a/clients/admin-ui/src/features/system/history/modal/SystemHistoryModal.tsx b/clients/admin-ui/src/features/system/history/modal/SystemHistoryModal.tsx new file mode 100644 index 0000000000..3760c8e3b7 --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/SystemHistoryModal.tsx @@ -0,0 +1,114 @@ +import { + Badge, + Flex, + Heading, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Spacer, +} from "@fidesui/react"; + +import { SystemHistory } from "~/types/api/models/SystemHistory"; + +import SelectedHistoryProvider from "./SelectedHistoryContext"; +import SystemDataForm from "./SystemDataForm"; + +const getBadges = (before: Record, after: Record) => { + const badges = []; + const specialFields = new Set(["egress", "ingress", "privacy_declarations"]); + + if (before.egress || after.egress || before.ingress || after.ingress) { + badges.push("Data Flow"); + } + + if ( + (before.privacy_declarations && before.privacy_declarations.length > 0) || + (after.privacy_declarations && after.privacy_declarations.length > 0) + ) { + badges.push("Data Uses"); + } + + const hasOtherFields = [...Object.keys(before), ...Object.keys(after)].some( + (key) => !specialFields.has(key) + ); + if (hasOtherFields) { + badges.unshift("System Information"); + } + + return badges; +}; + +interface Props { + selectedHistory: SystemHistory; + isOpen: boolean; + onClose: () => void; +} + +const SystemHistoryModal = ({ selectedHistory, isOpen, onClose }: Props) => ( + + + + + + Diff review + {selectedHistory && ( + <> + {getBadges(selectedHistory.before, selectedHistory.after).map( + (badge, index) => ( + + {badge} + + ) + )} + + )} + + <> + + + + + + +
+ + + +
+
+ + + +
+
+
+
+
+); + +export default SystemHistoryModal; diff --git a/clients/admin-ui/src/features/system/history/modal/fields/SystemDataSwitch.tsx b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataSwitch.tsx new file mode 100644 index 0000000000..083b7173dc --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataSwitch.tsx @@ -0,0 +1,90 @@ +import { Flex, FormControl, Tag, VStack } from "@fidesui/react"; +import { useField } from "formik"; +import _ from "lodash"; +import { useEffect, useState } from "react"; + +import { + CustomInputProps, + Label, + StringField, +} from "~/features/common/form/inputs"; +import QuestionTooltip from "~/features/common/QuestionTooltip"; + +import { useSelectedHistory } from "../SelectedHistoryContext"; + +const SystemDataSwitch = ({ + label, + tooltip, + ...props +}: CustomInputProps & StringField) => { + const { selectedHistory, formType } = useSelectedHistory(); + const [initialField] = useField(props); + const field = { ...initialField, value: initialField.value ?? "" }; + + const [shouldHighlight, setShouldHighlight] = useState(false); + + useEffect(() => { + const beforeValue = _.get(selectedHistory?.before, props.name) || ""; + const afterValue = _.get(selectedHistory?.after, props.name) || ""; + + // Determine whether to highlight + setShouldHighlight(beforeValue !== afterValue); + }, [selectedHistory, props.name, field.value]); + + let highlightStyle = {}; + + if (shouldHighlight) { + if (formType === "before") { + highlightStyle = { + backgroundColor: "#FFF5F5", + borderColor: "#E53E3E", + borderTop: "1px dashed #E53E3E", + borderBottom: "1px dashed #E53E3E", + }; + } else { + highlightStyle = { + backgroundColor: "#F0FFF4", + borderColor: "#38A169", + borderTop: "1px dashed #38A169", + borderBottom: "1px dashed #38A169", + }; + } + } + + return ( + + + + + {tooltip ? : null} + + + {field.value ? "YES" : "NO"} + + {formType === "before" && shouldHighlight && ( +
+ → +
+ )} +
+
+ ); +}; + +export default SystemDataSwitch; diff --git a/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTags.tsx b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTags.tsx new file mode 100644 index 0000000000..533c2c6781 --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTags.tsx @@ -0,0 +1,116 @@ +import { Flex, FormControl, Tag, VStack } from "@fidesui/react"; +import { useField } from "formik"; +import _ from "lodash"; +import React, { useEffect, useRef, useState } from "react"; + +import { Label } from "~/features/common/form/inputs"; +import QuestionTooltip from "~/features/common/QuestionTooltip"; + +import { useSelectedHistory } from "../SelectedHistoryContext"; + +const SystemDataTags = ({ + label, + tooltip, + ...props +}: { + label: string; + tooltip?: string; + name: string; +}) => { + const { selectedHistory, formType } = useSelectedHistory(); + const [initialField] = useField(props.name); + const field = { ...initialField, value: initialField.value ?? [] }; + + const contentRef = useRef(null); + const [height, setHeight] = useState(null); + const [longestValue, setLongestValue] = useState([]); + const [shouldHighlight, setShouldHighlight] = useState(false); + + useEffect(() => { + const beforeValue = _.get(selectedHistory?.before, props.name) || []; + const afterValue = _.get(selectedHistory?.after, props.name) || []; + + // Determine whether to highlight + setShouldHighlight(!_.isEqual(beforeValue, afterValue)); + + // Determine the longest value for height calculation + setLongestValue( + beforeValue.length > afterValue.length ? beforeValue : afterValue + ); + }, [selectedHistory, props.name]); + + useEffect(() => { + if (contentRef.current) { + setHeight(contentRef.current.offsetHeight); + } + }, [longestValue]); + + let highlightStyle = {}; + + if (shouldHighlight) { + if (formType === "before") { + highlightStyle = { + backgroundColor: "#FFF5F5", + borderColor: "#E53E3E", + borderTop: "1px dashed #E53E3E", + borderBottom: "1px dashed #E53E3E", + }; + } else { + highlightStyle = { + backgroundColor: "#F0FFF4", + borderColor: "#38A169", + borderTop: "1px dashed #38A169", + borderBottom: "1px dashed #38A169", + }; + } + } + + return ( + + + + + {tooltip ? : null} + + + {(height ? field.value : longestValue).map( + (value: any, index: number) => ( + // eslint-disable-next-line react/no-array-index-key + + {typeof value === "object" ? value.fides_key : value} + + ) + )} + + {formType === "before" && shouldHighlight && ( +
+ → +
+ )} +
+
+ ); +}; + +export default SystemDataTags; diff --git a/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTextField.tsx b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTextField.tsx new file mode 100644 index 0000000000..e6f819240b --- /dev/null +++ b/clients/admin-ui/src/features/system/history/modal/fields/SystemDataTextField.tsx @@ -0,0 +1,110 @@ +import { Flex, FormControl, Text, VStack } from "@fidesui/react"; +import { useField } from "formik"; +import _ from "lodash"; +import { useEffect, useRef, useState } from "react"; + +import { + CustomInputProps, + Label, + StringField, +} from "~/features/common/form/inputs"; +import QuestionTooltip from "~/features/common/QuestionTooltip"; + +import { useSelectedHistory } from "../SelectedHistoryContext"; + +const SystemDataTextField = ({ + label, + tooltip, + ...props +}: CustomInputProps & StringField) => { + const { selectedHistory, formType } = useSelectedHistory(); + const [initialField] = useField(props); + const field = { ...initialField, value: initialField.value ?? "" }; + + const contentRef = useRef(null); + const [height, setHeight] = useState(null); + const [shouldHighlight, setShouldHighlight] = useState(false); + + useEffect(() => { + const beforeValue = _.get(selectedHistory?.before, props.name) || ""; + const afterValue = _.get(selectedHistory?.after, props.name) || ""; + + // Determine whether to highlight + setShouldHighlight(beforeValue !== afterValue); + + const longestValue = + beforeValue.length > afterValue.length ? beforeValue : afterValue; + + if (contentRef.current) { + // Temporarily set the value to the longest one to measure height + contentRef.current.textContent = longestValue; + + // Measure and set the height + setHeight(contentRef.current.offsetHeight); + + // Reset the value to the actual one + contentRef.current.textContent = field.value; + } + }, [selectedHistory, props.name, field.value]); + + let highlightStyle = {}; + + if (shouldHighlight) { + if (formType === "before") { + highlightStyle = { + backgroundColor: "#FFF5F5", + borderColor: "#E53E3E", + borderTop: "1px dashed #E53E3E", + borderBottom: "1px dashed #E53E3E", + }; + } else { + highlightStyle = { + backgroundColor: "#F0FFF4", + borderColor: "#38A169", + borderTop: "1px dashed #38A169", + borderBottom: "1px dashed #38A169", + }; + } + } + + return ( + + + + + {tooltip ? : null} + + + {field.value} + + {formType === "before" && shouldHighlight && ( +
+ → +
+ )} +
+
+ ); +}; + +export default SystemDataTextField; diff --git a/clients/admin-ui/src/types/api/models/SystemHistory.ts b/clients/admin-ui/src/types/api/models/SystemHistory.ts index efb79bd3fa..01084444e0 100644 --- a/clients/admin-ui/src/types/api/models/SystemHistory.ts +++ b/clients/admin-ui/src/types/api/models/SystemHistory.ts @@ -1,7 +1,7 @@ export type SystemHistory = { edited_by: string; system_id: string; - before: object; - after: object; + before: Record; + after: Record; created_at: string; };