Skip to content

Commit

Permalink
System form/vendor selector fixes (#4420)
Browse files Browse the repository at this point in the history
jpople committed Nov 13, 2023
1 parent 4af6308 commit 4c3f4f2
Showing 6 changed files with 260 additions and 128 deletions.
12 changes: 0 additions & 12 deletions clients/admin-ui/cypress/e2e/systems-plus.cy.ts
Original file line number Diff line number Diff line change
@@ -45,16 +45,6 @@ describe("System management with Plus features", () => {
);
});

it("can switch entries", () => {
cy.getSelectValueContainer("input-vendor_id").type("Aniview{enter}");
cy.getSelectValueContainer("input-vendor_id").contains("Aniview LTD");

cy.getSelectValueContainer("input-vendor_id").type("Anzu{enter}");
cy.getSelectValueContainer("input-vendor_id").contains(
"Anzu Virtual Reality LTD"
);
});

it("locks editing for a GVL vendor when TCF is enabled", () => {
cy.getSelectValueContainer("input-vendor_id").type("Aniview{enter}");
cy.getByTestId("locked-for-GVL-notice");
@@ -66,8 +56,6 @@ describe("System management with Plus features", () => {
it("can switch between tabs after populating from dictionary", () => {
cy.wait("@getSystems");
cy.getSelectValueContainer("input-vendor_id").type("Anzu{enter}");
cy.getByTestId("dict-suggestions-btn").click();
cy.getByTestId("toggle-dict-suggestions").click();
// the form fetches the system again after saving, so update the intercept with dictionary values
cy.fixture("systems/dictionary-system.json").then((dictSystem) => {
cy.fixture("systems/system.json").then((origSystem) => {
60 changes: 42 additions & 18 deletions clients/admin-ui/src/features/system/SystemInformationForm.tsx
Original file line number Diff line number Diff line change
@@ -20,7 +20,6 @@ import {
} from "~/features/common/custom-fields";
import { useFeatures } from "~/features/common/features/features.slice";
import {
CustomCreatableSelect,
CustomSelect,
CustomSwitch,
CustomTextInput,
@@ -29,6 +28,7 @@ import {
extractVendorSource,
getErrorMessage,
isErrorResult,
isFetchBaseQueryError,
VendorSources,
} from "~/features/common/helpers";
import { FormGuard } from "~/features/common/hooks/useIsAnyFormDirty";
@@ -43,6 +43,7 @@ import {
setSuggestions,
} from "~/features/system/dictionary-form/dict-suggestion.slice";
import {
DictSuggestionCreatableSelect,
DictSuggestionNumberInput,
DictSuggestionSelect,
DictSuggestionSwitch,
@@ -72,11 +73,6 @@ import {
responsibilityOptions,
} from "./SystemInformationFormSelectOptions";

const ValidationSchema = Yup.object().shape({
name: Yup.string().required().label("System name"),
privacy_policy: Yup.string().min(1).url().nullable(),
});

const SystemHeading = ({ system }: { system?: SystemResponse }) => {
const isManual = !system;
const headingName = isManual
@@ -103,6 +99,8 @@ const SystemInformationForm = ({
withHeader,
children,
}: Props) => {
const systems = useAppSelector(selectAllSystems);

const dispatch = useAppDispatch();
const customFields = useCustomFields({
resourceType: ResourceTypes.SYSTEM,
@@ -125,6 +123,23 @@ const SystemInformationForm = ({
[passedInSystem, customFields.customFieldValues]
);

const ValidationSchema = useMemo(
() =>
Yup.object().shape({
name: Yup.string()
.required()
.label("System name")
.notOneOf(
systems
.filter((s) => s.name !== initialValues.name)
.map((s) => s.name),
"System must have a unique name"
),
privacy_policy: Yup.string().min(1).url().nullable(),
}),
[systems, initialValues.name]
);

const features = useFeatures();

const [createSystemMutationTrigger, createSystemMutationResult] =
@@ -139,7 +154,6 @@ const SystemInformationForm = ({
const dictionaryOptions = useAppSelector(selectAllDictEntries);
const lockedForGVL = useAppSelector(selectLockedForGVL);

const systems = useAppSelector(selectAllSystems);
const isEditing = useMemo(
() =>
Boolean(
@@ -167,16 +181,21 @@ const SystemInformationForm = ({
formikHelpers: FormikHelpers<FormValues>
) => {
let dictionaryDeclarations;
if (lockedForGVL && values.privacy_declarations.length === 0) {
if (values.vendor_id && values.privacy_declarations.length === 0) {
const dataUseQueryResult = await getDictionaryDataUseTrigger({
vendor_id: values.vendor_id!,
});
if (dataUseQueryResult.isError) {
const dataUseErrorMsg = getErrorMessage(
dataUseQueryResult.error,
`A problem occurred while fetching data uses from the GVL for your system. Please try again.`
);
toast({ status: "error", description: dataUseErrorMsg });
const isNotFoundError =
isFetchBaseQueryError(dataUseQueryResult.error) &&
dataUseQueryResult.error.status === 404;
if (!isNotFoundError) {
const dataUseErrorMsg = getErrorMessage(
dataUseQueryResult.error,
`A problem occurred while fetching data uses from Fides Compass for your system. Please try again.`
);
toast({ status: "error", description: dataUseErrorMsg });
}
} else if (
dataUseQueryResult.data &&
dataUseQueryResult.data.items.length > 0
@@ -232,12 +251,17 @@ const SystemInformationForm = ({
handleResult(result);
};

const handleVendorSelected = (newVendorId: string) => {
const handleVendorSelected = (newVendorId: string | undefined) => {
if (!newVendorId) {
dispatch(setSuggestions("hiding"));
dispatch(setLockedForGVL(false));
return;
}
dispatch(setSuggestions("showing"));
if (
features.tcf &&
extractVendorSource(newVendorId) === VendorSources.GVL
) {
dispatch(setSuggestions("showing"));
dispatch(setLockedForGVL(true));
} else {
dispatch(setLockedForGVL(false));
@@ -275,6 +299,7 @@ const SystemInformationForm = ({
<VendorSelector
options={dictionaryOptions}
onVendorSelected={handleVendorSelected}
disabled={!!passedInSystem && lockedForGVL}
/>
) : null}
<DictSuggestionTextInput
@@ -303,11 +328,10 @@ const SystemInformationForm = ({
tooltip="What services does this system perform?"
disabled={lockedForGVL}
/>
<CustomCreatableSelect
<DictSuggestionCreatableSelect
id="tags"
name="tags"
label="System Tags"
variant="stacked"
options={
initialValues.tags
? initialValues.tags.map((s) => ({
@@ -318,7 +342,7 @@ const SystemInformationForm = ({
}
tooltip="Are there any tags to associate with this system?"
isMulti
isDisabled={lockedForGVL}
disabled={lockedForGVL}
/>
</SystemFormInputGroup>
<SystemFormInputGroup heading="Dataset reference">
51 changes: 44 additions & 7 deletions clients/admin-ui/src/features/system/VendorSelector.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,47 @@
import { Flex, FormControl, HStack, Text, VStack } from "@fidesui/react";
import { Select, SingleValue } from "chakra-react-select";
import {
ActionMeta,
chakraComponents,
GroupBase,
OptionProps,
Select,
SingleValue,
} from "chakra-react-select";
import { useField, useFormikContext } from "formik";
import { useState } from "react";

import { ErrorMessage, Label, Option } from "~/features/common/form/inputs";
import QuestionTooltip from "~/features/common/QuestionTooltip";
import { DictOption } from "~/features/plus/plus.slice";
import { DictSuggestionToggle } from "~/features/system/dictionary-form/ToggleDictSuggestions";

interface Props {
disabled?: boolean;
options: DictOption[];
onVendorSelected: (vendorId: string) => void;
onVendorSelected: (vendorId: string | undefined) => void;
}

const CustomDictOption: React.FC<
OptionProps<Option, false, GroupBase<Option>>
> = ({ children, ...props }) => (
<chakraComponents.Option {...props} type="option">
<Flex flexDirection="column" padding={2}>
<Text color="gray.700" fontSize="14px" lineHeight={5} fontWeight="medium">
{props.data.label}
</Text>

{props.data.description ? (
<Text
color="gray.500"
fontSize="12px"
lineHeight={4}
fontWeight="normal"
>
{props.data.description}
</Text>
) : null}
</Flex>
</chakraComponents.Option>
);
const VendorSelector = ({ disabled, options, onVendorSelected }: Props) => {
const [initialField, meta, { setValue }] = useField({ name: "vendor_id" });
const isInvalid = !!(meta.touched && meta.error);
@@ -26,17 +54,25 @@ const VendorSelector = ({ disabled, options, onVendorSelected }: Props) => {
opt.label.toLowerCase().startsWith(searchParam.toLowerCase())
);

const selected = options.find((o) => o.value === field.value);

const handleTabPressed = () => {
if (suggestions.length > 0 && searchParam !== suggestions[0].label) {
setSearchParam(suggestions[0].label);
setValue(suggestions[0].value);
}
};

const handleChange = (newValue: SingleValue<Option>) => {
const handleChange = (
newValue: SingleValue<Option>,
actionMeta: ActionMeta<Option>
) => {
if (newValue) {
setValue(newValue.value);
onVendorSelected(newValue.value);
} else if (actionMeta.action === "clear") {
setValue("");
onVendorSelected(undefined);
}
};

@@ -45,7 +81,7 @@ const VendorSelector = ({ disabled, options, onVendorSelected }: Props) => {
<FormControl isInvalid={isInvalid}>
<VStack alignItems="start" position="relative">
<Flex alignItems="center">
<Label htmlFor="vendor" fontSize="xs" my={0} mr={1}>
<Label htmlFor="vendor_id" fontSize="xs" my={0} mr={1}>
Vendor
</Label>
<QuestionTooltip label="Enter the vendor to associate with the system" />
@@ -57,8 +93,9 @@ const VendorSelector = ({ disabled, options, onVendorSelected }: Props) => {
>
<Select
options={suggestions}
value={selected}
onBlur={(e) => {
setTouched({ ...touched, test_vendor: true });
setTouched({ ...touched, vendor_id: true });
field.onBlur(e);
}}
onChange={handleChange}
@@ -102,6 +139,7 @@ const VendorSelector = ({ disabled, options, onVendorSelected }: Props) => {
display: "none",
}),
}}
components={{ Option: CustomDictOption }}
/>
<Text
aria-hidden
@@ -131,7 +169,6 @@ const VendorSelector = ({ disabled, options, onVendorSelected }: Props) => {
/>
</VStack>
</FormControl>
<DictSuggestionToggle />
</HStack>
);
};
Original file line number Diff line number Diff line change
@@ -1,99 +1,51 @@
import { AddIcon, WarningTwoIcon } from "@chakra-ui/icons";
import { Box, Button, HStack, Stack, Text, Tooltip } from "@fidesui/react";
import { ReactNode, useMemo } from "react";

import { SparkleIcon } from "../../common/Icon/SparkleIcon";
import { Box, Button, HStack, Stack, Text } from "@fidesui/react";
import { ReactNode } from "react";

type Props = {
title: string;
description: string | ReactNode;
dictAvailable: boolean;
handleAdd: () => void;
handleDictSuggestion: () => void;
vendorSelected: boolean;
};

const EmptyTableState = ({
title,
description,
dictAvailable = false,
handleAdd,
handleDictSuggestion,
vendorSelected,
}: Props) => {
const dictDisabledTooltip = useMemo(
() =>
"You will need to select a vendor for this system before you can use the Fides dictionary. You can do this on System Information tab above.",
[]
);

return (
<Stack
backgroundColor="gray.50"
border="1px solid"
borderColor={dictAvailable ? "purple.400" : "blue.500"}
borderRadius="md"
justifyContent="space-between"
py={4}
px={6}
data-testid="empty-state"
>
<HStack>
{dictAvailable ? (
<SparkleIcon alignSelf="start" color="purple.400" mt={0.5} />
) : (
<WarningTwoIcon alignSelf="start" color="blue.400" mt={0.5} />
)}
const EmptyTableState = ({ title, description, handleAdd }: Props) => (
<Stack
backgroundColor="gray.50"
border="1px solid"
borderColor="blue.500"
borderRadius="md"
justifyContent="space-between"
py={4}
px={6}
data-testid="empty-state"
>
<HStack>
<WarningTwoIcon alignSelf="start" color="blue.400" mt={0.5} />

<Box>
<Text fontWeight="bold" fontSize="sm" mb={1}>
{title}
</Text>
<Box>
<Text fontWeight="bold" fontSize="sm" mb={1}>
{title}
</Text>

<Text fontSize="sm" color="gray.600" lineHeight="5">
{description}
</Text>
<HStack mt={4}>
{dictAvailable ? (
<>
<Tooltip
hasArrow
placement="top"
label={dictDisabledTooltip}
isDisabled={vendorSelected}
shouldWrapChildren
>
<Button
size="xs"
colorScheme="purple"
fontWeight="semibold"
data-testid="dict-btn"
onClick={handleDictSuggestion}
rightIcon={<SparkleIcon />}
disabled={!vendorSelected}
>
Generate data uses automatically
</Button>
</Tooltip>
<Text size="sm">or</Text>
</>
) : null}
<Button
size="xs"
colorScheme="black"
backgroundColor="primary.800"
fontWeight="semibold"
data-testid="add-btn"
onClick={handleAdd}
rightIcon={<AddIcon boxSize={2} />}
>
Add data use
</Button>
</HStack>
</Box>
</HStack>
</Stack>
);
};
<Text fontSize="sm" color="gray.600" lineHeight="5">
{description}
</Text>
<HStack mt={4}>
<Button
size="xs"
colorScheme="black"
backgroundColor="primary.800"
fontWeight="semibold"
data-testid="add-btn"
onClick={handleAdd}
rightIcon={<AddIcon boxSize={2} />}
>
Add data use
</Button>
</HStack>
</Box>
</HStack>
</Stack>
);

export default EmptyTableState;
Original file line number Diff line number Diff line change
@@ -12,7 +12,12 @@ import {
Textarea,
VStack,
} from "@fidesui/react";
import { MultiValue, Select, SingleValue } from "chakra-react-select";
import {
CreatableSelect,
MultiValue,
Select,
SingleValue,
} from "chakra-react-select";
import { useField, useFormikContext } from "formik";
import React, { useEffect, useRef, useState } from "react";

@@ -373,6 +378,135 @@ export const DictSuggestionSelect = ({
);
};

export const DictSuggestionCreatableSelect = ({
label,
tooltip,
disabled,
isRequired = false,
dictField,
name,
placeholder,
id,
options,
isMulti = false,
}: SelectProps) => {
const { field, isInvalid, isShowingSuggestions, error } = useDictSuggestion(
name,
dictField
);

const selected =
field.value.length > 0
? field.value.map(
(fieldValue: string) =>
options.find((o) => o.value === fieldValue) ?? {
value: fieldValue,
label: fieldValue,
}
)
: [];

const { setFieldValue } = useFormikContext();

const handleChangeMulti = (newValue: MultiValue<SelectOption>) => {
setFieldValue(
field.name,
newValue.map((v) => v.value)
);
};

const handleChangeSingle = (newValue: SingleValue<SelectOption>) => {
setFieldValue(field.name, newValue);
};

const handleChange = (
newValue: MultiValue<SelectOption> | SingleValue<SelectOption>
) =>
isMulti
? handleChangeMulti(newValue as MultiValue<SelectOption>)
: handleChangeSingle(newValue as SingleValue<SelectOption>);

return (
<FormControl isInvalid={isInvalid} isRequired={isRequired}>
<VStack alignItems="start">
<Flex alignItems="center">
<Label htmlFor={id || name} fontSize="xs" my={0} mr={1}>
{label}
</Label>
{tooltip ? <QuestionTooltip label={tooltip} /> : null}
</Flex>
<Flex width="100%">
<CreatableSelect
{...field}
size="sm"
value={selected}
isDisabled={disabled}
isMulti={isMulti}
onChange={handleChange}
data-testid={`input-${field.name}`}
placeholder={placeholder}
options={options}
chakraStyles={{
input: (provided) => ({
...provided,
color:
isShowingSuggestions === "showing"
? "complimentary.500"
: "gray.800",
}),
container: (provided) => ({
...provided,
flexGrow: 1,
backgroundColor: "white",
}),
dropdownIndicator: (provided) => ({
...provided,
bg: "transparent",
px: 2,
cursor: "inherit",
}),
indicatorSeparator: (provided) => ({
...provided,
display: "none",
}),
multiValueLabel: (provided) => ({
...provided,
display: "flex",
height: "16px",
alignItems: "center",
}),
multiValue: (provided) => ({
...provided,
fontWeight: "400",
background: "gray.200",
color:
isShowingSuggestions === "showing"
? "complimentary.500"
: "gray.800",
borderRadius: "2px",
py: 1,
px: 2,
}),
multiValueRemove: (provided) => ({
...provided,
ml: 1,
size: "lg",
width: 3,
height: 3,
}),
}}
/>
</Flex>
<ErrorMessage
isInvalid={isInvalid}
message={error}
fieldName={field.name}
/>
</VStack>
</FormControl>
);
};

export const DictSuggestionNumberInput = ({
label,
tooltip,
Original file line number Diff line number Diff line change
@@ -232,10 +232,7 @@ const PrivacyDeclarationFormTab = ({
<EmptyTableState
title="You don't have a data use set up for this system yet."
description='A Data Use is the purpose for which data is used in a system. In Fides, a system may have more than one Data Use. For example, a CRM system may be used both for "Customer Support" and also for "Email Marketing", each of these is a Data Use.'
dictAvailable={features.dictionaryService}
handleAdd={handleOpenNewForm}
handleDictSuggestion={handleOpenDictModal}
vendorSelected={!!system.vendor_id}
/>
) : (
<PrivacyDeclarationDisplayGroup

0 comments on commit 4c3f4f2

Please sign in to comment.