Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions clients/admin-ui/src/features/common/nav/nav-config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
},
],
});
}
Expand Down
1 change: 1 addition & 0 deletions clients/admin-ui/src/features/common/nav/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 8 additions & 0 deletions clients/admin-ui/src/features/common/pagination.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,11 @@ export interface CursorPaginationQueryParams {
cursor?: string | null;
size?: number | null;
}

export interface CursorPaginatedResponse<T> {
items?: Array<T>;
total?: number;
next_page?: string;
current_page?: string;
prev_page?: string;
}
Original file line number Diff line number Diff line change
@@ -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<Record<string, NodeAction<TreeDataNode>>> {
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 ? (
<Tooltip title={statusInfo?.tooltip}>
<Badge
className="h-full"
offset={[0, 5]}
color={statusInfo?.color}
dot={shouldShowBadgeDot(item)}
>
<IconComponent className="h-full" />
</Badge>
</Tooltip>
) : undefined;
},
isLeaf: item.resource_type === StagedResourceTypeValue.FIELD,
}));

return (
<div className="grid h-full grid-rows-[1fr_minmax(max-content,max-content)] gap-6 overflow-x-hidden">
<AsyncTree
loadData={({ cursor, size }, key) =>
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"
/>
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Skeleton } from "fidesui";

import { AsyncTreeNodeComponentProps } from "./types";

export const AsyncNodeSkeleton = ({ node }: AsyncTreeNodeComponentProps) => (
<Skeleton paragraph={false} title={{ width: "80px" }} active>
{typeof node.title === "function" ? node.title(node) : node.title}
</Skeleton>
);
Original file line number Diff line number Diff line change
@@ -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 */
<Flex
gap={4}
align="center"
className={`group ml-1 flex grow ${node.disabled ? "opacity-40" : ""}`}
aria-label={
node.disabled ? `${title.toString()} (ignored)` : title.toString()
}
>
<Text ellipsis={{ tooltip: title }} className="grow select-none">
{title}
</Text>
<Dropdown
menu={{
items: node.actions
? Object.entries(node.actions).map(
([key, { disabled, label }]) => ({
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"
>
<Button
aria-label="Show More Resource Actions"
icon={
<Icons.OverflowMenuVertical className="opacity-0 group-hover:opacity-100 group-[.ant-dropdown-open]:opacity-100" />
}
type="text"
size="small"
className="self-end"
/>
</Dropdown>
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { Button, Dropdown, Flex, Icons, SparkleIcon, Text } from "fidesui";
import { Key } from "react";

import { pluralize } from "~/features/common/utils";

import { ActionDict, Plural, TreeActions } from "./types";

export const AsyncTreeFooter = ({
selectedKeys,
actions,
taxonomy,
}: {
selectedKeys: Key[];
actions?: TreeActions<ActionDict>;
taxonomy: Plural;
}) => {
return (
<Flex justify="space-between" align="center" gap="small">
{actions ? (
<>
<Button
aria-label={`${actions.nodeActions[actions.primaryAction]} ${selectedKeys.length} selected ${pluralize(selectedKeys.length, ...taxonomy)}`}
/** TODO: add icons to the action definitions to render here * */
icon={<SparkleIcon size={12} />}
size="small"
onClick={() =>
actions.nodeActions[actions.primaryAction]?.callback(
selectedKeys,
[],
// selectedNodeKeys.flatMap((nodeKey) => {
// const node = findNodeByUrn(treeData, nodeKey.toString());
// return node ? [node] : [];
// }),
)
}
className="flex-none"
/>
<Text
ellipsis
>{`${selectedKeys.length} ${pluralize(selectedKeys.length, ...taxonomy)} selected`}</Text>
<Dropdown
menu={{
items: actions.nodeActions
? Object.entries(actions.nodeActions).map(
([key, { label, disabled }]) => ({
key,
label,
disabled: disabled?.([]),
}),
)
: [],
onClick: ({ key, domEvent }) => {
domEvent.preventDefault();
domEvent.stopPropagation();
actions.nodeActions[key]?.callback(selectedKeys, []);
},
}}
destroyOnHidden
className="group mr-1 flex-none"
>
<Button
aria-label={`Show more ${taxonomy[0]} actions`}
icon={<Icons.OverflowMenuVertical />}
size="small"
className="self-end"
/>
</Dropdown>
</>
) : null}
</Flex>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Button, ButtonProps } from "fidesui";

import { AsyncTreeNodeComponentProps } from "./types";

export const AsyncTreeDataLink = ({
node,
buttonProps,
}: Omit<AsyncTreeNodeComponentProps, "actions"> & {
buttonProps: ButtonProps;
}) => {
const { title, disabled } = node;
if (!title) {
return null;
}

return (
<Button
type="link"
block
disabled={disabled}
{...buttonProps}
className="p-0"
>
{typeof title === "function" ? title(node) : title}
</Button>
);
};
Loading
Loading