) => {
- draftState.logs = action.payload;
- },
- clearLogs: (draftState) => {
- draftState.logs = [];
},
},
});
-export const {
- startTest,
- setPrivacyRequestId,
- finishTest,
- interruptTest,
- setTestInputs,
- setCurrentPolicyKey,
- setCurrentDataset,
- setReachability,
- setTestResults,
- setLogs,
- clearLogs,
-} = datasetTestSlice.actions;
+export const { setCurrentPolicyKey, setCurrentDataset } =
+ datasetTestSlice.actions;
-export const selectPrivacyRequestId = (state: RootState) =>
- state.datasetTest.privacyRequestId;
-export const selectDatasetTestPrivacyRequestId = (state: RootState) =>
- state.datasetTest.privacyRequestId;
export const selectCurrentDataset = (state: RootState) =>
state.datasetTest.currentDataset;
-export const selectIsReachable = (state: RootState) =>
- state.datasetTest.isReachable;
-export const selectTestInputs = (state: RootState) => {
- const { currentDataset } = state.datasetTest;
- return currentDataset
- ? state.datasetTest.testInputs[currentDataset.fides_key] || {}
- : {};
-};
export const selectCurrentPolicyKey = (state: RootState) =>
state.datasetTest.currentPolicyKey;
-export const selectTestResults = (state: RootState) => {
- const { currentDataset } = state.datasetTest;
- return currentDataset
- ? state.datasetTest.testResults[currentDataset.fides_key] || ""
- : "";
-};
-export const selectIsTestRunning = (state: RootState) =>
- state.datasetTest.isTestRunning;
-export const selectLogs = (state: RootState) => state.datasetTest.logs;
export const { reducer } = datasetTestSlice;
diff --git a/clients/admin-ui/src/features/test-datasets/edges/DatasetTreeEdge.tsx b/clients/admin-ui/src/features/test-datasets/edges/DatasetTreeEdge.tsx
new file mode 100644
index 00000000000..7338486ed47
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/edges/DatasetTreeEdge.tsx
@@ -0,0 +1,53 @@
+import { BezierEdge, BezierEdgeProps } from "@xyflow/react";
+import palette from "fidesui/src/palette/palette.module.scss";
+import { useContext } from "react";
+
+import {
+ DatasetNodeHoverStatus,
+ DatasetTreeHoverContext,
+} from "../context/DatasetTreeHoverContext";
+
+interface DatasetTreeEdgeProps extends BezierEdgeProps {
+ target: string;
+}
+
+const getStrokeColor = (status: DatasetNodeHoverStatus): string => {
+ switch (status) {
+ case DatasetNodeHoverStatus.ACTIVE_HOVER:
+ case DatasetNodeHoverStatus.PARENT_OF_HOVER:
+ return palette.FIDESUI_MINOS;
+ case DatasetNodeHoverStatus.INACTIVE:
+ return palette.FIDESUI_NEUTRAL_400;
+ default:
+ return palette.FIDESUI_SANDSTONE;
+ }
+};
+
+const getStrokeWidth = (status: DatasetNodeHoverStatus): number => {
+ switch (status) {
+ case DatasetNodeHoverStatus.ACTIVE_HOVER:
+ case DatasetNodeHoverStatus.PARENT_OF_HOVER:
+ return 2;
+ default:
+ return 1;
+ }
+};
+
+const DatasetTreeEdge = (props: DatasetTreeEdgeProps) => {
+ const { getNodeHoverStatus } = useContext(DatasetTreeHoverContext);
+ const { target } = props;
+ const targetHoverStatus = getNodeHoverStatus(target);
+
+ return (
+
+ );
+};
+
+export default DatasetTreeEdge;
diff --git a/clients/admin-ui/src/features/test-datasets/nodes/DatasetCollectionNode.tsx b/clients/admin-ui/src/features/test-datasets/nodes/DatasetCollectionNode.tsx
new file mode 100644
index 00000000000..e904d559078
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/nodes/DatasetCollectionNode.tsx
@@ -0,0 +1,66 @@
+import { NodeProps } from "@xyflow/react";
+import { Button, Typography } from "fidesui";
+import { useContext } from "react";
+
+import DatasetEditorActionsContext from "../context/DatasetEditorActionsContext";
+import {
+ DatasetNodeHoverStatus,
+ DatasetTreeHoverContext,
+} from "../context/DatasetTreeHoverContext";
+import { CollectionNodeData } from "../useDatasetGraph";
+import styles from "./DatasetNode.module.scss";
+import DatasetNodeHandle from "./DatasetNodeHandle";
+import { getNodeHoverClass } from "./getNodeHoverClass";
+
+const DatasetCollectionNode = ({ data, id }: NodeProps) => {
+ const nodeData = data as CollectionNodeData;
+ const { onMouseEnter, onMouseLeave, getNodeHoverStatus } = useContext(
+ DatasetTreeHoverContext,
+ );
+ const actions = useContext(DatasetEditorActionsContext);
+ const hoverStatus = getNodeHoverStatus(id);
+
+ return (
+ onMouseEnter(id)}
+ onMouseLeave={() => onMouseLeave()}
+ >
+
+
+ {nodeData.isRoot && (
+
+ )}
+
+
+ );
+};
+
+export default DatasetCollectionNode;
diff --git a/clients/admin-ui/src/features/test-datasets/nodes/DatasetFieldNode.tsx b/clients/admin-ui/src/features/test-datasets/nodes/DatasetFieldNode.tsx
new file mode 100644
index 00000000000..87aab018827
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/nodes/DatasetFieldNode.tsx
@@ -0,0 +1,74 @@
+import { NodeProps } from "@xyflow/react";
+import { Button, Typography } from "fidesui";
+import { useContext } from "react";
+
+import DatasetEditorActionsContext from "../context/DatasetEditorActionsContext";
+import {
+ DatasetNodeHoverStatus,
+ DatasetTreeHoverContext,
+} from "../context/DatasetTreeHoverContext";
+import { FieldNodeData } from "../useDatasetGraph";
+import styles from "./DatasetNode.module.scss";
+import DatasetNodeHandle from "./DatasetNodeHandle";
+import { getNodeHoverClass } from "./getNodeHoverClass";
+
+const DatasetFieldNode = ({ data, id }: NodeProps) => {
+ const nodeData = data as FieldNodeData;
+ const categories = nodeData.field.data_categories ?? [];
+ const { onMouseEnter, onMouseLeave, getNodeHoverStatus } = useContext(
+ DatasetTreeHoverContext,
+ );
+ const actions = useContext(DatasetEditorActionsContext);
+ const hoverStatus = getNodeHoverStatus(id);
+
+ return (
+ onMouseEnter(id)}
+ onMouseLeave={() => onMouseLeave()}
+ >
+
+
+
+ {nodeData.hasChildren && (
+
+ )}
+
+ );
+};
+
+export default DatasetFieldNode;
diff --git a/clients/admin-ui/src/features/test-datasets/nodes/DatasetNode.module.scss b/clients/admin-ui/src/features/test-datasets/nodes/DatasetNode.module.scss
new file mode 100644
index 00000000000..d323e4a791a
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/nodes/DatasetNode.module.scss
@@ -0,0 +1,123 @@
+.container {
+ position: relative;
+ max-width: 240px;
+}
+
+.button {
+ transition:
+ background-color 300ms ease-in 0s,
+ color 300ms ease 0s,
+ border-color 300ms ease 0s,
+ box-shadow 300ms ease 0s;
+
+ height: 28px;
+ max-width: 240px;
+
+ --ant-color-text-disabled: var(--ant-color-text);
+
+ &--hover {
+ color: var(--ant-button-primary-color) !important;
+ background-color: var(--fidesui-minos) !important;
+ box-shadow:
+ 0px 1px 2px 0px rgba(0, 0, 0, 0.06),
+ 0px 1px 3px 0px rgba(0, 0, 0, 0.1);
+ }
+
+ &--parent-hover {
+ background-color: var(--fidesui-neutral-50);
+ border-color: var(--ant-button-default-border-color);
+ box-shadow:
+ 0px 1px 2px 0px rgba(0, 0, 0, 0.06),
+ 0px 1px 3px 0px rgba(0, 0, 0, 0.1);
+ }
+
+ &--inactive {
+ color: var(--fidesui-neutral-400);
+ }
+
+ &--protected {
+ font-style: italic;
+ opacity: 0.85;
+ border-color: var(--fidesui-warning);
+
+ &:hover {
+ border-color: var(--fidesui-warning) !important;
+ }
+ }
+}
+
+// Root node: slightly bolder
+.button--root {
+ height: 32px;
+ font-weight: 600;
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 10px;
+ line-height: 14px;
+ padding: 0 4px;
+ border-radius: 4px;
+ margin-left: 4px;
+ color: white;
+ background-color: var(--fidesui-minos);
+}
+
+.badge--warning {
+ background-color: var(--fidesui-warning);
+ font-size: 9px;
+}
+
+.badge--muted {
+ background-color: var(--fidesui-neutral-400);
+ color: white;
+}
+
+.add-button {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ border: 1px solid var(--fidesui-neutral-300);
+ background-color: white;
+ color: var(--fidesui-neutral-600);
+ font-size: 12px;
+ line-height: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 200ms ease;
+ z-index: 10;
+
+ &:hover {
+ background-color: var(--fidesui-minos);
+ color: white;
+ border-color: var(--fidesui-minos);
+ }
+}
+
+.container:hover .add-button,
+.container:focus-within .add-button {
+ opacity: 1;
+}
+
+@keyframes highlight-fade {
+ 0% {
+ background-color: var(--fidesui-bg-caution);
+ border-color: var(--fidesui-warning);
+ }
+ 100% {
+ background-color: transparent;
+ border-color: var(--ant-button-default-border-color);
+ }
+}
+
+.button--highlighted {
+ animation: highlight-fade 1.5s ease-out forwards;
+}
diff --git a/clients/admin-ui/src/features/test-datasets/nodes/DatasetNodeHandle.tsx b/clients/admin-ui/src/features/test-datasets/nodes/DatasetNodeHandle.tsx
new file mode 100644
index 00000000000..6ff32aa9fdc
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/nodes/DatasetNodeHandle.tsx
@@ -0,0 +1,27 @@
+import { Handle, HandleType, Position } from "@xyflow/react";
+import palette from "fidesui/src/palette/palette.module.scss";
+
+interface DatasetNodeHandleProps {
+ type: HandleType;
+ inactive?: boolean;
+}
+
+const DatasetNodeHandle = ({
+ type,
+ inactive = false,
+}: DatasetNodeHandleProps) => (
+
+);
+
+export default DatasetNodeHandle;
diff --git a/clients/admin-ui/src/features/test-datasets/nodes/DatasetRootNode.tsx b/clients/admin-ui/src/features/test-datasets/nodes/DatasetRootNode.tsx
new file mode 100644
index 00000000000..22fa6724806
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/nodes/DatasetRootNode.tsx
@@ -0,0 +1,44 @@
+import { NodeProps } from "@xyflow/react";
+import { Button, Typography } from "fidesui";
+import { useContext } from "react";
+
+import {
+ DatasetNodeHoverStatus,
+ DatasetTreeHoverContext,
+} from "../context/DatasetTreeHoverContext";
+import { DATASET_ROOT_ID } from "../useDatasetGraph";
+import styles from "./DatasetNode.module.scss";
+import DatasetNodeHandle from "./DatasetNodeHandle";
+import { getNodeHoverClass } from "./getNodeHoverClass";
+
+const DatasetRootNode = ({ data, id }: NodeProps) => {
+ const nodeData = data as { label: string };
+ const { onMouseEnter, onMouseLeave, getNodeHoverStatus } = useContext(
+ DatasetTreeHoverContext,
+ );
+ const hoverStatus = getNodeHoverStatus(id);
+
+ return (
+ onMouseEnter(id)}
+ onMouseLeave={() => onMouseLeave()}
+ >
+
+
+
+ );
+};
+
+export default DatasetRootNode;
diff --git a/clients/admin-ui/src/features/test-datasets/nodes/getNodeHoverClass.ts b/clients/admin-ui/src/features/test-datasets/nodes/getNodeHoverClass.ts
new file mode 100644
index 00000000000..d7a97fac047
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/nodes/getNodeHoverClass.ts
@@ -0,0 +1,21 @@
+import { DatasetNodeHoverStatus } from "../context/DatasetTreeHoverContext";
+import styles from "./DatasetNode.module.scss";
+
+export const getNodeHoverClass = (
+ status: DatasetNodeHoverStatus,
+ options?: { isProtected?: boolean },
+): string => {
+ if (options?.isProtected) {
+ return styles["button--protected"] || "";
+ }
+ switch (status) {
+ case DatasetNodeHoverStatus.ACTIVE_HOVER:
+ return styles["button--hover"] || "";
+ case DatasetNodeHoverStatus.PARENT_OF_HOVER:
+ return styles["button--parent-hover"] || "";
+ case DatasetNodeHoverStatus.INACTIVE:
+ return styles["button--inactive"] || "";
+ default:
+ return "";
+ }
+};
diff --git a/clients/admin-ui/src/features/test-datasets/useDatasetGraph.ts b/clients/admin-ui/src/features/test-datasets/useDatasetGraph.ts
new file mode 100644
index 00000000000..7d17b71bd08
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/useDatasetGraph.ts
@@ -0,0 +1,203 @@
+import { Edge, Node } from "@xyflow/react";
+import { useMemo } from "react";
+
+import { Dataset, DatasetCollection, DatasetField } from "~/types/api";
+
+export const DATASET_ROOT_ID = "dataset-root";
+export const COLLECTION_ROOT_PREFIX = "collection-";
+
+export type CollectionNodeData = {
+ label: string;
+ collection: DatasetCollection;
+ nodeType: "collection";
+ isProtected?: boolean;
+ isRoot?: boolean;
+ [key: string]: unknown;
+};
+
+export type FieldNodeData = {
+ label: string;
+ field: DatasetField;
+ collectionName: string;
+ fieldPath: string;
+ nodeType: "field";
+ isProtected?: boolean;
+ hasChildren: boolean;
+ [key: string]: unknown;
+};
+
+export interface ProtectedFieldsInfo {
+ immutable_fields: string[];
+ protected_collection_fields: Array<{
+ collection: string;
+ field: string;
+ }>;
+}
+
+/**
+ * Recursively build nodes and edges for a field and its nested sub-fields.
+ */
+const buildFieldNodes = (
+ fields: DatasetField[],
+ parentId: string,
+ collectionName: string,
+ pathPrefix: string,
+ protectedPaths: Set,
+ nodes: Node[],
+ edges: Edge[],
+) => {
+ (Array.isArray(fields) ? fields : []).filter(Boolean).forEach((field) => {
+ const fieldPath = pathPrefix ? `${pathPrefix}.${field.name}` : field.name;
+ const nodeId = `field-${collectionName}-${fieldPath}`;
+ const hasChildren = !!(field.fields && field.fields.length > 0);
+
+ nodes.push({
+ id: nodeId,
+ position: { x: 0, y: 0 },
+ type: "datasetFieldNode",
+ data: {
+ label: field.name,
+ field,
+ collectionName,
+ fieldPath,
+ nodeType: "field",
+ isProtected: protectedPaths.has(fieldPath),
+ hasChildren,
+ } satisfies FieldNodeData,
+ });
+
+ edges.push({
+ id: `${parentId}->${nodeId}`,
+ source: parentId,
+ target: nodeId,
+ type: "datasetTreeEdge",
+ });
+
+ if (field.fields?.length) {
+ buildFieldNodes(
+ field.fields,
+ nodeId,
+ collectionName,
+ fieldPath,
+ protectedPaths,
+ nodes,
+ edges,
+ );
+ }
+ });
+};
+
+/**
+ * Convert a Dataset into React Flow nodes and edges for visualization.
+ *
+ * When `focusedCollection` is null, shows the overview: root → collection nodes.
+ * When `focusedCollection` is set, shows drill-down: collection root → field nodes.
+ */
+const useDatasetGraph = (
+ dataset: Dataset | undefined,
+ protectedFields?: ProtectedFieldsInfo,
+ focusedCollection?: string | null,
+) => {
+ return useMemo(() => {
+ if (!dataset) {
+ return { nodes: [], edges: [] };
+ }
+
+ const nodes: Node[] = [];
+ const edges: Edge[] = [];
+
+ // Build protected paths lookup per collection
+ const protectedByCollection = new Map>();
+ if (protectedFields) {
+ protectedFields.protected_collection_fields.forEach((pf) => {
+ if (!protectedByCollection.has(pf.collection)) {
+ protectedByCollection.set(pf.collection, new Set());
+ }
+ const pathSet = protectedByCollection.get(pf.collection)!;
+ const segments = pf.field.split(".");
+ segments.forEach((_, idx) => {
+ pathSet.add(segments.slice(0, idx + 1).join("."));
+ });
+ });
+ }
+
+ if (focusedCollection) {
+ // --- Drill-down view: single collection → its fields ---
+ const collection = (
+ Array.isArray(dataset.collections) ? dataset.collections : []
+ )
+ .filter(Boolean)
+ .find((c) => c.name === focusedCollection);
+ if (!collection) {
+ return { nodes: [], edges: [] };
+ }
+
+ const collectionId = `${COLLECTION_ROOT_PREFIX}${collection.name}`;
+ const protectedPaths =
+ protectedByCollection.get(collection.name) ?? new Set();
+
+ // Collection as root of this view
+ nodes.push({
+ id: collectionId,
+ position: { x: 0, y: 0 },
+ type: "datasetCollectionNode",
+ data: {
+ label: collection.name,
+ collection,
+ nodeType: "collection",
+ isProtected: false,
+ isRoot: true,
+ } satisfies CollectionNodeData,
+ });
+
+ buildFieldNodes(
+ collection.fields,
+ collectionId,
+ collection.name,
+ "",
+ protectedPaths,
+ nodes,
+ edges,
+ );
+ } else {
+ // --- Overview: root → collections only ---
+ nodes.push({
+ id: DATASET_ROOT_ID,
+ position: { x: 0, y: 0 },
+ type: "datasetRootNode",
+ data: {
+ label: dataset.name || dataset.fides_key,
+ },
+ });
+
+ (Array.isArray(dataset.collections) ? dataset.collections : [])
+ .filter(Boolean)
+ .forEach((collection) => {
+ const collectionId = `${COLLECTION_ROOT_PREFIX}${collection.name}`;
+
+ nodes.push({
+ id: collectionId,
+ position: { x: 0, y: 0 },
+ type: "datasetCollectionNode",
+ data: {
+ label: collection.name,
+ collection,
+ nodeType: "collection",
+ isProtected: false,
+ } satisfies CollectionNodeData,
+ });
+
+ edges.push({
+ id: `${DATASET_ROOT_ID}->${collectionId}`,
+ source: DATASET_ROOT_ID,
+ target: collectionId,
+ type: "datasetTreeEdge",
+ });
+ });
+ }
+
+ return { nodes, edges };
+ }, [dataset, protectedFields, focusedCollection]);
+};
+
+export default useDatasetGraph;
diff --git a/clients/admin-ui/src/features/test-datasets/useDatasetNodeLayout.ts b/clients/admin-ui/src/features/test-datasets/useDatasetNodeLayout.ts
new file mode 100644
index 00000000000..8750ecc184c
--- /dev/null
+++ b/clients/admin-ui/src/features/test-datasets/useDatasetNodeLayout.ts
@@ -0,0 +1,61 @@
+import { Edge, Node } from "@xyflow/react";
+import { stratify, tree } from "d3-hierarchy";
+import { useMemo } from "react";
+
+interface UseDatasetNodeLayoutProps {
+ nodes: Node[];
+ edges: Edge[];
+ options: {
+ direction: "TB" | "LR";
+ };
+}
+
+// Horizontal gap between parent→child ranks; vertical gap between siblings
+const NODE_WIDTH = 300;
+const NODE_HEIGHT = 44;
+
+const layoutTree = tree();
+
+const useDatasetNodeLayout = ({
+ nodes,
+ edges,
+ options,
+}: UseDatasetNodeLayoutProps) => {
+ const layouted = useMemo(() => {
+ if (nodes.length === 0) {
+ return { nodes, edges };
+ }
+
+ const hierarchy = stratify()
+ .id((node) => node.id)
+ .parentId(
+ (node) => edges.find((edge) => edge.target === node.id)?.source,
+ );
+
+ const root = hierarchy(nodes);
+
+ const nodeSizes: [number, number] =
+ options.direction === "LR"
+ ? [NODE_HEIGHT, NODE_WIDTH]
+ : [NODE_WIDTH, NODE_HEIGHT];
+
+ const layout = layoutTree
+ .nodeSize(nodeSizes)
+ .separation((a, b) => (a.parent === b.parent ? 1 : 1.6))(root);
+
+ return {
+ nodes: layout.descendants().map((node) => {
+ const position =
+ options.direction === "LR"
+ ? { x: node.y, y: node.x }
+ : { x: node.x, y: node.y };
+ return { ...node.data, position };
+ }),
+ edges,
+ };
+ }, [nodes, edges, options]);
+
+ return layouted;
+};
+
+export default useDatasetNodeLayout;
diff --git a/clients/admin-ui/src/pages/systems/configure/[id]/test-datasets.tsx b/clients/admin-ui/src/pages/systems/configure/[id]/test-datasets.tsx
index 921d508ba9d..fb59b61e3b3 100644
--- a/clients/admin-ui/src/pages/systems/configure/[id]/test-datasets.tsx
+++ b/clients/admin-ui/src/pages/systems/configure/[id]/test-datasets.tsx
@@ -7,8 +7,6 @@ import { SYSTEM_ROUTE } from "~/features/common/nav/routes";
import PageHeader from "~/features/common/PageHeader";
import { useGetSystemByFidesKeyQuery } from "~/features/system";
import EditorSection from "~/features/test-datasets/DatasetEditorSection";
-import TestLogsSection from "~/features/test-datasets/TestLogsSection";
-import TestResultsSection from "~/features/test-datasets/TestRunnerSection";
// Helper functions
const getSystemId = (query: { id?: string | string[] }): string => {
@@ -27,7 +25,9 @@ const TestDatasetPage: NextPage = () => {
skip: !systemId,
});
- const connectionKey = system?.connection_configs?.[0]?.key || "";
+ const connectionConfig = system?.connection_configs?.[0];
+ const connectionKey = connectionConfig?.key || "";
+ const connectionType = connectionConfig?.connection_type;
if (isLoading) {
return (
@@ -52,22 +52,14 @@ const TestDatasetPage: NextPage = () => {
title: system?.name || "",
href: `/systems/configure/${systemId}#integrations`,
},
- { title: "Test datasets" },
+ { title: "Edit dataset" },
]}
/>
-
-
-
-
-
-
-
+
);