diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f4d6beb687..de38f66105e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,9 +27,9 @@ The types of changes are: * Added the ability to edit taxonomy fields via the UI [#977](https://github.com/ethyca/fides/pull/977) * New column `is_default` added to DataCategory, DataUse, DataSubject, and DataQualifier tables [#976](https://github.com/ethyca/fides/pull/976) +* Added the ability to add taxonomy fields via the UI [#1019](https://github.com/ethyca/fides/pull/1019) * Added the ability to delete taxonomy fields via the UI [#1006](https://github.com/ethyca/fides/pull/1006) * Prevent modifying taxonomy `is_default` fields and from adding `is_default=True` fields via the API [#990](https://github.com/ethyca/fides/pull/990). - ### Changed * Upgraded base Docker version to Python 3.9 and updated all other references from 3.8 -> 3.9 [#974](https://github.com/ethyca/fides/pull/974) diff --git a/clients/ctl/admin-ui/__tests__/features/taxonomy-helpers.test.tsx b/clients/ctl/admin-ui/__tests__/features/taxonomy-helpers.test.tsx new file mode 100644 index 00000000000..eba9a802ca0 --- /dev/null +++ b/clients/ctl/admin-ui/__tests__/features/taxonomy-helpers.test.tsx @@ -0,0 +1,18 @@ +import { parentKeyFromFidesKey } from "~/features/taxonomy/helpers"; + +describe("taxonomy helpers", () => { + describe("parent key from fides key", () => { + it("should handle nested fides keys", () => { + const fidesKey = "grandparent.parent.child"; + expect(parentKeyFromFidesKey(fidesKey)).toEqual("grandparent.parent"); + }); + it("should handle empty fides key", () => { + const fidesKey = ""; + expect(parentKeyFromFidesKey(fidesKey)).toEqual(""); + }); + it("should handle single fides key", () => { + const fidesKey = "root"; + expect(parentKeyFromFidesKey(fidesKey)).toEqual(""); + }); + }); +}); diff --git a/clients/ctl/admin-ui/cypress/e2e/taxonomy.cy.ts b/clients/ctl/admin-ui/cypress/e2e/taxonomy.cy.ts index 91c7f140854..7701d430281 100644 --- a/clients/ctl/admin-ui/cypress/e2e/taxonomy.cy.ts +++ b/clients/ctl/admin-ui/cypress/e2e/taxonomy.cy.ts @@ -148,18 +148,20 @@ describe("Taxonomy management page", () => { "have.value", tabValue.description ); - cy.getByTestId("input-parent_key").should( - "have.value", - tabValue.parentKey - ); - cy.getByTestId("input-parent_key").should("be.disabled"); - cy.getByTestId("update-btn").should("be.disabled"); + if (tabValue.tab !== "Data Subjects") { + cy.getByTestId("input-parent_key").should( + "have.value", + tabValue.parentKey + ); + cy.getByTestId("input-parent_key").should("be.disabled"); + } + cy.getByTestId("submit-btn").should("be.disabled"); // make an edit const addedText = "foo"; cy.getByTestId("input-name").type(addedText); - cy.getByTestId("update-btn").should("be.enabled"); - cy.getByTestId("update-btn").click(); + cy.getByTestId("submit-btn").should("be.enabled"); + cy.getByTestId("submit-btn").click(); cy.wait(tabValue.request).then((interception) => { const { body } = interception.request; expect(body.name).to.eql(`${tabValue.name}${addedText}`); @@ -207,7 +209,7 @@ describe("Taxonomy management page", () => { cy.getByTestId("input-legitimate_interest_impact_assessment") .clear() .type("foo"); - cy.getByTestId("update-btn").click(); + cy.getByTestId("submit-btn").click(); cy.wait("@putDataUse").then((interception) => { const { body } = interception.request; const expected = { @@ -216,7 +218,6 @@ describe("Taxonomy management page", () => { description: "Provide, give, or make available the product, service, application or system.", is_default: true, - parent_key: null, legal_basis: "Legitimate Interests", special_category: "Vital Interests", recipients: ["marketing team", "dog shelter"], @@ -260,7 +261,7 @@ describe("Taxonomy management page", () => { // trigger a PUT cy.getByTestId("input-name").clear().type("foo"); - cy.getByTestId("update-btn").click(); + cy.getByTestId("submit-btn").click(); cy.wait("@putDataSubject").then((interception) => { const { body } = interception.request; const expected = { @@ -298,7 +299,7 @@ describe("Taxonomy management page", () => { const addedText = "foo"; cy.getByTestId("input-name").type(addedText); - cy.getByTestId("update-btn").click(); + cy.getByTestId("submit-btn").click(); cy.wait("@putDataCategoryError"); cy.getByTestId("toast-success-msg").should("not.exist"); @@ -306,6 +307,132 @@ describe("Taxonomy management page", () => { }); }); + describe("Can create data", () => { + beforeEach(() => { + cy.visit("/taxonomy"); + const taxonomyPayload = { + statusCode: 200, + body: { + fides_key: "key", + organization_fides_key: "default_organization", + name: "name", + description: "description", + parent_key: undefined, + }, + }; + cy.intercept("POST", "/api/v1/data_category*", taxonomyPayload).as( + "postDataCategory" + ); + cy.intercept("POST", "/api/v1/data_use*", taxonomyPayload).as( + "postDataUse" + ); + cy.intercept("POST", "/api/v1/data_subject*", taxonomyPayload).as( + "postDataSubject" + ); + cy.intercept("POST", "/api/v1/data_qualifier*", taxonomyPayload).as( + "postDataQualifier" + ); + }); + + it("Can open a create form for each taxonomy entity", () => { + const expectedTabValues = [ + { + tab: "Data Categories", + name: "Data category", + request: "@postDataCategory", + }, + { + tab: "Data Uses", + name: "Data use", + request: "@postDataUse", + }, + { + tab: "Data Subjects", + name: "Data subject", + request: "@postDataSubject", + }, + { + tab: "Identifiability", + name: "Data qualifier", + request: "@postDataQualifier", + }, + ]; + expectedTabValues.forEach((tabValue) => { + cy.getByTestId(`tab-${tabValue.tab}`).click(); + cy.getByTestId("add-taxonomy-btn").click(); + cy.getByTestId("create-taxonomy-form"); + cy.getByTestId("form-heading").should("contain", tabValue.name); + + // add a root value + cy.getByTestId("input-fides_key").type("foo"); + if (tabValue.tab !== "Data Subjects") { + cy.getByTestId("input-parent_key").should("have.value", ""); + } + cy.getByTestId("submit-btn").click(); + cy.wait(tabValue.request).then((interception) => { + const { body } = interception.request; + expect(body.fides_key).to.eql("foo"); + expect(body.parent_key).to.equal(undefined); + expect(body.is_default).to.equal(false); + }); + cy.getByTestId("toast-success-msg").should("exist"); + + // add a child value + cy.getByTestId("add-taxonomy-btn").click(); + cy.getByTestId("input-fides_key").type("foo.bar.baz"); + if (tabValue.tab !== "Data Subjects") { + cy.getByTestId("input-parent_key").should("have.value", "foo.bar"); + } + cy.getByTestId("submit-btn").click(); + cy.wait(tabValue.request).then((interception) => { + const { body } = interception.request; + expect(body.fides_key).to.eql("foo.bar.baz"); + expect(body.parent_key).to.equal("foo.bar"); + expect(body.is_default).to.equal(false); + }); + cy.getByTestId("toast-success-msg").should("exist"); + }); + }); + + it("Can trigger an error", () => { + const errorMsg = "Internal Server Error"; + cy.intercept("POST", "/api/v1/data_category*", { + statusCode: 500, + body: errorMsg, + }).as("postDataCategoryError"); + + cy.getByTestId(`tab-Data Categories`).click(); + cy.getByTestId("add-taxonomy-btn").click(); + + cy.getByTestId("input-fides_key").type("foo"); + cy.getByTestId("submit-btn").click(); + + cy.wait("@postDataCategoryError"); + cy.getByTestId("toast-success-msg").should("not.exist"); + cy.getByTestId("taxonomy-form-error").should("contain", errorMsg); + }); + + it("Will only show either the add or the edit form", () => { + cy.getByTestId(`tab-Data Categories`).click(); + const openEditForm = () => { + cy.getByTestId("accordion-item-System Data").trigger("mouseover"); + cy.getByTestId("edit-btn").click(); + }; + const openCreateForm = () => { + cy.getByTestId("add-taxonomy-btn").click(); + }; + openEditForm(); + cy.getByTestId("edit-taxonomy-form"); + cy.getByTestId("create-taxonomy-form").should("not.exist"); + openCreateForm(); + cy.getByTestId("edit-taxonomy-form").should("not.exist"); + cy.getByTestId("create-taxonomy-form"); + openEditForm(); + cy.getByTestId("edit-taxonomy-form"); + cy.getByTestId("create-taxonomy-form").should("not.exist"); + }); + }); + describe("Can delete data", () => { beforeEach(() => { cy.visit("/taxonomy"); @@ -344,25 +471,19 @@ describe("Taxonomy management page", () => { cy.getByTestId("accordion-item-User Data").trigger("mouseover"); cy.getByTestId("delete-btn").should("not.exist"); cy.getByTestId("accordion-item-User Data").click(); - cy.getByTestId("accordion-item-User Provided Data").click(); - cy.getByTestId("item-User Provided Non-Identifiable Data").trigger( - "mouseover" - ); + cy.getByTestId("item-Biometric Data").trigger("mouseover"); cy.getByTestId("delete-btn"); }); it("Can delete a data category", () => { cy.getByTestId(`tab-Data Categories`).click(); cy.getByTestId("accordion-item-User Data").click(); - cy.getByTestId("accordion-item-User Provided Data").click(); - cy.getByTestId("item-User Provided Non-Identifiable Data").trigger( - "mouseover" - ); + cy.getByTestId("item-Biometric Data").trigger("mouseover"); cy.getByTestId("delete-btn").click(); cy.getByTestId("continue-btn").click(); cy.wait("@deleteDataCategory").then((interception) => { const { url } = interception.request; - expect(url).to.contain("user.provided.nonidentifiable"); + expect(url).to.contain("user.biometric"); }); cy.getByTestId("toast-success-msg"); }); @@ -416,10 +537,7 @@ describe("Taxonomy management page", () => { }).as("deleteDataCategoryError"); cy.getByTestId(`tab-Data Categories`).click(); cy.getByTestId("accordion-item-User Data").click(); - cy.getByTestId("accordion-item-User Provided Data").click(); - cy.getByTestId("item-User Provided Non-Identifiable Data").trigger( - "mouseover" - ); + cy.getByTestId("item-Biometric Data").trigger("mouseover"); cy.getByTestId("delete-btn").click(); cy.getByTestId("continue-btn").click(); cy.wait("@deleteDataCategoryError"); diff --git a/clients/ctl/admin-ui/src/app/store.ts b/clients/ctl/admin-ui/src/app/store.ts index 385628a8295..0c5e2b5b349 100644 --- a/clients/ctl/admin-ui/src/app/store.ts +++ b/clients/ctl/admin-ui/src/app/store.ts @@ -22,10 +22,7 @@ import { reducer as organizationReducer, } from "~/features/organization"; import { systemApi } from "~/features/system"; -import { - dataCategoriesApi, - reducer as dataCategoriesReducer, -} from "~/features/taxonomy"; +import { reducer as taxonomyReducer, taxonomyApi } from "~/features/taxonomy"; import { reducer as userReducer } from "~/features/user"; const makeStore = () => { @@ -34,7 +31,7 @@ const makeStore = () => { configWizard: configWizardReducer, user: userReducer, dataset: datasetReducer, - dataCategories: dataCategoriesReducer, + taxonomy: taxonomyReducer, dataQualifier: dataQualifierReducer, dataSubjects: dataSubjectsReducer, dataUse: dataUseReducer, @@ -43,7 +40,7 @@ const makeStore = () => { [organizationApi.reducerPath]: organizationApi.reducer, [scannerApi.reducerPath]: scannerApi.reducer, [systemApi.reducerPath]: systemApi.reducer, - [dataCategoriesApi.reducerPath]: dataCategoriesApi.reducer, + [taxonomyApi.reducerPath]: taxonomyApi.reducer, [dataQualifierApi.reducerPath]: dataQualifierApi.reducer, [dataSubjectsApi.reducerPath]: dataSubjectsApi.reducer, [dataUseApi.reducerPath]: dataUseApi.reducer, @@ -54,7 +51,7 @@ const makeStore = () => { organizationApi.middleware, scannerApi.middleware, systemApi.middleware, - dataCategoriesApi.middleware, + taxonomyApi.middleware, dataQualifierApi.middleware, dataSubjectsApi.middleware, dataUseApi.middleware diff --git a/clients/ctl/admin-ui/src/features/common/DataTabs.tsx b/clients/ctl/admin-ui/src/features/common/DataTabs.tsx index 486a8ad1d2a..5693d4610a1 100644 --- a/clients/ctl/admin-ui/src/features/common/DataTabs.tsx +++ b/clients/ctl/admin-ui/src/features/common/DataTabs.tsx @@ -8,7 +8,7 @@ import { } from "@fidesui/react"; import { ReactNode } from "react"; -interface TabData { +export interface TabData { label: string; content: ReactNode; } diff --git a/clients/ctl/admin-ui/src/features/config-wizard/PrivacyDeclarationForm.tsx b/clients/ctl/admin-ui/src/features/config-wizard/PrivacyDeclarationForm.tsx index 4cf977da0f3..d5e1f6adebb 100644 --- a/clients/ctl/admin-ui/src/features/config-wizard/PrivacyDeclarationForm.tsx +++ b/clients/ctl/admin-ui/src/features/config-wizard/PrivacyDeclarationForm.tsx @@ -43,7 +43,7 @@ import { selectDataCategories, setDataCategories, useGetAllDataCategoriesQuery, -} from "~/features/taxonomy/data-categories.slice"; +} from "~/features/taxonomy/taxonomy.slice"; import { PrivacyDeclaration } from "~/types/api"; import { diff --git a/clients/ctl/admin-ui/src/features/data-qualifier/data-qualifier.slice.ts b/clients/ctl/admin-ui/src/features/data-qualifier/data-qualifier.slice.ts index 7e0e17a2ae6..5338ed8c8ec 100644 --- a/clients/ctl/admin-ui/src/features/data-qualifier/data-qualifier.slice.ts +++ b/clients/ctl/admin-ui/src/features/data-qualifier/data-qualifier.slice.ts @@ -38,6 +38,14 @@ export const dataQualifierApi = createApi({ }), invalidatesTags: ["Data Qualifiers"], }), + createDataQualifier: build.mutation({ + query: (dataQualifier) => ({ + url: `data_qualifier/`, + method: "POST", + body: dataQualifier, + }), + invalidatesTags: ["Data Qualifiers"], + }), deleteDataQualifier: build.mutation({ query: (key) => ({ url: `data_qualifier/${key}`, @@ -52,6 +60,7 @@ export const dataQualifierApi = createApi({ export const { useGetAllDataQualifiersQuery, useUpdateDataQualifierMutation, + useCreateDataQualifierMutation, useDeleteDataQualifierMutation, } = dataQualifierApi; diff --git a/clients/ctl/admin-ui/src/features/data-subjects/data-subject.slice.ts b/clients/ctl/admin-ui/src/features/data-subjects/data-subject.slice.ts index ec780a3be70..0f0686cca3c 100644 --- a/clients/ctl/admin-ui/src/features/data-subjects/data-subject.slice.ts +++ b/clients/ctl/admin-ui/src/features/data-subjects/data-subject.slice.ts @@ -37,6 +37,14 @@ export const dataSubjectsApi = createApi({ }), invalidatesTags: ["Data Subjects"], }), + createDataSubject: build.mutation({ + query: (dataSubject) => ({ + url: `data_subject/`, + method: "POST", + body: dataSubject, + }), + invalidatesTags: ["Data Subjects"], + }), deleteDataSubject: build.mutation({ query: (key) => ({ url: `data_subject/${key}`, @@ -51,6 +59,7 @@ export const dataSubjectsApi = createApi({ export const { useGetAllDataSubjectsQuery, useUpdateDataSubjectMutation, + useCreateDataSubjectMutation, useDeleteDataSubjectMutation, } = dataSubjectsApi; diff --git a/clients/ctl/admin-ui/src/features/data-use/data-use.slice.ts b/clients/ctl/admin-ui/src/features/data-use/data-use.slice.ts index fb96f00d3f5..d67e53501a6 100644 --- a/clients/ctl/admin-ui/src/features/data-use/data-use.slice.ts +++ b/clients/ctl/admin-ui/src/features/data-use/data-use.slice.ts @@ -37,6 +37,14 @@ export const dataUseApi = createApi({ }), invalidatesTags: ["Data Uses"], }), + createDataUse: build.mutation({ + query: (dataUse) => ({ + url: `data_use/`, + method: "POST", + body: dataUse, + }), + invalidatesTags: ["Data Uses"], + }), deleteDataUse: build.mutation({ query: (key) => ({ url: `data_use/${key}`, @@ -51,6 +59,7 @@ export const dataUseApi = createApi({ export const { useGetAllDataUsesQuery, useUpdateDataUseMutation, + useCreateDataUseMutation, useDeleteDataUseMutation, } = dataUseApi; diff --git a/clients/ctl/admin-ui/src/features/dataset/DatasetCollectionView.tsx b/clients/ctl/admin-ui/src/features/dataset/DatasetCollectionView.tsx index 79e6174ee99..b2ed2f04ce2 100644 --- a/clients/ctl/admin-ui/src/features/dataset/DatasetCollectionView.tsx +++ b/clients/ctl/admin-ui/src/features/dataset/DatasetCollectionView.tsx @@ -6,7 +6,7 @@ import { useDispatch, useSelector } from "react-redux"; import { setDataCategories, useGetAllDataCategoriesQuery, -} from "~/features/taxonomy/data-categories.slice"; +} from "~/features/taxonomy/taxonomy.slice"; import { successToastParams } from "../common/toast"; import ColumnDropdown from "./ColumnDropdown"; diff --git a/clients/ctl/admin-ui/src/features/dataset/EditCollectionOrFieldForm.tsx b/clients/ctl/admin-ui/src/features/dataset/EditCollectionOrFieldForm.tsx index 63049ae968b..abb28070f80 100644 --- a/clients/ctl/admin-ui/src/features/dataset/EditCollectionOrFieldForm.tsx +++ b/clients/ctl/admin-ui/src/features/dataset/EditCollectionOrFieldForm.tsx @@ -3,7 +3,7 @@ import { Form, Formik } from "formik"; import { useState } from "react"; import { useSelector } from "react-redux"; -import { selectDataCategories } from "~/features/taxonomy/data-categories.slice"; +import { selectDataCategories } from "~/features/taxonomy/taxonomy.slice"; import { DatasetCollection, DatasetField } from "~/types/api"; import { CustomSelect, CustomTextInput } from "../common/form/inputs"; diff --git a/clients/ctl/admin-ui/src/features/dataset/EditDatasetForm.tsx b/clients/ctl/admin-ui/src/features/dataset/EditDatasetForm.tsx index 9d3632b45be..f49d87dcf11 100644 --- a/clients/ctl/admin-ui/src/features/dataset/EditDatasetForm.tsx +++ b/clients/ctl/admin-ui/src/features/dataset/EditDatasetForm.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { useSelector } from "react-redux"; import { COUNTRY_OPTIONS } from "~/features/common/countries"; -import { selectDataCategories } from "~/features/taxonomy/data-categories.slice"; +import { selectDataCategories } from "~/features/taxonomy/taxonomy.slice"; import { Dataset } from "~/types/api"; import { diff --git a/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyFormBase.tsx b/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyFormBase.tsx index 7794e9c13e1..92ec30aa2da 100644 --- a/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyFormBase.tsx +++ b/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyFormBase.tsx @@ -5,18 +5,21 @@ import { FormLabel, Grid, Heading, + Input, Stack, Text, useToast, } from "@fidesui/react"; import { Form, Formik } from "formik"; import { ReactNode, useState } from "react"; +import * as Yup from "yup"; import { CustomTextArea, CustomTextInput } from "~/features/common/form/inputs"; import { isErrorResult, parseError } from "~/features/common/helpers"; import { successToastParams } from "~/features/common/toast"; import { RTKErrorResult } from "~/types/errors"; +import { parentKeyFromFidesKey } from "./helpers"; import TaxonomyEntityTag from "./TaxonomyEntityTag"; import { Labels, RTKResult, TaxonomyEntity } from "./types"; @@ -24,23 +27,25 @@ export type FormValues = Partial & Pick; interface Props { - entity: TaxonomyEntity; labels: Labels; onCancel: () => void; - onEdit: (entity: TaxonomyEntity) => RTKResult; + onSubmit: (entity: TaxonomyEntity) => RTKResult; extraFormFields?: ReactNode; initialValues: FormValues; } -const EditTaxonomyForm = ({ - entity, +const TaxonomyFormBase = ({ labels, onCancel, - onEdit, + onSubmit, extraFormFields, initialValues, }: Props) => { const toast = useToast(); const [formError, setFormError] = useState(null); + const ValidationSchema = Yup.object().shape({ + fides_key: Yup.string().required().label(labels.fides_key), + }); + const isCreate = initialValues.fides_key === ""; const handleError = (error: RTKErrorResult["error"]) => { const parsedError = parseError(error); @@ -50,46 +55,91 @@ const EditTaxonomyForm = ({ const handleSubmit = async (newValues: FormValues) => { setFormError(null); // parent_key and fides_keys are immutable - const payload = { - ...newValues, - parent_key: entity.parent_key, - fides_key: entity.fides_key, - }; - const result = await onEdit(payload); + // parent_key also needs to be undefined, not an empty string, if there is no parent element + let payload: TaxonomyEntity; + if (isCreate) { + const parentKey = parentKeyFromFidesKey(newValues.fides_key); + payload = { + ...newValues, + parent_key: parentKey === "" ? undefined : parentKey, + }; + } else { + payload = { + ...newValues, + parent_key: + initialValues.parent_key === "" + ? undefined + : initialValues.parent_key, + fides_key: initialValues.fides_key, + }; + } + + const result = await onSubmit(payload); if (isErrorResult(result)) { handleError(result.error); } else { - toast(successToastParams("Taxonomy successfully updated")); + toast( + successToastParams( + `Taxonomy successfully ${isCreate ? "created" : "updated"}` + ) + ); + if (isCreate) { + onCancel(); + } } }; return ( - - - Modify {labels.fides_key} + + + {isCreate ? "Create" : "Modify"} {labels.fides_key} - {({ dirty }) => ( + {({ dirty, values }) => (
- - {labels.fides_key} - - - - + {isCreate ? ( + + ) : ( + + {labels.fides_key} + + + + + )} - + {labels.parent_key && + (isCreate ? ( + + {labels.parent_key} + + + + + ) : ( + + ))} {extraFormFields} @@ -108,12 +158,12 @@ const EditTaxonomyForm = ({ Cancel
@@ -123,4 +173,4 @@ const EditTaxonomyForm = ({ ); }; -export default EditTaxonomyForm; +export default TaxonomyFormBase; diff --git a/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyTabContent.tsx b/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyTabContent.tsx index 5586c1b5bc0..573635eee2e 100644 --- a/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyTabContent.tsx +++ b/clients/ctl/admin-ui/src/features/taxonomy/TaxonomyTabContent.tsx @@ -6,8 +6,9 @@ import { useDisclosure, useToast, } from "@fidesui/react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useAppDispatch, useAppSelector } from "~/app/hooks"; import AccordionTree from "~/features/common/AccordionTree"; import ConfirmationModal from "~/features/common/ConfirmationModal"; import { getErrorMessage } from "~/features/common/helpers"; @@ -17,6 +18,7 @@ import { isErrorResult } from "~/types/errors"; import ActionButtons from "./ActionButtons"; import { transformTaxonomyEntityToNodes } from "./helpers"; import { TaxonomyHookData } from "./hooks"; +import { selectIsAddFormOpen, setIsAddFormOpen } from "./taxonomy.slice"; import TaxonomyFormBase from "./TaxonomyFormBase"; import { TaxonomyEntity, TaxonomyEntityNode } from "./types"; @@ -24,11 +26,20 @@ interface Props { useTaxonomy: () => TaxonomyHookData; } +const DEFAULT_INITIAL_VALUES: TaxonomyEntity = { + fides_key: "", + parent_key: "", + name: "", + description: "", +}; + const TaxonomyTabContent = ({ useTaxonomy }: Props) => { + const dispatch = useAppDispatch(); const { isLoading, data, labels, + handleCreate: createEntity, handleEdit, handleDelete: deleteEntity, extraFormFields, @@ -42,6 +53,20 @@ const TaxonomyTabContent = ({ useTaxonomy }: Props) => { }, [data]); const [editEntity, setEditEntity] = useState(null); + + const isAdding = useAppSelector(selectIsAddFormOpen); + + useEffect(() => { + // prevent both the add and edit forms being opened at once + if (isAdding) { + setEditEntity(null); + } + }, [isAdding]); + + const closeAddForm = () => { + dispatch(setIsAddFormOpen(false)); + }; + const [deleteKey, setDeleteKey] = useState(null); const { @@ -65,6 +90,9 @@ const TaxonomyTabContent = ({ useTaxonomy }: Props) => { const taxonomyType = labels.fides_key.toLocaleLowerCase(); const handleSetEditEntity = (node: TaxonomyEntityNode) => { + if (isAdding) { + closeAddForm(); + } const entity = data?.find((d) => d.fides_key === node.value) ?? null; setEditEntity(entity); }; @@ -104,13 +132,23 @@ const TaxonomyTabContent = ({ useTaxonomy }: Props) => { {editEntity ? ( setEditEntity(null)} - onEdit={handleEdit} + onSubmit={handleEdit} extraFormFields={extraFormFields} initialValues={transformEntityToInitialValues(editEntity)} /> ) : null} + {isAdding ? ( + + ) : null} , @@ -27,10 +30,34 @@ const TABS = [ content: , }, ]; -const TaxonomyTabs = () => ( - - - -); +const TaxonomyTabs = () => { + const dispatch = useAppDispatch(); + + const handleAddEntity = () => { + dispatch(setIsAddFormOpen(true)); + }; + + return ( + + + + + + + ); +}; export default TaxonomyTabs; diff --git a/clients/ctl/admin-ui/src/features/taxonomy/helpers.ts b/clients/ctl/admin-ui/src/features/taxonomy/helpers.ts index f4d645a33b1..d850b019677 100644 --- a/clients/ctl/admin-ui/src/features/taxonomy/helpers.ts +++ b/clients/ctl/admin-ui/src/features/taxonomy/helpers.ts @@ -20,10 +20,21 @@ export const transformTaxonomyEntityToNodes = ( const thisLevelKey = thisLevelEntity.fides_key; return { value: thisLevelEntity.fides_key, - label: thisLevelEntity.name ?? thisLevelEntity.fides_key, + label: + thisLevelEntity.name === "" || thisLevelEntity.name == null + ? thisLevelEntity.fides_key + : thisLevelEntity.name, description: thisLevelEntity.description, children: transformTaxonomyEntityToNodes(entities, thisLevelKey), }; }); return nodes; }; + +export const parentKeyFromFidesKey = (fidesKey: string) => { + const split = fidesKey.split("."); + if (split.length === 1) { + return ""; + } + return split.slice(0, split.length - 1).join("."); +}; diff --git a/clients/ctl/admin-ui/src/features/taxonomy/hooks.tsx b/clients/ctl/admin-ui/src/features/taxonomy/hooks.tsx index c7e05ad3bcc..e504c1c66b7 100644 --- a/clients/ctl/admin-ui/src/features/taxonomy/hooks.tsx +++ b/clients/ctl/admin-ui/src/features/taxonomy/hooks.tsx @@ -18,25 +18,29 @@ import { CustomTextInput, } from "../common/form/inputs"; import { + useCreateDataQualifierMutation, useDeleteDataQualifierMutation, useGetAllDataQualifiersQuery, useUpdateDataQualifierMutation, } from "../data-qualifier/data-qualifier.slice"; import { + useCreateDataSubjectMutation, useDeleteDataSubjectMutation, useGetAllDataSubjectsQuery, useUpdateDataSubjectMutation, } from "../data-subjects/data-subject.slice"; import { + useCreateDataUseMutation, useDeleteDataUseMutation, useGetAllDataUsesQuery, useUpdateDataUseMutation, } from "../data-use/data-use.slice"; import { + useCreateDataCategoryMutation, useDeleteDataCategoryMutation, useGetAllDataCategoriesQuery, useUpdateDataCategoryMutation, -} from "./data-categories.slice"; +} from "./taxonomy.slice"; import type { FormValues } from "./TaxonomyFormBase"; import { Labels, RTKResult, TaxonomyEntity } from "./types"; @@ -44,6 +48,7 @@ export interface TaxonomyHookData { data?: TaxonomyEntity[]; isLoading: boolean; labels: Labels; + handleCreate: (entity: T) => RTKResult; handleEdit: (entity: T) => RTKResult; handleDelete: (key: string) => RTKResult; extraFormFields?: ReactNode; @@ -74,15 +79,17 @@ export const useDataCategory = (): TaxonomyHookData => { parent_key: "Parent category", }; - const [editDataCategory] = useUpdateDataCategoryMutation(); - const [deleteDataCategory] = useDeleteDataCategoryMutation(); + const [handleEdit] = useUpdateDataCategoryMutation(); + const [handleDelete] = useDeleteDataCategoryMutation(); + const [handleCreate] = useCreateDataCategoryMutation(); return { data, isLoading, labels, - handleEdit: editDataCategory, - handleDelete: deleteDataCategory, + handleCreate, + handleEdit, + handleDelete, transformEntityToInitialValues: transformTaxonomyBaseToInitialValues, }; }; @@ -104,15 +111,24 @@ export const useDataUse = (): TaxonomyHookData => { }; const [edit] = useUpdateDataUseMutation(); + const [create] = useCreateDataUseMutation(); + + const transformFormValuesToEntity = (formValues: DataUse) => ({ + ...formValues, + // text inputs don't like having undefined, so we originally converted + // to "", but on submission we need to convert back to undefined + legitimate_interest_impact_assessment: + formValues.legitimate_interest_impact_assessment === "" + ? undefined + : formValues.legitimate_interest_impact_assessment, + }); + const [handleDelete] = useDeleteDataUseMutation(); const handleEdit = (entity: DataUse) => - edit({ - ...entity, - // text inputs don't like having undefined, so we originally converted - // to "", but on submission we need to convert back to undefined - legitimate_interest_impact_assessment: - entity.legitimate_interest_impact_assessment ?? undefined, - }); + edit(transformFormValuesToEntity(entity)); + + const handleCreate = (entity: DataUse) => + create(transformFormValuesToEntity(entity)); const transformEntityToInitialValues = (du: DataUse) => { const base = transformTaxonomyBaseToInitialValues(du); @@ -160,6 +176,7 @@ export const useDataUse = (): TaxonomyHookData => { data, isLoading, labels, + handleCreate, handleEdit, handleDelete, extraFormFields, @@ -174,16 +191,16 @@ export const useDataSubject = (): TaxonomyHookData => { fides_key: "Data subject", name: "Data subject name", description: "Data subject description", - parent_key: "Parent data subject", rights: "Rights", strategy: "Strategy", automatic_decisions: "Automatic decisions or profiling", }; const [edit] = useUpdateDataSubjectMutation(); + const [create] = useCreateDataSubjectMutation(); const [handleDelete] = useDeleteDataSubjectMutation(); - const handleEdit = (entity: TaxonomyEntity) => { + const transformFormValuesToEntity = (entity: TaxonomyEntity) => { const transformedEntity = { ...entity, // @ts-ignore because DataSubjects have their rights field nested, which @@ -195,9 +212,15 @@ export const useDataSubject = (): TaxonomyHookData => { }; // @ts-ignore for the same reason as above delete transformedEntity.strategy; - return edit(transformedEntity); + return transformedEntity; }; + const handleEdit = (entity: TaxonomyEntity) => + edit(transformFormValuesToEntity(entity)); + + const handleCreate = (entity: TaxonomyEntity) => + create(transformFormValuesToEntity(entity)); + const transformEntityToInitialValues = (ds: DataSubject) => { const base = transformTaxonomyBaseToInitialValues(ds); return { @@ -228,6 +251,7 @@ export const useDataSubject = (): TaxonomyHookData => { data, isLoading, labels, + handleCreate, handleEdit, handleDelete, extraFormFields, @@ -245,6 +269,7 @@ export const useDataQualifier = (): TaxonomyHookData => { parent_key: "Parent data qualifier", }; + const [handleCreate] = useCreateDataQualifierMutation(); const [handleEdit] = useUpdateDataQualifierMutation(); const [handleDelete] = useDeleteDataQualifierMutation(); @@ -252,6 +277,7 @@ export const useDataQualifier = (): TaxonomyHookData => { data, isLoading, labels, + handleCreate, handleEdit, handleDelete, transformEntityToInitialValues: transformTaxonomyBaseToInitialValues, diff --git a/clients/ctl/admin-ui/src/features/taxonomy/index.ts b/clients/ctl/admin-ui/src/features/taxonomy/index.ts index dd523037ec4..88a353bd58a 100644 --- a/clients/ctl/admin-ui/src/features/taxonomy/index.ts +++ b/clients/ctl/admin-ui/src/features/taxonomy/index.ts @@ -1 +1 @@ -export * from "./data-categories.slice"; +export * from "./taxonomy.slice"; diff --git a/clients/ctl/admin-ui/src/features/taxonomy/data-categories.slice.ts b/clients/ctl/admin-ui/src/features/taxonomy/taxonomy.slice.ts similarity index 67% rename from clients/ctl/admin-ui/src/features/taxonomy/data-categories.slice.ts rename to clients/ctl/admin-ui/src/features/taxonomy/taxonomy.slice.ts index e924ccfcbf8..0fb0447de28 100644 --- a/clients/ctl/admin-ui/src/features/taxonomy/data-categories.slice.ts +++ b/clients/ctl/admin-ui/src/features/taxonomy/taxonomy.slice.ts @@ -6,14 +6,16 @@ import { DataCategory } from "~/types/api"; export interface State { dataCategories: DataCategory[]; + isAddFormOpen: boolean; } const initialState: State = { dataCategories: [], + isAddFormOpen: false, }; -export const dataCategoriesApi = createApi({ - reducerPath: "dataCategoriesApi", +export const taxonomyApi = createApi({ + reducerPath: "taxonomyApi", baseQuery: fetchBaseQuery({ baseUrl: process.env.NEXT_PUBLIC_FIDESCTL_API, }), @@ -37,6 +39,16 @@ export const dataCategoriesApi = createApi({ }), invalidatesTags: ["Data Categories"], }), + + createDataCategory: build.mutation({ + query: (dataCategory) => ({ + url: `data_category/`, + method: "POST", + body: dataCategory, + }), + invalidatesTags: ["Data Categories"], + }), + deleteDataCategory: build.mutation({ query: (key) => ({ url: `data_category/${key}`, @@ -52,21 +64,28 @@ export const { useGetAllDataCategoriesQuery, useUpdateDataCategoryMutation, useDeleteDataCategoryMutation, -} = dataCategoriesApi; + useCreateDataCategoryMutation, +} = taxonomyApi; -export const dataCategoriesSlice = createSlice({ - name: "dataCategories", +export const taxonomySlice = createSlice({ + name: "taxonomy", initialState, reducers: { setDataCategories: (state, action: PayloadAction) => ({ ...state, dataCategories: action.payload, }), + setIsAddFormOpen: (state, action: PayloadAction) => ({ + ...state, + isAddFormOpen: action.payload, + }), }, }); -export const { setDataCategories } = dataCategoriesSlice.actions; +export const { setDataCategories, setIsAddFormOpen } = taxonomySlice.actions; export const selectDataCategories = (state: AppState) => - state.dataCategories.dataCategories; + state.taxonomy.dataCategories; +export const selectIsAddFormOpen = (state: AppState) => + state.taxonomy.isAddFormOpen; -export const { reducer } = dataCategoriesSlice; +export const { reducer } = taxonomySlice; diff --git a/clients/ctl/admin-ui/src/features/taxonomy/types.ts b/clients/ctl/admin-ui/src/features/taxonomy/types.ts index 23e803e23e3..14f498610a7 100644 --- a/clients/ctl/admin-ui/src/features/taxonomy/types.ts +++ b/clients/ctl/admin-ui/src/features/taxonomy/types.ts @@ -19,7 +19,7 @@ export interface Labels { fides_key: string; name: string; description: string; - parent_key: string; + parent_key?: string; } export type RTKResult = Promise<