diff --git a/clients/admin-ui/src/features/common/nav/nav-config.tsx b/clients/admin-ui/src/features/common/nav/nav-config.tsx index 87c229459f7..4eabdffd51b 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -389,6 +389,11 @@ if (process.env.NEXT_PUBLIC_APP_ENV === "development") { path: routes.ERRORS_POC_ROUTE, scopes: [], }, + { + title: "Async Tree POC", + path: routes.ASYNC_TREE_ROUTE, + scopes: [], + }, ], }); } diff --git a/clients/admin-ui/src/features/common/nav/routes.ts b/clients/admin-ui/src/features/common/nav/routes.ts index 0bf2857a749..ec238145d8f 100644 --- a/clients/admin-ui/src/features/common/nav/routes.ts +++ b/clients/admin-ui/src/features/common/nav/routes.ts @@ -103,6 +103,7 @@ export const ANT_POC_ROUTE = "/poc/ant-components"; export const FORMS_POC_ROUTE = "/poc/forms"; export const ERRORS_POC_ROUTE = "/poc/error"; export const TABLE_MIGRATION_POC_ROUTE = "/poc/table-migration"; +export const ASYNC_TREE_ROUTE = "/poc/async-tree"; export const FIDES_JS_DOCS = "/fides-js-docs"; export const PROMPT_EXPLORER_ROUTE = "/poc/prompt-explorer"; export const TEST_MONITORS_ROUTE = "/poc/test-monitors"; diff --git a/clients/admin-ui/src/features/common/pagination.d.ts b/clients/admin-ui/src/features/common/pagination.d.ts index 63f116f170c..268a0f660cd 100644 --- a/clients/admin-ui/src/features/common/pagination.d.ts +++ b/clients/admin-ui/src/features/common/pagination.d.ts @@ -42,3 +42,11 @@ export interface CursorPaginationQueryParams { cursor?: string | null; size?: number | null; } + +export interface CursorPaginatedResponse { + items?: Array; + total?: number; + next_page?: string; + current_page?: string; + prev_page?: string; +} diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncMonitorTree.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncMonitorTree.tsx new file mode 100644 index 00000000000..bba996380da --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncMonitorTree.tsx @@ -0,0 +1,193 @@ +/* eslint-disable react/no-unstable-nested-components */ +import { Badge, Icons, Tooltip, TreeDataNode } from "fidesui"; +import { useRouter } from "next/router"; +import { Key } from "react"; + +import { useLazyGetMonitorTreeQuery } from "~/features/data-discovery-and-detection/action-center/action-center.slice"; +import { AsyncTree } from "~/features/data-discovery-and-detection/action-center/AsyncTree"; +import { + NodeAction, + TreeActions, +} from "~/features/data-discovery-and-detection/action-center/AsyncTree/types"; +import { + DEFAULT_FILTER_STATUSES, + MAP_DATASTORE_RESOURCE_TYPE_TO_ICON, + MAP_DIFF_STATUS_TO_STATUS_INFO, +} from "~/features/data-discovery-and-detection/action-center/fields/MonitorFields.const"; +import { shouldShowBadgeDot } from "~/features/data-discovery-and-detection/action-center/fields/treeUtils"; +import { DiffStatus, StagedResourceTypeValue } from "~/types/api"; +import { CursorPage_DatastoreStagedResourceTreeAPIResponse_ } from "~/types/api/models/CursorPage_DatastoreStagedResourceTreeAPIResponse_"; +import { FieldActionType } from "~/types/api/models/FieldActionType"; + +import { + FIELD_ACTION_LABEL, + RESOURCE_ACTIONS, +} from "./fields/FieldActions.const"; +import { intoDiffStatus } from "./fields/utils"; +import { DatastorePageSettings } from "./types"; + +interface AsyncMonitorTreeProps + extends TreeActions>> { + setSelectedNodeKeys: (keys: Key[]) => void; + selectedNodeKeys: Key[]; +} + +export const AsyncMonitorTree = ({ + setSelectedNodeKeys, + selectedNodeKeys, + nodeActions, + primaryAction, + showApproved, + showIgnored, +}: AsyncMonitorTreeProps & DatastorePageSettings) => { + const router = useRouter(); + const monitorId = decodeURIComponent(router.query.monitorId as string); + const [trigger] = useLazyGetMonitorTreeQuery(); + + /** + * @description the primary of interacting with the async data tree is through request/response patterns + */ + const transformResponseToNode = ( + response: CursorPage_DatastoreStagedResourceTreeAPIResponse_, + ): TreeDataNode[] => + response.items.map((item) => ({ + key: item.urn, + title: item.name, + disabled: item.diff_status === DiffStatus.MUTED, + actions: Object.fromEntries( + RESOURCE_ACTIONS.map((action) => [ + action, + { + label: FIELD_ACTION_LABEL[action], + /** Logic for this should exist on the BE */ + disabled: () => { + const classifyable = [ + StagedResourceTypeValue.SCHEMA, + StagedResourceTypeValue.TABLE, + StagedResourceTypeValue.ENDPOINT, + StagedResourceTypeValue.FIELD, + ].some((resourceType) => resourceType === item.resource_type); + + if ( + (action === FieldActionType.PROMOTE_REMOVALS && + item.diff_status === DiffStatus.REMOVAL) || + (action === FieldActionType.CLASSIFY && + classifyable && + item.diff_status !== DiffStatus.MUTED) || + (action === FieldActionType.MUTE && + item.diff_status !== DiffStatus.MUTED) || + (action === FieldActionType.UN_MUTE && + item.diff_status === DiffStatus.MUTED) + ) { + return false; + } + + return true; + }, + callback: async () => + nodeActions[action].callback( + [item.urn], + [ + { + key: item.urn, + title: item.name, + disabled: item.diff_status === DiffStatus.MUTED, + }, + ], + ), + }, + ]), + ), + icon: () => { + const resourceType = Object.values(StagedResourceTypeValue).find( + (key) => key === item.resource_type, + ); + + const resourceIcon = resourceType + ? MAP_DATASTORE_RESOURCE_TYPE_TO_ICON[resourceType] + : undefined; + + const IconComponent = + item.diff_status === DiffStatus.MUTED ? Icons.ViewOff : resourceIcon; + + const statusInfo = item.diff_status + ? MAP_DIFF_STATUS_TO_STATUS_INFO[item.diff_status] + : undefined; + + return IconComponent ? ( + + + + + + ) : undefined; + }, + isLeaf: item.resource_type === StagedResourceTypeValue.FIELD, + })); + + return ( +
+ + new Promise((resolve) => { + trigger({ + monitor_config_id: monitorId, + staged_resource_urn: key?.toString(), + diff_status: [ + ...DEFAULT_FILTER_STATUSES.flatMap(intoDiffStatus), + ...(showIgnored ? intoDiffStatus("Ignored") : []), + ...(showApproved ? intoDiffStatus("Approved") : []), + ], + cursor, + size, + }).then(({ data }) => + resolve({ + items: data ? transformResponseToNode(data) : [], + total: data?.total ?? 0, + current_page: data?.current_page ?? undefined, + next_page: data?.next_page ?? undefined, + }), + ); + }) + } + refreshData={(key, childKeys) => + new Promise((resolve) => { + trigger({ + monitor_config_id: monitorId, + staged_resource_urn: key?.toString(), + diff_status: [ + ...DEFAULT_FILTER_STATUSES.flatMap(intoDiffStatus), + ...(showIgnored ? intoDiffStatus("Ignored") : []), + ...(showApproved ? intoDiffStatus("Approved") : []), + ], + child_staged_resource_urns: childKeys?.map((childKey) => + childKey.toString(), + ), + size: childKeys?.length, + }).then(({ data }) => + resolve({ + items: data ? transformResponseToNode(data) : [], + total: data?.total ?? 0, + current_page: data?.current_page ?? undefined, + next_page: data?.next_page ?? undefined, + }), + ); + }) + } + actions={{ primaryAction, nodeActions }} + // pageSize={2} + onSelect={(keys) => setSelectedNodeKeys(keys)} + selectedKeys={selectedNodeKeys} + showFooter + taxonomy={["resource", "resources"]} + queryKeys={[String(showIgnored), String(showApproved)]} + className="overflow-scroll overflow-x-hidden" + /> +
+ ); +}; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/AsyncNodeSkeleton.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/AsyncNodeSkeleton.tsx new file mode 100644 index 00000000000..6cbd02bd7ca --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/AsyncNodeSkeleton.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from "fidesui"; + +import { AsyncTreeNodeComponentProps } from "./types"; + +export const AsyncNodeSkeleton = ({ node }: AsyncTreeNodeComponentProps) => ( + + {typeof node.title === "function" ? node.title(node) : node.title} + +); diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/AsyncNodeTitle.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/AsyncNodeTitle.tsx new file mode 100644 index 00000000000..f6461343cba --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/AsyncNodeTitle.tsx @@ -0,0 +1,62 @@ +import { Button, Dropdown, Flex, Icons, Text } from "fidesui"; + +import { AsyncTreeNodeComponentProps } from "./types"; + +export const AsyncTreeDataTitle = ({ + node, + actions, + refreshCallback, +}: AsyncTreeNodeComponentProps) => { + const title = + typeof node.title === "function" ? node.title(node) : node.title; + + if (!title) { + return null; + } + return ( + /** TODO: migrate group class to semantic dom after upgrading ant */ + + + {title} + + ({ + key, + disabled: disabled([node]), + label, + }), + ) + : [], + onClick: async ({ key, domEvent }) => { + domEvent.preventDefault(); + domEvent.stopPropagation(); + await actions?.[key]?.callback([key], [node]); + refreshCallback?.(node.key); + }, + }} + destroyOnHidden + className="group mr-1 flex-none group-[.multi-select]/monitor-tree:pointer-events-none group-[.multi-select]/monitor-tree:opacity-0" + > + + ); +}; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/const.ts b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/const.ts new file mode 100644 index 00000000000..88ba4fb3260 --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/const.ts @@ -0,0 +1,34 @@ +import { TreeProps } from "fidesui"; +import { Key } from "react"; + +import { CursorPaginationState } from "~/features/common/pagination"; +import { DEFAULT_PAGE_SIZE } from "~/features/common/table/constants"; + +import { AsyncTreeNode } from "./types"; + +export const TREE_NODE_LOAD_MORE_KEY_PREFIX = ""; +export const TREE_NODE_SKELETON_KEY_PREFIX = ""; + +export const ROOT_NODE_KEY = "ROOT_NODE"; + +export const DEFAULT_ROOT_NODE: AsyncTreeNode & CursorPaginationState = { + key: ROOT_NODE_KEY, + pageSize: DEFAULT_PAGE_SIZE, + total: 0, +}; + +export const DEFAULT_ROOT_NODE_STATE: { + keys: Key[]; + nodes: Array; +} = { + keys: [ROOT_NODE_KEY], + nodes: [DEFAULT_ROOT_NODE], +}; + +export const DEFAULT_TREE_PROPS: TreeProps = { + showIcon: true, + showLine: true, + blockNode: true, + multiple: true, + expandAction: "doubleClick", +}; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/index.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/index.tsx new file mode 100644 index 00000000000..45ba319a960 --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/index.tsx @@ -0,0 +1,380 @@ +/* eslint-disable react/no-unstable-nested-components */ +import { Spin, Tree, TreeDataNode, TreeProps } from "fidesui"; +import _ from "lodash"; +import { Key, useCallback, useEffect, useRef, useState } from "react"; + +import { + CursorPaginatedResponse, + CursorPaginationState, +} from "~/features/common/pagination"; +import { DEFAULT_PAGE_SIZE } from "~/features/common/table/constants"; + +import { AsyncTreeDataTitle } from "./AsyncNodeTitle"; +import { AsyncTreeFooter } from "./AsyncTreeFooter"; +import { AsyncTreeDataLink } from "./AsyncTreeLinkNode"; +import { + DEFAULT_ROOT_NODE, + DEFAULT_ROOT_NODE_STATE, + DEFAULT_TREE_PROPS, + ROOT_NODE_KEY, +} from "./const"; +import { AsyncTreeNode, InternalTreeProps } from "./types"; + +const DEFAULT_NODE_PAGINATION_PROPS = { + cursor: undefined, + total: 0, +} as const; + +export const AsyncTree = ({ + pageSize = DEFAULT_PAGE_SIZE, + loadMoreText = "Load More", + showFooter = false, + taxonomy = ["item", "items"], + actions, + selectedKeys, + loadData, + refreshData, + queryKeys, + ...props +}: InternalTreeProps) => { + /* Storing lookup data as normalized nodes */ + const [asyncNodes, rawSetAsyncNodes] = useState<{ + keys: Key[]; + nodes: Array; + }>(DEFAULT_ROOT_NODE_STATE); + const asyncNodesRef = useRef(); + const [expandedKeys, setExpandedKeys] = useState([]); + + useEffect(() => { + asyncNodesRef.current = asyncNodes; + }); + + const setAsyncNodes = ( + nodes: Array, + ) => { + rawSetAsyncNodes({ + keys: nodes.flatMap(({ key }) => key), + nodes, + }); + }; + + const removeAsyncNodes = (keys: Key[]) => { + rawSetAsyncNodes((prev) => { + const nextKeys = _.difference(prev?.keys ?? [], keys); + + return { + keys: nextKeys, + nodes: nextKeys.flatMap((nextKey) => { + const target = prev.nodes.find(({ key }) => nextKey === key); + + return target ? [target] : []; + }), + }; + }); + setExpandedKeys((prev) => _.difference(prev, keys)); + }; + + const updateAsyncNodes = ( + next: Array, + ) => { + rawSetAsyncNodes((prev) => { + const nextKeys = _.uniq( + [...(prev?.nodes ?? []), ...next].flatMap(({ key }) => key), + ); + + return { + keys: nextKeys, + nodes: nextKeys.flatMap((nextKey) => { + const target = + next.find(({ key }) => nextKey === key) ?? + prev?.nodes.find(({ key }) => nextKey === key); + + return target ? [target] : []; + }), + }; + }); + }; + + const handleRefresh = useCallback( + ( + parentNode: TreeDataNode, + refreshedNodeKeys: Key[], + callback: (keys?: Key[]) => void, + resetPaginationNodes?: CursorPaginatedResponse, + refreshedNodes?: CursorPaginatedResponse, + ) => { + const foundKeys = + refreshedNodes?.items?.map(({ key }) => key.toString()) ?? []; + const keysToRemove = _.difference(refreshedNodeKeys, foundKeys); + removeAsyncNodes(keysToRemove); + + const dataNodes = + [ + ...(resetPaginationNodes?.items ?? []), + ...(refreshedNodes?.items ?? []), + ]?.flatMap((dn) => [ + { + ...dn, + title: () => ( + callback([key])} + /> + ), + parent: parentNode.key ?? ROOT_NODE_KEY, + next_page: asyncNodes.nodes.find( + (existingNode) => dn.key === existingNode.key, + )?.next_page, + ...DEFAULT_NODE_PAGINATION_PROPS, + pageSize, + }, + ]) ?? []; + updateAsyncNodes([ + ...dataNodes, + ...(parentNode + ? [ + { + ...parentNode, + pageSize, + total: resetPaginationNodes?.total ?? 0, + current_page: resetPaginationNodes?.current_page, + next_page: resetPaginationNodes?.next_page, + }, + ] + : []), + ]); + }, + [actions?.nodeActions, asyncNodes.nodes, pageSize], + ); + + const refreshCallback = useCallback( + (keys?: Key[]) => { + const refreshingNodes = keys + ? asyncNodesRef.current?.nodes.flatMap((node) => + keys.includes(node.key) ? [node] : [], + ) + : asyncNodesRef.current?.nodes; + + // const relatedNodes = refreshingNodes.reduce((agg, current) => { + // const parentNode = asyncNodes.nodes.find((n) => n.key === current.parent) + // return [ + // ...agg, + // ...(parentNode ? [parentNode] : []) + // ] + // }, [] as typeof refreshingNodes) + updateAsyncNodes( + refreshingNodes?.map((node) => ({ + ...node, + icon: , + })) ?? [], + ); + + const refresh = async ( + node: TreeDataNode, + parentKey: string, + childKeys: string[], + ) => { + const resolvedParentKey = + parentKey === ROOT_NODE_KEY ? undefined : parentKey; + await Promise.all([ + refreshData(resolvedParentKey, childKeys), + loadData({ size: pageSize }, resolvedParentKey), + ]).then(([refreshedNodes, resetPaginationNodes]) => { + handleRefresh( + node, + childKeys, + refreshCallback, + resetPaginationNodes, + refreshedNodes, + ); + }); + }; + + const parentNodes = asyncNodesRef.current?.nodes.flatMap((node) => + asyncNodesRef.current?.nodes.some(({ parent }) => parent === node.key) + ? [node] + : [], + ); + parentNodes?.map((node) => + refresh( + node, + node.key.toString(), + refreshingNodes?.flatMap((child) => + child.parent === node.key ? [child.key.toString()] : [], + ) ?? [], + ), + ); + }, + [handleRefresh, loadData, pageSize, refreshData], + ); + + const rawLoadData = (key?: Key) => { + return new Promise((resolve) => { + const asyncNode = asyncNodes?.nodes.find( + (node) => node.key === (key ?? ROOT_NODE_KEY), + ); + loadData({ cursor: asyncNode?.next_page, size: pageSize }, key).then( + (result) => { + const dataNodes = + result?.items?.flatMap((dn) => [ + { + ...dn, + title: () => ( + refreshCallback([k])} + /> + ), + parent: key ?? ROOT_NODE_KEY, + ...DEFAULT_NODE_PAGINATION_PROPS, + pageSize, + }, + ]) ?? []; + + updateAsyncNodes([ + ...dataNodes, + ...(asyncNode + ? [ + { + // update parent node's pagination + ...asyncNode, + pageSize, + total: result?.total ?? 0, + current_page: result?.current_page, + next_page: result?.next_page, + }, + ] + : []), + ]); + + resolve(result); + }, + ); + }); + }; + + /** + * @description This is the heart of the component that builds the tree data from our async state + */ + const recBuildTree = (node: AsyncTreeNode): TreeDataNode => ({ + ...node, + children: [ + ...(asyncNodes?.nodes + ?.flatMap((child) => + child.parent === node.key ? [recBuildTree(child)] : [], + ) + .sort((a, b) => a.key.toString().localeCompare(b.key.toString())) ?? + []), + ...(node.next_page + ? [ + { + key: `SHOW_MORE__${node.key}`, + title: () => ( + rawLoadData(node.key) }} + /> + ), + selectable: false, + icon: () => null, + isLeaf: true, + }, + ] + : []), + ], + }); + + /** + * @description initiates the tree construction + */ + const treeData: TreeProps["treeData"] = recBuildTree( + asyncNodes.nodes.find(({ key }) => key === ROOT_NODE_KEY) ?? + DEFAULT_ROOT_NODE, + ).children; + + const antLoadData: TreeProps["loadData"] = (args) => rawLoadData(args.key); + + useEffect(() => { + refreshCallback(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(queryKeys)]); + + useEffect(() => { + const initialize = async () => rawLoadData(); + initialize(); + + return () => { + setAsyncNodes([DEFAULT_ROOT_NODE]); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> + { + setExpandedKeys(newExpandedKeys); + }} + selectedKeys={selectedKeys} + /> + {showFooter ? ( + { + const selectedNodes = selectedKeys?.flatMap( + (selectedKey) => [ + asyncNodes.nodes.find((an) => an.key === selectedKey), + ], + ); + const selectedNodeDisabledActions = selectedNodes?.reduce( + (agg, current) => { + const disabledActions = current?.actions + ? Object.entries(current.actions)?.flatMap(() => + action.disabled?.([current]) ? [key] : [], + ) + : []; + return _.uniq([...agg, ...disabledActions]); + }, + [] as Array, + ); + + return [ + key, + { + ...action, + disabled: () => + selectedNodeDisabledActions?.includes(key) ?? false, + }, + ]; + }), + ) + : {}, + primaryAction: actions?.primaryAction ?? "", + }} + taxonomy={taxonomy} + /> + ) : null} + + ); +}; + +export default AsyncTree; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/types.ts b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/types.ts new file mode 100644 index 00000000000..43a5cdd16eb --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/types.ts @@ -0,0 +1,89 @@ +import { TreeDataNode, TreeProps } from "fidesui"; +import { Key } from "react"; + +import { + CursorPaginatedResponse, + CursorPaginationQueryParams, +} from "~/features/common/pagination"; + +export type ReactDataNode = T & { + key: Key; + children?: ReactDataNode[]; +}; + +type ATreeDataNode = Omit; + +export type AsyncTreeNode = + ReactDataNode & + Omit, "items"> & { + parent?: Key; + actions?: ActionDict; + }; + +export type Plural = [string, string]; + +export interface InternalTreeProps + extends Omit, "loadData" | "treeData"> { + loadData: ( + pagination: CursorPaginationQueryParams, + key?: Key, + ) => Promise | undefined>; + refreshData: ( + key?: Key, + childKeys?: Key[], + ) => Promise | undefined>; + queryKeys?: Key[]; + actions?: TreeActions; + pageSize?: number; + loadMoreText?: string; + showFooter?: boolean; + taxonomy?: Plural; +} + +export type AsyncTreeProps = Omit & { + pageSize?: number; + actions?: TreeActions; + loadMoreText?: string; + showFooter?: boolean; + taxonomy?: Plural; + loadData: ( + pagination: CursorPaginationQueryParams, + key?: Key, + ) => Promise | undefined>; +}; + +export type AsyncTreeNodeComponentProps = { + node: AsyncTreeNode; + actions?: ActionDict; + refreshCallback?: (key: Key) => void; +}; + +export type ParentMapNode = Omit, "items"> & { + key: Key; + parent?: Key; + children: Key[]; +}; + +type LeafMapNode = { + key: Key; + parent?: Key; +}; + +export type MapNode = ParentMapNode | LeafMapNode; + +export interface ActionDict + extends Record> {} + +export type TreeActions = { + nodeActions: AD; + primaryAction: keyof AD; +}; + +export type NodeAction = { + label: string; + /** TODO: should be generically typed * */ + callback: (keys: Key[], nodes: N[]) => Promise; + disabled: (nodes: N[]) => boolean; +}; + +export type TreeNodeAction = NodeAction; diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/utils.ts b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/utils.ts new file mode 100644 index 00000000000..e411a4e910d --- /dev/null +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/AsyncTree/utils.ts @@ -0,0 +1,36 @@ +// import { TreeDataNode } from "fidesui"; +import { Key } from "react"; + +// import { AsyncTreeNode } from "./types"; + +export type ReactDataNode = T & { + key: Key; + children?: ReactDataNode[]; +}; + +// const recBuildTree = ( +// node: AsyncTreeNode, +// nodes: AsyncTreeNode[], +// ): TreeDataNode => ({ +// ...node, +// children: [ +// ...nodes?.flatMap((child) => +// child.parent === node.key ? [recBuildTree(child, nodes)] : [], +// ), +// ...(node.total > node?.page * node.size +// ? [ +// { +// key: `SHOW_MORE__${node.key}`, +// // title: () => ( +// // _loadData(node.key) }} +// // /> +// // ), +// icon: () => null, +// isLeaf: true, +// }, +// ] +// : []), +// ], +// }); diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts index 1fc25940780..7257214a6dc 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/action-center.slice.ts @@ -130,6 +130,7 @@ const actionCenterApi = baseApi.injectEndpoints({ staged_resource_urn?: string; include_descendant_details?: boolean; diff_status?: DiffStatus[]; + child_staged_resource_urns?: string[]; } >({ query: ({ @@ -139,8 +140,12 @@ const actionCenterApi = baseApi.injectEndpoints({ staged_resource_urn, include_descendant_details, diff_status, + child_staged_resource_urns, }) => { - const urlParams = buildArrayQueryParams({ diff_status }); + const urlParams = buildArrayQueryParams({ + diff_status, + child_staged_resource_urns, + }); return { url: `/plus/discovery-monitor/${monitor_config_id}/tree?${urlParams}`, diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/MonitorTree.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/MonitorTree.tsx index b991a67b456..2f798571fb9 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/MonitorTree.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/MonitorTree.tsx @@ -9,6 +9,7 @@ import { Title, Tooltip, Tree, + TreeDataNode, } from "fidesui"; import { useRouter } from "next/router"; import { @@ -52,7 +53,7 @@ import { shouldShowBadgeDot, updateNodeStatus, } from "./treeUtils"; -import { CustomTreeDataNode, TreeNodeAction } from "./types"; +import { CustomTreeDataNode } from "./types"; import { intoDiffStatus } from "./utils"; const getIconComponent = ( @@ -244,13 +245,13 @@ export interface MonitorTreeRef { refreshResourcesAndAncestors: (urns: string[]) => Promise; } -interface NodeActions> { - nodeActions: ActionDict; - primaryAction: keyof ActionDict; -} +type TreeActions>> = { + nodeActions: AD; + primaryAction: keyof AD; +}; interface MonitorTreeProps - extends NodeActions> { + extends TreeActions>> { setSelectedNodeKeys: (keys: Key[]) => void; selectedNodeKeys: Key[]; } diff --git a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/page.tsx b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/page.tsx index 1a621a67bc7..11b1ac1fb54 100644 --- a/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/page.tsx +++ b/clients/admin-ui/src/features/data-discovery-and-detection/action-center/fields/page.tsx @@ -25,10 +25,11 @@ import { useGetDatastoreFiltersQuery, useGetMonitorConfigQuery, } from "~/features/data-discovery-and-detection/action-center/action-center.slice"; -import { DiffStatus, TreeResourceChangeIndicator } from "~/types/api"; +import { DiffStatus } from "~/types/api"; import { ConfidenceBucket } from "~/types/api/models/ConfidenceBucket"; import { FieldActionType } from "~/types/api/models/FieldActionType"; +import { AsyncMonitorTree } from "../AsyncMonitorTree"; import { MonitorFieldSearchForm, MonitorFieldSearchFormQuerySchema, @@ -58,7 +59,7 @@ import { FIELD_PAGE_SIZE, MAP_DIFF_STATUS_TO_RESOURCE_STATUS_LABEL, } from "./MonitorFields.const"; -import MonitorTree, { MonitorTreeRef } from "./MonitorTree"; +import { MonitorTreeRef } from "./MonitorTree"; import { ResourceDetailsDrawer } from "./ResourceDetailsDrawer"; import type { MonitorResource } from "./types"; import { useBulkActions } from "./useBulkActions"; @@ -269,10 +270,10 @@ const ActionCenterFields = ({ /** Note: style attr used here due to specificity of ant css. */ style={{ paddingRight: "var(--ant-padding-md)" }} > - - _(nodes) - .map((node) => { - if ( - (action === FieldActionType.PROMOTE_REMOVALS && - node.status === - TreeResourceChangeIndicator.REMOVAL) || - (action === FieldActionType.CLASSIFY && - node.classifyable && - node.diffStatus !== DiffStatus.MUTED) || - (action === FieldActionType.MUTE && - node.diffStatus !== DiffStatus.MUTED) || - (action === FieldActionType.UN_MUTE && - node.diffStatus === DiffStatus.MUTED) - ) { - return false; - } - - return true; - }) - .some((d) => d === true), - callback: (keys) => { - fieldActions[action](keys, false); + callback: async (_keys, dataNodes) => { + const nodeKeys = dataNodes.map((nodeKey) => + nodeKey.key.toString(), + ); + await fieldActions[action](nodeKeys, false); }, }, ]),