Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Classify data category dropdown #1110

Merged
merged 17 commits into from
Oct 3, 2022
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ The types of changes are:
* Added toggle for enabling classify during generation. [#1057](https://github.com/ethyca/fides/pull/1057)
* Initial implementation of API request to kick off classify, with confirmation modal. [#1069](https://github.com/ethyca/fides/pull/1069)
* Initial Classification & Review status for generated datasets. [#1074](https://github.com/ethyca/fides/pull/1074)
* Component for choosing data categories based on classification results. [#1110](https://github.com/ethyca/fides/pull/1110)
* The dataset fields table shows data categories from the classifier (if available). [#1088](https://github.com/ethyca/fides/pull/1088)
* System management UI:
* New page to add a system via yaml [#1062](https://github.com/ethyca/fides/pull/1062)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import * as React from "react";

import ClassifiedDataCategoryDropdown from "~/features/dataset/ClassifiedDataCategoryDropdown";
import { MOCK_DATA_CATEGORIES } from "~/mocks/data";
import { DataCategory } from "~/types/api";

const CATEGORIES_WITH_CONFIDENCE = MOCK_DATA_CATEGORIES.map((dc) => ({
...dc,
confidence: Math.floor(Math.random() * 100),
}));
const MOST_LIKELY_CATEGORIES = CATEGORIES_WITH_CONFIDENCE.slice(0, 5);

describe("ClassifiedDataCategoryDropdown", () => {
it("can render checked categories", () => {
const onCheckedSpy = cy.spy().as("onCheckedSpy");
const selectedClassifiedCategory = MOST_LIKELY_CATEGORIES[0];
cy.mount(
<ClassifiedDataCategoryDropdown
dataCategories={MOCK_DATA_CATEGORIES as DataCategory[]}
ssangervasi marked this conversation as resolved.
Show resolved Hide resolved
// check one "most likely" and one regular one
checked={[
selectedClassifiedCategory.fides_key,
"system.authentication",
]}
onChecked={onCheckedSpy}
mostLikelyCategories={MOST_LIKELY_CATEGORIES}
/>
);

// check that the classified one is selected
cy.getByTestId("classified-select").should(
"contain",
selectedClassifiedCategory.fides_key
);
// check that the regular one is selected
cy.getByTestId("data-category-dropdown").click();
cy.get("[data-testid='checkbox-Authentication Data'] > span").should(
"have.attr",
"data-checked"
);
cy.get(
`[data-testid='checkbox-${selectedClassifiedCategory.name}'] > span`
).should("have.attr", "data-checked");
});

it("can select from classified select without overriding taxonomy dropdown", () => {
const onCheckedSpy = cy.spy().as("onCheckedSpy");
const toSelect = MOST_LIKELY_CATEGORIES[0];
cy.mount(
<ClassifiedDataCategoryDropdown
dataCategories={MOCK_DATA_CATEGORIES as DataCategory[]}
checked={["system.authentication"]}
onChecked={onCheckedSpy}
mostLikelyCategories={MOST_LIKELY_CATEGORIES}
/>
);
cy.getByTestId("classified-select")
.click()
.type(`${toSelect.fides_key}{enter}`);
cy.get("@onCheckedSpy").should("have.been.calledWith", [
"system.authentication",
toSelect.fides_key,
]);
});

it("can select from taxonomy dropdown without overriding classified select", () => {
const onCheckedSpy = cy.spy().as("onCheckedSpy");
const selectedClassifiedCategory = MOST_LIKELY_CATEGORIES[0];
cy.mount(
<ClassifiedDataCategoryDropdown
dataCategories={MOCK_DATA_CATEGORIES as DataCategory[]}
checked={[selectedClassifiedCategory.fides_key]}
onChecked={onCheckedSpy}
mostLikelyCategories={MOST_LIKELY_CATEGORIES}
/>
);
cy.getByTestId("data-category-dropdown").click();
cy.getByTestId("expand-System Data").click();
cy.getByTestId("checkbox-Authentication Data").click();
cy.get("@onCheckedSpy").should("have.been.calledWith", [
selectedClassifiedCategory.fides_key,
"system.authentication",
]);
});

it("can remove items from classified select", () => {
const onCheckedSpy = cy.spy().as("onCheckedSpy");
const selectedClassifiedCategory = MOST_LIKELY_CATEGORIES[0];
cy.mount(
<ClassifiedDataCategoryDropdown
dataCategories={MOCK_DATA_CATEGORIES as DataCategory[]}
checked={[
selectedClassifiedCategory.fides_key,
"system.authentication",
]}
onChecked={onCheckedSpy}
mostLikelyCategories={MOST_LIKELY_CATEGORIES}
/>
);
// delete the selected category
cy.getByTestId("classified-select").click().type("{backspace}");
cy.get("@onCheckedSpy").should("have.been.calledWith", [
"system.authentication",
]);
});

it("can remove items from taxonomy select", () => {
const onCheckedSpy = cy.spy().as("onCheckedSpy");
const selectedClassifiedCategory = MOST_LIKELY_CATEGORIES[0];
cy.mount(
<ClassifiedDataCategoryDropdown
dataCategories={MOCK_DATA_CATEGORIES as DataCategory[]}
checked={[
selectedClassifiedCategory.fides_key,
"system.authentication",
]}
onChecked={onCheckedSpy}
mostLikelyCategories={MOST_LIKELY_CATEGORIES}
/>
);
// uncheck system authentication
cy.getByTestId("data-category-dropdown").click();
cy.getByTestId("checkbox-Authentication Data").click();
cy.get("@onCheckedSpy").should("have.been.calledWith", [
selectedClassifiedCategory.fides_key,
]);
});

it("playground", () => {
// it's useful when developing to be able to play with the component with actual react state
const ClassifiedDataCategoryDropdownWithState = () => {
const [checked, setChecked] = React.useState([]);
return (
<ClassifiedDataCategoryDropdown
dataCategories={MOCK_DATA_CATEGORIES as DataCategory[]}
checked={checked}
onChecked={setChecked}
mostLikelyCategories={MOST_LIKELY_CATEGORIES}
/>
);
};
cy.mount(<ClassifiedDataCategoryDropdownWithState />);
});
});
18 changes: 18 additions & 0 deletions clients/ctl/admin-ui/cypress/components/DataCategoryInput.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import DataCategoryInput from "~/features/dataset/DataCategoryInput";
import { MOCK_DATA_CATEGORIES } from "~/mocks/data";
import { DataCategory } from "~/types/api";

import { stubPlusHealth } from "../support/stubs";

describe("DataCategoryInput", () => {
it("can check a category", () => {
const onCheckedSpy = cy.spy().as("onCheckedSpy");
Expand All @@ -15,6 +17,8 @@ describe("DataCategoryInput", () => {
/>
);

cy.getByTestId("classified-select").should("not.exist");

cy.getByTestId("selected-categories").should("contain", "user");
cy.getByTestId("data-category-dropdown").click();
cy.getByTestId("expand-System Data").click();
Expand All @@ -25,4 +29,18 @@ describe("DataCategoryInput", () => {
"system.authentication",
]);
});

it("can render the classified version", () => {
stubPlusHealth();

const onCheckedSpy = cy.spy().as("onCheckedSpy");
cy.mount(
<DataCategoryInput
dataCategories={MOCK_DATA_CATEGORIES as DataCategory[]}
checked={["user"]}
onChecked={onCheckedSpy}
/>
);
cy.getByTestId("classified-select");
});
});
10 changes: 3 additions & 7 deletions clients/ctl/admin-ui/cypress/e2e/datasets-classify.cy.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { stubPlusHealth } from "../support/stubs";

/**
* This test suite is a parallel of datasets.cy.ts for testing Dataset features when the user has
* access to the Fidescls API. This suite should cover the behavior that is different when a
* dataset is classified.
*/
describe("Datasets with Fides Classify", () => {
beforeEach(() => {
cy.intercept("GET", "/api/v1/plus/health", {
statusCode: 200,
body: {
status: "healthy",
core_fidesctl_version: "1.8",
},
}).as("getPlusHealth");
stubPlusHealth();
});

describe("Creating datasets", () => {
Expand Down
36 changes: 27 additions & 9 deletions clients/ctl/admin-ui/cypress/support/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,16 @@ import "@fontsource/inter/500.css";
import "@fontsource/inter/700.css";

import { ChakraProvider } from "@chakra-ui/react";
import { EnhancedStore } from "@reduxjs/toolkit";
// Alternatively you can use CommonJS syntax:
// require('./commands')
// eslint-disable-next-line import/no-extraneous-dependencies
import { mount } from "cypress/react";
import { mount, MountOptions, MountReturn } from "cypress/react";
import * as React from "react";
import { Provider } from "react-redux";

import theme from "../../src/theme";
import { AppState, makeStore } from "~/app/store";
import theme from "~/theme";

// Augment the Cypress namespace to include type definitions for
// your custom command.
Expand All @@ -35,14 +38,29 @@ import theme from "../../src/theme";
declare global {
namespace Cypress {
interface Chainable {
mount: typeof mount;
/**
* Mounts a React node
* @param component React Node to mount
* @param options Additional options to pass into mount
*/
mount(
component: React.ReactNode,
options?: MountOptions & { reduxStore?: EnhancedStore<AppState> }
): Cypress.Chainable<MountReturn>;
}
}
}

Cypress.Commands.add("mount", (jsx, options) =>
mount(React.createElement(ChakraProvider, { theme }, jsx), options)
);

// Example use:
// cy.mount(<MyComponent />)
/**
* Wrap the default mount in Redux and Chakra
*/
Cypress.Commands.add("mount", (component, options = {}) => {
const { reduxStore = makeStore(), ...mountOptions } = options;
const wrapChakra = React.createElement(ChakraProvider, { theme }, component);
const wrapRedux = React.createElement(
Provider,
{ store: reduxStore },
wrapChakra
);
return mount(wrapRedux, mountOptions);
});
10 changes: 10 additions & 0 deletions clients/ctl/admin-ui/cypress/support/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,13 @@ export const stubSystemCrud = () => {
}).as("deleteSystem");
});
};

export const stubPlusHealth = () => {
cy.intercept("GET", "/api/v1/plus/health", {
statusCode: 200,
body: {
status: "healthy",
core_fidesctl_version: "1.8",
},
}).as("getPlusHealth");
};
2 changes: 1 addition & 1 deletion clients/ctl/admin-ui/src/app/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { reducer as systemReducer, systemApi } from "~/features/system";
import { reducer as taxonomyReducer, taxonomyApi } from "~/features/taxonomy";
import { reducer as userReducer } from "~/features/user";

const makeStore = () => {
export const makeStore = () => {
const store = configureStore({
reducer: {
configWizard: configWizardReducer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Box, ButtonProps } from "@fidesui/react";
import { MultiValue, Select } from "chakra-react-select";

import { DataCategory } from "~/types/api";

import DataCategoryDropdown from "./DataCategoryDropdown";
import type { Props as DataCategoryDropdownProps } from "./DataCategoryInput";

// TODO: just making up a structure until we have something real from the API
ssangervasi marked this conversation as resolved.
Show resolved Hide resolved
interface DataCategoryWithConfidence extends DataCategory {
confidence: number;
}

interface Props extends DataCategoryDropdownProps {
mostLikelyCategories: DataCategoryWithConfidence[];
}

const ClassifiedDataCategoryDropdown = ({
mostLikelyCategories,
onChecked,
checked,
...props
}: Props) => {
const menuButtonProps: ButtonProps = {
size: "sm",
colorScheme: "complimentary",
borderRadius: "6px 0px 0px 6px",
};

const options = mostLikelyCategories
ssangervasi marked this conversation as resolved.
Show resolved Hide resolved
.sort((a, b) => b.confidence - a.confidence)
.map((c) => ({
label: `${c.fides_key} (${c.confidence}%)`,
value: c.fides_key,
}));

const selectedOptions = options.filter((o) => checked.indexOf(o.value) >= 0);

const handleChange = (
newValues: MultiValue<{ label: string; value: string }>
) => {
const oldKeys = selectedOptions.map((o) => o.value);
const newKeys = newValues.map((o) => o.value);
let newChecked;

if (newKeys.length > oldKeys.length) {
const addedKey = newKeys.filter(
(newKey) => oldKeys.indexOf(newKey) === -1
)[0];
newChecked = [...checked, addedKey];
} else {
const removedKey = oldKeys.filter(
(oldKey) => newKeys.indexOf(oldKey) === -1
)[0];
newChecked = checked.filter((c) => c !== removedKey);
}
ssangervasi marked this conversation as resolved.
Show resolved Hide resolved
onChecked(newChecked);
};

return (
<Box display="flex">
<Box>
<DataCategoryDropdown
{...props}
checked={checked}
onChecked={onChecked}
buttonProps={menuButtonProps}
buttonLabel="Search taxonomy"
/>
</Box>
<Box flexGrow={1} data-testid="classified-select">
<Select
options={options}
onChange={handleChange}
value={selectedOptions}
size="sm"
placeholder="Select from recommendations..."
chakraStyles={{
dropdownIndicator: (provided) => ({
...provided,
bg: "transparent",
px: 2,
cursor: "inherit",
}),
indicatorSeparator: (provided) => ({
...provided,
display: "none",
}),
multiValue: (provided) => ({
...provided,
background: "primary.400",
color: "white",
}),
}}
components={{
ClearIndicator: () => null,
}}
isSearchable
isClearable
isMulti
instanceId="classified-data-category-input-multiselect"
/>
</Box>
</Box>
);
};

export default ClassifiedDataCategoryDropdown;
Loading