diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6129f346..8d7f90decb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ The types of changes are: - Serve GVL languages as they are requested [#5112](https://github.com/ethyca/fides/pull/5112) - Changed text on system integrations tab to direct to new integration management [#5097](https://github.com/ethyca/fides/pull/5097) - Updates to consent experience styling [#5085](https://github.com/ethyca/fides/pull/5085) +- Updated the dataset page to display the new table and support pagination [#5130](https://github.com/ethyca/fides/pull/5130) - Improve performance by removing the need to load every system into redux store [#5135](https://github.com/ethyca/fides/pull/5135) - Use the `user_id` from a Segment Trait instead of an `email` when deleting a user in Segment [#5004](https://github.com/ethyca/fides/pull/5004) - Moves some endpoints for property-specific messaging from OSS -> plus [#5069](https://github.com/ethyca/fides/pull/5069) diff --git a/clients/admin-ui/cypress/e2e/datasets-classify.cy.ts b/clients/admin-ui/cypress/e2e/datasets-classify.cy.ts index 6f586d3066..9ea8f3335b 100644 --- a/clients/admin-ui/cypress/e2e/datasets-classify.cy.ts +++ b/clients/admin-ui/cypress/e2e/datasets-classify.cy.ts @@ -41,7 +41,7 @@ describe("Datasets with Fides Classify", () => { cy.visit("/dataset"); cy.wait("@getFilteredDatasets"); cy.getByTestId("dataset-table"); - cy.getByTestId("dataset-row-demo_users_dataset_4"); + cy.getByTestId("row-3"); cy.getByTestId("dataset-table__status-table-header").should( "have.text", @@ -95,25 +95,6 @@ describe("Datasets with Fides Classify", () => { }); }); - describe("List of datasets with classifications", () => { - it("Shows the each dataset's classify status", () => { - cy.visit("/dataset"); - cy.wait("@getFilteredDatasets"); - cy.wait("@getClassifyList"); - cy.getByTestId("dataset-table"); - cy.getByTestId("dataset-status-demo_users_dataset_2").contains( - "Processing" - ); - cy.getByTestId("dataset-status-demo_users_dataset_3").contains( - "Awaiting Review" - ); - cy.getByTestId("dataset-status-demo_users_dataset_4").contains( - "Classified" - ); - cy.getByTestId("classification-status-badge").should("exist"); - }); - }); - describe("Dataset collection view", () => { beforeEach(() => { cy.intercept("GET", "/api/v1/dataset/*", { diff --git a/clients/admin-ui/cypress/e2e/datasets.cy.ts b/clients/admin-ui/cypress/e2e/datasets.cy.ts index e26a52b240..f3f08e5f8f 100644 --- a/clients/admin-ui/cypress/e2e/datasets.cy.ts +++ b/clients/admin-ui/cypress/e2e/datasets.cy.ts @@ -18,7 +18,7 @@ describe("Dataset", () => { cy.getByTestId("Manage datasets-nav-link").click(); cy.wait("@getFilteredDatasets"); cy.getByTestId("dataset-table"); - cy.getByTestId("dataset-row-demo_users_dataset_4"); + cy.getByTestId("row-3"); // The classifier toggle should not be available. cy.get("input-classify").should("not.exist"); @@ -35,7 +35,7 @@ describe("Dataset", () => { it("Can load an individual dataset", () => { cy.visit("/dataset"); cy.wait("@getFilteredDatasets"); - cy.getByTestId("dataset-row-demo_users_dataset").click(); + cy.getByTestId("row-0").click(); // for some reason this is slow in CI, so add a timeout :( cy.url({ timeout: 10000 }).should( "contain", diff --git a/clients/admin-ui/cypress/fixtures/datasets_paginated.json b/clients/admin-ui/cypress/fixtures/datasets_paginated.json new file mode 100644 index 0000000000..e72d16516f --- /dev/null +++ b/clients/admin-ui/cypress/fixtures/datasets_paginated.json @@ -0,0 +1,239 @@ +{ + "page": 1, + "size": 25, + "pages": 1, + "total": 5, + "items": [ + { + "fides_key": "demo_users_dataset", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Demo Users Dataset", + "description": "Data collected about users for our analytics system.", + "meta": null, + "data_categories": ["system"], + "fidesctl_meta": null, + "collections": [ + { + "name": "users", + "description": "User information", + "data_categories": [], + "fields": [ + { + "name": "created_at", + "description": "User's creation timestamp", + "data_categories": ["system.operations"], + "fields": null + }, + { + "name": "email", + "description": "User's Email", + "data_categories": ["user.contact.email"], + "fields": null + }, + { + "name": "first_name", + "description": "User's first name", + "data_categories": ["user.name"], + "fields": null + }, + { + "name": "food_preference", + "description": "User's favorite food", + "data_categories": [], + "fields": null + }, + { + "name": "state", + "description": "User's State", + "data_categories": ["user.contact.address.state"], + "fields": null + }, + { + "name": "uuid", + "description": "User's unique ID", + "data_categories": ["user.unique_id"], + "fields": null + } + ] + } + ] + }, + { + "fides_key": "demo_users_dataset_2", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Demo Users Dataset 2", + "description": "Data collected about users for our analytics system.", + "meta": null, + "data_categories": [], + "fidesctl_meta": null, + "collections": [ + { + "name": "users", + "description": "User information", + "data_categories": [], + "fields": [ + { + "name": "created_at", + "description": "User's creation timestamp", + "data_categories": ["system.operations"], + "fields": null + }, + { + "name": "email", + "description": "User's Email", + "data_categories": ["user.contact.email"], + "fields": null + }, + { + "name": "first_name", + "description": "User's first name", + "data_categories": ["user.name"], + "fields": null + }, + { + "name": "food_preference", + "description": "User's favorite food", + "data_categories": [], + "fields": null + }, + { + "name": "state", + "description": "User's State", + "data_categories": ["user.contact.address.state"], + "fields": null + }, + { + "name": "uuid", + "description": "User's unique ID", + "data_categories": ["user.unique_id"], + "fields": null + } + ] + } + ] + }, + { + "fides_key": "demo_users_dataset_3", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Demo Users Dataset 3", + "description": "Data collected about users for our analytics system.", + "meta": null, + "data_categories": [], + "fidesctl_meta": null, + "collections": [ + { + "name": "users", + "description": "User information", + "data_categories": [], + "fields": [ + { + "name": "created_at", + "description": "User's creation timestamp", + "data_categories": ["system.operations"], + "fields": null + }, + { + "name": "email", + "description": "User's Email", + "data_categories": ["user.contact.email"], + "fields": null + }, + { + "name": "first_name", + "description": "User's first name", + "data_categories": ["user.name"], + "fields": null + }, + { + "name": "food_preference", + "description": "User's favorite food", + "data_categories": [], + "fields": null + }, + { + "name": "state", + "description": "User's State", + "data_categories": ["user.contact.address.state"], + "fields": null + }, + { + "name": "uuid", + "description": "User's unique ID", + "data_categories": ["user.unique_id"], + "fields": null + } + ] + } + ] + }, + { + "fides_key": "demo_users_dataset_4", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Demo Users Dataset 4", + "description": "Data collected about users for our analytics system.", + "meta": null, + "data_categories": [], + "fidesctl_meta": null, + "collections": [ + { + "name": "users", + "description": "User information", + "data_categories": [], + "fields": [ + { + "name": "created_at", + "description": "User's creation timestamp", + "data_categories": ["system.operations"], + "fields": null + }, + { + "name": "email", + "description": "User's Email", + "data_categories": ["user.contact.email"], + "fields": null + }, + { + "name": "first_name", + "description": "User's first name", + "data_categories": ["user.name"], + "fields": null + }, + { + "name": "food_preference", + "description": "User's favorite food", + "data_categories": [], + "fields": null + }, + { + "name": "state", + "description": "User's State", + "data_categories": ["user.contact.address.state"], + "fields": null + }, + { + "name": "uuid", + "description": "User's unique ID", + "data_categories": ["user.unique_id"], + "fields": null + } + ] + } + ] + }, + { + "fides_key": "postgres_example_test_dataset", + "organization_fides_key": "default_organization", + "tags": null, + "name": "Postgres Example Test Dataset", + "description": "Example of a Postgres dataset containing a variety of related tables like customers, products, addresses, etc.", + "meta": null, + "data_categories": null, + "fides_meta": null, + "collections": [] + } + ] +} diff --git a/clients/admin-ui/cypress/support/stubs.ts b/clients/admin-ui/cypress/support/stubs.ts index ed0f2b944f..4792f37a1d 100644 --- a/clients/admin-ui/cypress/support/stubs.ts +++ b/clients/admin-ui/cypress/support/stubs.ts @@ -81,9 +81,13 @@ export const stubDatasetCrud = () => { cy.intercept("GET", "/api/v1/dataset", { fixture: "datasets.json" }).as( "getDatasets" ); - cy.intercept("GET", "/api/v1/filter/dataset?only_unlinked_datasets=false", { - fixture: "datasets.json", - }).as("getFilteredDatasets"); + cy.intercept( + "GET", + "/api/v1/dataset?page=1&size=25&exclude_saas_datasets=true", + { + fixture: "datasets_paginated.json", + } + ).as("getFilteredDatasets"); cy.intercept("GET", "/api/v1/dataset/*", { fixture: "dataset.json" }).as( "getDataset" ); diff --git a/clients/admin-ui/src/features/common/nav/v2/routes.ts b/clients/admin-ui/src/features/common/nav/v2/routes.ts index d1f4955a39..5a49edc22f 100644 --- a/clients/admin-ui/src/features/common/nav/v2/routes.ts +++ b/clients/admin-ui/src/features/common/nav/v2/routes.ts @@ -11,6 +11,7 @@ export const SYSTEM_ROUTE = "/systems"; export const EDIT_SYSTEM_ROUTE = "/systems/configure/[id]"; export const CLASSIFY_SYSTEMS_ROUTE = "/classify-systems"; export const DATASET_ROUTE = "/dataset"; +export const DATASET_EDIT_ROUTE = "/dataset/[id]"; // Detection and discovery export const DETECTION_DISCOVERY_ACTIVITY_ROUTE = "/data-discovery/activity"; diff --git a/clients/admin-ui/src/features/dataset/DatasetTable.tsx b/clients/admin-ui/src/features/dataset/DatasetTable.tsx deleted file mode 100644 index bdc8b0f8f4..0000000000 --- a/clients/admin-ui/src/features/dataset/DatasetTable.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Table, Tbody, Td, Th, Thead, Tr } from "fidesui"; -import { useRouter } from "next/router"; -import { useDispatch, useSelector } from "react-redux"; - -import { usePollForClassifications } from "~/features/common/classifications"; -import { useFeatures } from "~/features/common/features"; -import ClassificationStatusBadge from "~/features/plus/ClassificationStatusBadge"; -import { selectDatasetClassifyInstanceMap } from "~/features/plus/plus.slice"; -import { Dataset, GenerateTypes } from "~/types/api"; - -import { - setActiveDatasetFidesKey, - useGetAllFilteredDatasetsQuery, -} from "./dataset.slice"; - -const DatasetsTable = () => { - const dispatch = useDispatch(); - const router = useRouter(); - const { data: datasets } = useGetAllFilteredDatasetsQuery({ - onlyUnlinkedDatasets: false, - }); - const features = useFeatures(); - usePollForClassifications({ - resourceType: GenerateTypes.DATASETS, - skip: !features.plus, - }); - const classifyInstanceMap = useSelector(selectDatasetClassifyInstanceMap); - - const handleRowClick = (dataset: Dataset) => { - dispatch(setActiveDatasetFidesKey(dataset.fides_key)); - router.push(`/dataset/${dataset.fides_key}`); - }; - - if (!datasets) { - return
Empty state
; - } - - return ( - - - - - - - {features.plus ? ( - - ) : null} - - - - {datasets.map((dataset) => { - const classifyDataset = classifyInstanceMap.get(dataset.fides_key); - - return ( - handleRowClick(dataset)} - onKeyPress={(e) => { - if (e.key === "Enter") { - handleRowClick(dataset); - } - }} - cursor="pointer" - data-testid={`dataset-row-${dataset.fides_key}`} - > - - - - {features.plus ? ( - - ) : null} - - ); - })} - -
NameFides KeyDescription - Status -
{dataset.name}{dataset.fides_key}{dataset.description} - {classifyDataset?.status ? ( - - ) : null} -
- ); -}; - -export default DatasetsTable; diff --git a/clients/admin-ui/src/features/dataset/dataset.slice.ts b/clients/admin-ui/src/features/dataset/dataset.slice.ts index d123be032f..2d762d0595 100644 --- a/clients/admin-ui/src/features/dataset/dataset.slice.ts +++ b/clients/admin-ui/src/features/dataset/dataset.slice.ts @@ -7,7 +7,10 @@ import { Dataset, GenerateRequestPayload, GenerateResponse, + Page_Dataset_, } from "~/types/api"; +import { PaginationQueryParams } from "~/types/common/PaginationQueryParams"; +import { SearchQueryParams } from "~/types/common/SearchQueryParams"; import { EditableType } from "./types"; @@ -27,8 +30,24 @@ interface DatasetDeleteResponse { resource: Dataset; } +interface DatasetFiltersQueryParams { + exclude_saas_datasets?: boolean; + only_unlinked_datasets?: boolean; +} + const datasetApi = baseApi.injectEndpoints({ endpoints: (build) => ({ + getDatasets: build.query< + Page_Dataset_, + PaginationQueryParams & SearchQueryParams & DatasetFiltersQueryParams + >({ + query: (params) => ({ + method: "GET", + url: `dataset`, + params, + }), + providesTags: () => ["Datasets"], + }), getAllDatasets: build.query({ query: () => ({ url: `dataset` }), providesTags: () => ["Datasets"], @@ -99,6 +118,7 @@ const datasetApi = baseApi.injectEndpoints({ }); export const { + useGetDatasetsQuery, useGetAllDatasetsQuery, useGetAllFilteredDatasetsQuery, useGetDatasetByKeyQuery, diff --git a/clients/admin-ui/src/features/system/system.slice.ts b/clients/admin-ui/src/features/system/system.slice.ts index c19f8a1928..2fc22020f1 100644 --- a/clients/admin-ui/src/features/system/system.slice.ts +++ b/clients/admin-ui/src/features/system/system.slice.ts @@ -11,6 +11,8 @@ import { SystemResponse, TestStatusMessage, } from "~/types/api"; +import { PaginationQueryParams } from "~/types/common/PaginationQueryParams"; +import { SearchQueryParams } from "~/types/common/SearchQueryParams"; interface SystemDeleteResponse { message: string; @@ -23,14 +25,6 @@ interface UpsertResponse { updated: number; } -interface PaginationParams { - page: number; - size: number; -} -interface SearchParams { - search?: string; -} - export type ConnectionConfigSecretsRequest = { systemFidesKey: string; secrets: { @@ -42,7 +36,7 @@ const systemApi = baseApi.injectEndpoints({ endpoints: (build) => ({ getSystems: build.query< Page_BasicSystemResponse_, - PaginationParams & SearchParams + PaginationQueryParams & SearchQueryParams >({ query: (params) => ({ method: "GET", diff --git a/clients/admin-ui/src/pages/dataset/index.tsx b/clients/admin-ui/src/pages/dataset/index.tsx index d3a4cddde5..28f16df918 100644 --- a/clients/admin-ui/src/pages/dataset/index.tsx +++ b/clients/admin-ui/src/pages/dataset/index.tsx @@ -1,35 +1,248 @@ -import { Box, Button, Spinner } from "fidesui"; +/* eslint-disable @typescript-eslint/no-use-before-define */ +/* eslint-disable react/no-unstable-nested-components */ +import { + ColumnDef, + createColumnHelper, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { Box, Button, Text, VStack } from "fidesui"; import type { NextPage } from "next"; import NextLink from "next/link"; +import { useRouter } from "next/router"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useDispatch } from "react-redux"; +import { usePollForClassifications } from "~/features/common/classifications"; +import { useFeatures } from "~/features/common/features"; import Layout from "~/features/common/Layout"; +import { DATASET_EDIT_ROUTE } from "~/features/common/nav/v2/routes"; import PageHeader from "~/features/common/PageHeader"; -import { useGetAllFilteredDatasetsQuery } from "~/features/dataset/dataset.slice"; -import DatasetsTable from "~/features/dataset/DatasetTable"; +import { + DefaultCell, + DefaultHeaderCell, + FidesTableV2, + GlobalFilterV2, + PaginationBar, + TableActionBar, + TableSkeletonLoader, + useServerSidePagination, +} from "~/features/common/table/v2"; +import { + setActiveDatasetFidesKey, + useGetDatasetsQuery, +} from "~/features/dataset/dataset.slice"; +import { Dataset, GenerateTypes } from "~/types/api"; + +const columnHelper = createColumnHelper(); + +const EMPTY_RESPONSE = { + items: [], + total: 0, + page: 1, + size: 25, + pages: 1, +}; const DataSets: NextPage = () => { - const { isLoading } = useGetAllFilteredDatasetsQuery({ - onlyUnlinkedDatasets: false, + const dispatch = useDispatch(); + const router = useRouter(); + + const { + PAGE_SIZES, + pageSize, + setPageSize, + onPreviousPageClick, + isPreviousPageDisabled, + onNextPageClick, + isNextPageDisabled, + startRange, + endRange, + pageIndex, + setTotalPages, + resetPageIndexToDefault, + } = useServerSidePagination(); + + const [globalFilter, setGlobalFilter] = useState(); + const updateGlobalFilter = (searchTerm: string) => { + resetPageIndexToDefault(); + setGlobalFilter(searchTerm); + }; + + const { + data: datasetResponse, + isLoading, + isFetching, + } = useGetDatasetsQuery({ + page: pageIndex, + size: pageSize, + search: globalFilter, + exclude_saas_datasets: true, + }); + + const { + items: data, + total: totalRows, + pages: totalPages, + } = useMemo(() => datasetResponse ?? EMPTY_RESPONSE, [datasetResponse]); + + useEffect(() => { + setTotalPages(totalPages); + }, [totalPages, setTotalPages]); + + const handleEdit = useCallback( + (dataset: Dataset) => { + dispatch(setActiveDatasetFidesKey(dataset.fides_key)); + router.push({ + pathname: DATASET_EDIT_ROUTE, + query: { + id: dataset.fides_key, + }, + }); + }, + [dispatch, router] + ); + + const features = useFeatures(); + usePollForClassifications({ + resourceType: GenerateTypes.DATASETS, + skip: !features.plus, + }); + + const columns = useMemo( + () => + [ + columnHelper.accessor((row) => row.name, { + id: "name", + cell: (props) => , + header: (props) => ( + + ), + size: 180, + }), + columnHelper.accessor((row) => row.fides_key, { + id: "fides_key", + cell: (props) => , + header: (props) => , + size: 150, + }), + columnHelper.accessor((row) => row.description, { + id: "description", + cell: (props) => , + header: (props) => ( + + ), + size: 300, + }), + + // Will be added back in PROD-2481 + // columnHelper.display({ + // id: "actions", + // header: "Actions", + // cell: ({ row }) => { + // const system = row.original; + // return ( + // + // } + // onClick={() => handleEdit(system)} + // /> + // + // ); + // }, + // meta: { + // disableRowClick: true, + // }, + // }) + ].filter(Boolean) as ColumnDef[], + [] + ); + + const tableInstance = useReactTable({ + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + columns, + data, }); return ( - - {isLoading ? : } - - + + + {isLoading ? ( + + ) : ( + + + + + + } + onRowClick={handleEdit} + /> + + )} + ); }; +const EmptyTableNotice = () => ( + + + + No datasets found. + + + Click "Create new dataset" to add your first dataset to Fides. + + + +); + export default DataSets; diff --git a/clients/admin-ui/src/types/common/PaginationQueryParams.ts b/clients/admin-ui/src/types/common/PaginationQueryParams.ts new file mode 100644 index 0000000000..bfdeda1475 --- /dev/null +++ b/clients/admin-ui/src/types/common/PaginationQueryParams.ts @@ -0,0 +1,4 @@ +export interface PaginationQueryParams { + page: number; + size: number; +} diff --git a/clients/admin-ui/src/types/common/SearchQueryParams.ts b/clients/admin-ui/src/types/common/SearchQueryParams.ts new file mode 100644 index 0000000000..f100c8d6d1 --- /dev/null +++ b/clients/admin-ui/src/types/common/SearchQueryParams.ts @@ -0,0 +1,3 @@ +export interface SearchQueryParams { + search?: string; +}