diff --git a/clients/admin-ui/__tests__/features/test-datasets/dataset-field-helpers.test.ts b/clients/admin-ui/__tests__/features/test-datasets/dataset-field-helpers.test.ts new file mode 100644 index 00000000000..cbd393b4823 --- /dev/null +++ b/clients/admin-ui/__tests__/features/test-datasets/dataset-field-helpers.test.ts @@ -0,0 +1,148 @@ +import { + addNestedField, + getFieldsAtPath, + removeFieldAtPath, + updateFieldAtPath, +} from "~/features/test-datasets/dataset-field-helpers"; + +import { DatasetField } from "~/types/api"; + +const makeField = ( + name: string, + children?: DatasetField[], +): DatasetField => ({ + name, + ...(children ? { fields: children } : {}), +}); + +describe("dataset-field-helpers", () => { + describe("updateFieldAtPath", () => { + it("updates a top-level field", () => { + const fields = [makeField("a"), makeField("b")]; + const result = updateFieldAtPath(fields, ["b"], { + description: "updated", + }); + expect(result[0]).toEqual(fields[0]); + expect(result[1]).toEqual({ name: "b", description: "updated" }); + }); + + it("updates a nested field", () => { + const fields = [makeField("a", [makeField("b", [makeField("c")])])]; + const result = updateFieldAtPath(fields, ["a", "b", "c"], { + description: "deep", + }); + expect(result[0].fields![0].fields![0]).toEqual({ + name: "c", + description: "deep", + }); + }); + + it("leaves non-matching fields untouched", () => { + const fields = [makeField("a"), makeField("b")]; + const result = updateFieldAtPath(fields, ["a"], { + description: "only a", + }); + expect(result[1]).toBe(fields[1]); + }); + + it("handles missing path gracefully", () => { + const fields = [makeField("a")]; + const result = updateFieldAtPath(fields, ["nonexistent"], { + description: "x", + }); + expect(result).toEqual(fields); + }); + }); + + describe("getFieldsAtPath", () => { + it("returns children of a top-level field", () => { + const child1 = makeField("c1"); + const child2 = makeField("c2"); + const fields = [makeField("parent", [child1, child2])]; + const result = getFieldsAtPath(fields, ["parent"]); + expect(result).toEqual([child1, child2]); + }); + + it("returns children of a nested field", () => { + const leaf = makeField("leaf"); + const fields = [makeField("a", [makeField("b", [leaf])])]; + const result = getFieldsAtPath(fields, ["a", "b"]); + expect(result).toEqual([leaf]); + }); + + it("returns empty array for missing path", () => { + const fields = [makeField("a")]; + expect(getFieldsAtPath(fields, ["missing"])).toEqual([]); + }); + + it("returns empty array for field with no children", () => { + const fields = [makeField("a")]; + expect(getFieldsAtPath(fields, ["a"])).toEqual([]); + }); + }); + + describe("addNestedField", () => { + it("adds a child to a top-level field", () => { + const fields = [makeField("parent", [makeField("existing")])]; + const newField = makeField("new_child"); + const result = addNestedField(fields, ["parent"], newField); + expect(result[0].fields).toHaveLength(2); + expect(result[0].fields![1]).toEqual(newField); + }); + + it("adds a child to a deeply nested field", () => { + const fields = [makeField("a", [makeField("b", [])])]; + const newField = makeField("c"); + const result = addNestedField(fields, ["a", "b"], newField); + expect(result[0].fields![0].fields).toEqual([newField]); + }); + + it("creates fields array if parent has none", () => { + const fields = [makeField("parent")]; + const newField = makeField("child"); + const result = addNestedField(fields, ["parent"], newField); + expect(result[0].fields).toEqual([newField]); + }); + + it("does not modify non-matching siblings", () => { + const sibling = makeField("sibling"); + const fields = [sibling, makeField("target", [])]; + const newField = makeField("child"); + const result = addNestedField(fields, ["target"], newField); + expect(result[0]).toBe(sibling); + }); + }); + + describe("removeFieldAtPath", () => { + it("removes a top-level field", () => { + const fields = [makeField("a"), makeField("b"), makeField("c")]; + const result = removeFieldAtPath(fields, ["b"]); + expect(result).toHaveLength(2); + expect(result.map((f) => f.name)).toEqual(["a", "c"]); + }); + + it("removes a nested field", () => { + const fields = [ + makeField("a", [makeField("b"), makeField("c")]), + ]; + const result = removeFieldAtPath(fields, ["a", "b"]); + expect(result[0].fields).toHaveLength(1); + expect(result[0].fields![0].name).toBe("c"); + }); + + it("removes a deeply nested field", () => { + const fields = [ + makeField("a", [makeField("b", [makeField("c"), makeField("d")])]), + ]; + const result = removeFieldAtPath(fields, ["a", "b", "c"]); + expect(result[0].fields![0].fields).toHaveLength(1); + expect(result[0].fields![0].fields![0].name).toBe("d"); + }); + + it("returns unchanged array if field not found", () => { + const fields = [makeField("a")]; + const result = removeFieldAtPath(fields, ["nonexistent"]); + expect(result).toEqual(fields); + }); + }); +}); diff --git a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx index 939f7ce25fc..20e0048855e 100644 --- a/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx +++ b/clients/admin-ui/src/features/datastore-connections/system_portal_config/forms/ConnectorParametersForm.tsx @@ -290,10 +290,11 @@ export const ConnectorParametersForm = ({ ) : null} {isPlusEnabled && - SystemType.DATABASE === connectionOption.type && + (SystemType.DATABASE === connectionOption.type || + SystemType.SAAS === connectionOption.type) && !_.isEmpty(initialDatasets) && ( )} {connectionOption.authorization_required && !authorized ? ( diff --git a/clients/admin-ui/src/features/test-datasets/AddNodeModal.tsx b/clients/admin-ui/src/features/test-datasets/AddNodeModal.tsx new file mode 100644 index 00000000000..677b5e99044 --- /dev/null +++ b/clients/admin-ui/src/features/test-datasets/AddNodeModal.tsx @@ -0,0 +1,139 @@ +import { Button, Collapse, Drawer, Form, Input } from "fidesui"; +import { useEffect } from "react"; + +import { DatasetField } from "~/types/api"; + +import FieldMetadataFormItems, { + buildFieldMeta, + DataCategoryTagSelect, +} from "./FieldMetadataFormItems"; + +interface AddNodeModalProps { + open: boolean; + title: string; + existingNames: string[]; + /** "collection" shows only name; "field" shows name + metadata */ + mode?: "collection" | "field"; + onConfirm: (name: string, fieldData?: Partial) => void; + onCancel: () => void; +} + +const buildFieldData = ( + values: Record, +): Partial | undefined => { + const description = (values.description as string) || undefined; + const categories = values.data_categories as string[] | undefined; + const dataCategories = + categories && categories.length > 0 ? categories : undefined; + const fidesMeta = buildFieldMeta(values); + + if (!description && !dataCategories && !fidesMeta) { + return undefined; + } + + return { + description, + data_categories: dataCategories, + fides_meta: fidesMeta, + }; +}; + +const AddNodeModal = ({ + open, + title, + existingNames, + mode = "collection", + onConfirm, + onCancel, +}: AddNodeModalProps) => { + const [form] = Form.useForm(); + + useEffect(() => { + if (open) { + form.resetFields(); + } + }, [open, form]); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + const name = values.name.trim(); + if (mode === "field") { + onConfirm(name, buildFieldData(values)); + } else { + onConfirm(name); + } + } catch { + // validation failed + } + }; + + return ( + +
+ { + if (value && existingNames.includes(value.trim())) { + return Promise.reject( + new Error("A node with this name already exists"), + ); + } + return Promise.resolve(); + }, + }, + { + pattern: /^[a-zA-Z0-9_]+$/, + message: + "Name must contain only letters, numbers, and underscores", + }, + ]} + > + + + + {mode === "field" && ( + <> + + + + + + + + + , + }, + ]} + /> + + )} + +
+ +
+ +
+ ); +}; + +export default AddNodeModal; diff --git a/clients/admin-ui/src/features/test-datasets/DatasetEditorSection.tsx b/clients/admin-ui/src/features/test-datasets/DatasetEditorSection.tsx index 3bf7cae83de..33e72f5cf1a 100644 --- a/clients/admin-ui/src/features/test-datasets/DatasetEditorSection.tsx +++ b/clients/admin-ui/src/features/test-datasets/DatasetEditorSection.tsx @@ -12,7 +12,7 @@ import { useMessage, } from "fidesui"; import yaml, { YAMLException } from "js-yaml"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; import ClipboardButton from "~/features/common/ClipboardButton"; @@ -23,18 +23,19 @@ import { useGetConnectionConfigDatasetConfigsQuery, useGetDatasetReachabilityQuery, } from "~/features/datastore-connections"; -import { Dataset } from "~/types/api"; +import { ConnectionType, Dataset } from "~/types/api"; import { selectCurrentDataset, selectCurrentPolicyKey, setCurrentDataset, - setReachability, } from "./dataset-test.slice"; +import DatasetNodeEditor from "./DatasetNodeEditor"; import { removeNulls } from "./helpers"; interface EditorSectionProps { connectionKey: string; + connectionType?: ConnectionType; } const getReachabilityMessage = (details: any) => { @@ -52,11 +53,17 @@ const getReachabilityMessage = (details: any) => { return details; }; -const EditorSection = ({ connectionKey }: EditorSectionProps) => { +const EditorSection = ({ + connectionKey, + connectionType, +}: EditorSectionProps) => { const messageApi = useMessage(); const dispatch = useAppDispatch(); const [updateDataset] = useUpdateDatasetMutation(); + const isSaas = connectionType === ConnectionType.SAAS; + + const [localDataset, setLocalDataset] = useState(); const [editorContent, setEditorContent] = useState(""); const currentDataset = useAppSelector(selectCurrentDataset); const currentPolicyKey = useAppSelector(selectCurrentPolicyKey); @@ -77,16 +84,14 @@ const EditorSection = ({ connectionKey }: EditorSectionProps) => { policyKey: currentPolicyKey, }, { - skip: !connectionKey || !currentDataset?.fides_key || !currentPolicyKey, + skip: + isSaas || + !connectionKey || + !currentDataset?.fides_key || + !currentPolicyKey, }, ); - useEffect(() => { - if (reachability) { - dispatch(setReachability(reachability.reachable)); - } - }, [reachability, dispatch]); - const datasetOptions = useMemo( () => (datasetConfigs?.items || []).map((item) => ({ @@ -110,29 +115,35 @@ const EditorSection = ({ connectionKey }: EditorSectionProps) => { } }, [datasetConfigs, currentDataset, dispatch]); + // SaaS: store as Dataset object; DB: store as YAML string useEffect(() => { if (currentDataset?.ctl_dataset) { - setEditorContent(yaml.dump(removeNulls(currentDataset?.ctl_dataset))); + const cleaned = removeNulls(currentDataset.ctl_dataset); + if (isSaas) { + setLocalDataset(cleaned as Dataset); + } else { + setEditorContent(yaml.dump(cleaned)); + } } - }, [currentDataset]); + }, [currentDataset, isSaas]); useEffect(() => { - if (currentPolicyKey && currentDataset?.fides_key && connectionKey) { + if ( + !isSaas && + currentPolicyKey && + currentDataset?.fides_key && + connectionKey + ) { refetchReachability(); } }, [ + isSaas, currentPolicyKey, currentDataset?.fides_key, connectionKey, refetchReachability, ]); - useEffect(() => { - if (reachability) { - dispatch(setReachability(reachability.reachable)); - } - }, [reachability, dispatch]); - const handleDatasetChange = async (value: string) => { const selectedConfig = datasetConfigs?.items.find( (item) => item.fides_key === value, @@ -142,27 +153,36 @@ const EditorSection = ({ connectionKey }: EditorSectionProps) => { } }; + const handleLocalDatasetChange = useCallback((updated: Dataset) => { + setLocalDataset(updated); + }, []); + const handleSave = async () => { if (!currentDataset) { return; } - // Parse YAML first let datasetValues: Dataset; - try { - datasetValues = yaml.load(editorContent) as Dataset; - } catch (yamlError) { - messageApi.error( - `YAML Parsing Error: ${ - yamlError instanceof YAMLException - ? `${yamlError.reason} ${yamlError.mark ? `at line ${yamlError.mark.line}` : ""}` - : "Invalid YAML format" - }`, - ); - return; + if (isSaas) { + if (!localDataset) { + return; + } + datasetValues = localDataset; + } else { + try { + datasetValues = yaml.load(editorContent) as Dataset; + } catch (yamlError) { + messageApi.error( + `YAML Parsing Error: ${ + yamlError instanceof YAMLException + ? `${yamlError.reason} ${yamlError.mark ? `at line ${yamlError.mark.line}` : ""}` + : "Invalid YAML format" + }`, + ); + return; + } } - // Then handle the API update const result = await updateDataset(datasetValues); if (isErrorResult(result)) { @@ -178,7 +198,9 @@ const EditorSection = ({ connectionKey }: EditorSectionProps) => { ); messageApi.success("Successfully modified dataset"); await refetchDatasets(); - await refetchReachability(); + if (!isSaas) { + await refetchReachability(); + } }; const handleRefresh = async () => { @@ -188,7 +210,12 @@ const EditorSection = ({ connectionKey }: EditorSectionProps) => { (item) => item.fides_key === currentDataset?.fides_key, ); if (refreshedDataset?.ctl_dataset) { - setEditorContent(yaml.dump(removeNulls(refreshedDataset.ctl_dataset))); + const cleaned = removeNulls(refreshedDataset.ctl_dataset); + if (isSaas) { + setLocalDataset(cleaned as Dataset); + } else { + setEditorContent(yaml.dump(cleaned)); + } } messageApi.success("Successfully refreshed datasets"); } catch (error) { @@ -201,12 +228,14 @@ const EditorSection = ({ connectionKey }: EditorSectionProps) => { align="stretch" flex="1" gap="small" - className="max-h-screen max-w-[70vw]" vertical + style={{ height: "100%", minHeight: 0 }} > - Edit dataset: + + Edit dataset: + + + + + + + + + + + + {nodeData.nodeType === "collection" && ( + + + + + + + + + ), + }, + ]} + /> + )} + + {nodeData.nodeType === "field" && ( + , + }, + ]} + /> + )} + + {isProtected && ( + + This field is protected and cannot be deleted, but you can edit + its metadata. + + )} + + {!isProtected && ( +
+ +
+ )} + + )} + + ); +}; + +export default DatasetNodeDetailPanel; diff --git a/clients/admin-ui/src/features/test-datasets/DatasetNodeEditor.tsx b/clients/admin-ui/src/features/test-datasets/DatasetNodeEditor.tsx new file mode 100644 index 00000000000..e4ab9670978 --- /dev/null +++ b/clients/admin-ui/src/features/test-datasets/DatasetNodeEditor.tsx @@ -0,0 +1,633 @@ +import "@xyflow/react/dist/style.css"; + +import { + Background, + BackgroundVariant, + Controls, + EdgeTypes, + MiniMap, + Node, + NodeTypes, + ReactFlow, + ReactFlowProvider, + useReactFlow, +} from "@xyflow/react"; +import { Breadcrumb, Button, Flex, Icons, Typography } from "fidesui"; +import palette from "fidesui/src/palette/palette.module.scss"; +import yaml, { YAMLException } from "js-yaml"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { Editor } from "~/features/common/yaml/helpers"; +import { Dataset, DatasetCollection, DatasetField } from "~/types/api"; + +import AddNodeModal from "./AddNodeModal"; +import DatasetEditorActionsContext, { + DatasetEditorActions, +} from "./context/DatasetEditorActionsContext"; +import { + addNestedField, + getFieldsAtPath, + removeFieldAtPath, + updateFieldAtPath, +} from "./dataset-field-helpers"; +import { DatasetTreeHoverProvider } from "./context/DatasetTreeHoverContext"; +import DatasetNodeDetailPanel from "./DatasetNodeDetailPanel"; +import DatasetTreeEdge from "./edges/DatasetTreeEdge"; +import { removeNulls } from "./helpers"; +import DatasetCollectionNode from "./nodes/DatasetCollectionNode"; +import DatasetFieldNode from "./nodes/DatasetFieldNode"; +import DatasetRootNode from "./nodes/DatasetRootNode"; +import useDatasetGraph, { + COLLECTION_ROOT_PREFIX, + CollectionNodeData, + DATASET_ROOT_ID, + FieldNodeData, + ProtectedFieldsInfo, +} from "./useDatasetGraph"; +import useDatasetNodeLayout from "./useDatasetNodeLayout"; + +const LAYOUT_OPTIONS = { direction: "LR" } as const; + +interface DatasetNodeEditorProps { + dataset: Dataset; + protectedFields?: ProtectedFieldsInfo; + onDatasetChange: (dataset: Dataset) => void; +} + +const nodeTypes: NodeTypes = { + datasetRootNode: DatasetRootNode, + datasetCollectionNode: DatasetCollectionNode, + datasetFieldNode: DatasetFieldNode, +}; + +const edgeTypes: EdgeTypes = { + datasetTreeEdge: DatasetTreeEdge, +}; + +interface AddModalState { + open: boolean; + title: string; + existingNames: string[]; + mode: "collection" | "field"; + onConfirm: (name: string, fieldData?: Partial) => void; +} + +const CLOSED_MODAL: AddModalState = { + open: false, + title: "", + existingNames: [], + mode: "collection", + onConfirm: () => {}, +}; + +const DatasetNodeEditorInner = ({ + dataset, + protectedFields, + onDatasetChange, +}: DatasetNodeEditorProps) => { + const reactFlowInstance = useReactFlow(); + const reactFlowRef = useRef(null); + + const [focusedCollection, setFocusedCollection] = useState( + null, + ); + const [selectedNodeId, setSelectedNodeId] = useState(null); + const [addModal, setAddModal] = useState(CLOSED_MODAL); + const [highlightedNodeId, setHighlightedNodeId] = useState( + null, + ); + const highlightTimerRef = useRef | null>(null); + + // --- YAML editor state --- + const [yamlPanelOpen, setYamlPanelOpen] = useState(false); + const [yamlContent, setYamlContent] = useState(""); + const [yamlError, setYamlError] = useState(null); + // Tracks who initiated the last change to prevent sync loops + const changeSourceRef = useRef<"graph" | "yaml" | null>(null); + const yamlDebounceRef = useRef | null>(null); + + const highlightNode = useCallback((nodeId: string) => { + if (highlightTimerRef.current) { + clearTimeout(highlightTimerRef.current); + } + setHighlightedNodeId(nodeId); + highlightTimerRef.current = setTimeout(() => { + setHighlightedNodeId(null); + highlightTimerRef.current = null; + }, 1500); + }, []); + + // Sync dataset → YAML when the graph side changes + useEffect(() => { + if (!yamlPanelOpen) { + return; + } + if (changeSourceRef.current === "yaml") { + changeSourceRef.current = null; + return; + } + const cleaned = removeNulls(dataset); + setYamlContent(yaml.dump(cleaned)); + setYamlError(null); + }, [dataset, yamlPanelOpen]); + + // Initialize YAML content when panel opens + const handleToggleYamlPanel = useCallback(() => { + setYamlPanelOpen((prev) => { + if (!prev) { + const cleaned = removeNulls(dataset); + setYamlContent(yaml.dump(cleaned)); + setYamlError(null); + } + return !prev; + }); + }, [dataset]); + + // Handle YAML editor changes with debounce + const handleYamlChange = useCallback( + (value: string | undefined) => { + const newValue = value || ""; + setYamlContent(newValue); + + if (yamlDebounceRef.current) { + clearTimeout(yamlDebounceRef.current); + } + + yamlDebounceRef.current = setTimeout(() => { + try { + const parsed = yaml.load(newValue) as Dataset; + if ( + parsed && + typeof parsed === "object" && + Array.isArray(parsed.collections) + ) { + // Validate that the parsed structure won't crash the graph — + // e.g. a bare "-" in YAML produces null array entries. + changeSourceRef.current = "yaml"; + onDatasetChange(parsed); + setYamlError(null); + } else { + setYamlError("Invalid dataset structure"); + } + } catch (e) { + if (e instanceof YAMLException) { + setYamlError( + `${e.reason}${e.mark ? ` at line ${e.mark.line + 1}` : ""}`, + ); + } else { + setYamlError("Invalid YAML"); + } + } + }, 500); + }, + [onDatasetChange], + ); + + const { nodes: rawNodes, edges } = useDatasetGraph( + dataset, + protectedFields, + focusedCollection, + ); + const { nodes: layoutedNodes, edges: layoutedEdges } = useDatasetNodeLayout({ + nodes: rawNodes, + edges, + options: LAYOUT_OPTIONS, + }); + + // Derive selected node data from the current graph instead of stale state + const selectedNodeData = useMemo(() => { + if (!selectedNodeId) { + return null; + } + const node = rawNodes.find((n) => n.id === selectedNodeId); + return (node?.data as CollectionNodeData | FieldNodeData) ?? null; + }, [rawNodes, selectedNodeId]); + + // Merge selection + highlight state into nodes + const nodes = useMemo( + () => + layoutedNodes.map((node) => ({ + ...node, + selected: node.id === selectedNodeId, + data: { + ...node.data, + isHighlighted: node.id === highlightedNodeId, + }, + })), + [layoutedNodes, selectedNodeId, highlightedNodeId], + ); + + // Fit view only when the graph structure changes (drill-down or node count), + // not on metadata edits which don't affect layout. + const nodeCount = rawNodes.length; + useEffect(() => { + if (nodeCount > 0) { + const timer = setTimeout(() => { + reactFlowInstance.fitView({ padding: 0.2 }); + }, 150); + return () => clearTimeout(timer); + } + return undefined; + }, [nodeCount, focusedCollection, reactFlowInstance]); + + // Clear selection when drilling in/out + useEffect(() => { + setSelectedNodeId(null); + }, [focusedCollection]); + + const handleNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + if (node.id === DATASET_ROOT_ID) { + return; + } + // If clicking a collection in overview mode, drill in + if (!focusedCollection && node.id.startsWith(COLLECTION_ROOT_PREFIX)) { + const collectionName = node.id.slice(COLLECTION_ROOT_PREFIX.length); + setFocusedCollection(collectionName); + return; + } + // If clicking the collection root in drill-down mode, open its detail panel + if ( + focusedCollection && + node.id === `${COLLECTION_ROOT_PREFIX}${focusedCollection}` + ) { + setSelectedNodeId(node.id); + return; + } + setSelectedNodeId(node.id); + }, + [focusedCollection], + ); + + const handlePaneClick = useCallback(() => { + setSelectedNodeId(null); + }, []); + + const handleBack = useCallback(() => { + setFocusedCollection(null); + }, []); + + const handleUpdateCollection = useCallback( + (collectionName: string, updates: Partial) => { + const current = datasetRef.current; + const updated: Dataset = { + ...current, + collections: current.collections.map((c) => + c.name === collectionName ? { ...c, ...updates } : c, + ), + }; + onDatasetChange(updated); + }, + [onDatasetChange], + ); + + const handleUpdateField = useCallback( + ( + collectionName: string, + fieldPath: string, + updates: Partial, + ) => { + const current = datasetRef.current; + const segments = fieldPath.split("."); + const updated: Dataset = { + ...current, + collections: current.collections.map((c) => { + if (c.name !== collectionName) { + return c; + } + return { + ...c, + fields: updateFieldAtPath(c.fields, segments, updates), + }; + }), + }; + onDatasetChange(updated); + }, + [onDatasetChange], + ); + + // --- Add / Delete handlers --- + + const datasetRef = useRef(dataset); + datasetRef.current = dataset; + + const handleAddCollection = useCallback( + (name: string) => { + const current = datasetRef.current; + const newCollection: DatasetCollection = { + name, + fields: [], + }; + onDatasetChange({ + ...current, + collections: [...current.collections, newCollection], + }); + highlightNode(`${COLLECTION_ROOT_PREFIX}${name}`); + }, + [onDatasetChange, highlightNode], + ); + + const handleAddField = useCallback( + ( + collectionName: string, + name: string, + parentFieldPath?: string, + fieldData?: Partial, + ) => { + const current = datasetRef.current; + const newField: DatasetField = { name, ...fieldData }; + onDatasetChange({ + ...current, + collections: current.collections.map((c) => { + if (c.name !== collectionName) { + return c; + } + if (!parentFieldPath) { + return { ...c, fields: [...c.fields, newField] }; + } + const segments = parentFieldPath.split("."); + return { + ...c, + fields: addNestedField(c.fields, segments, newField), + }; + }), + }); + const fieldPath = parentFieldPath ? `${parentFieldPath}.${name}` : name; + highlightNode(`field-${collectionName}-${fieldPath}`); + }, + [onDatasetChange, highlightNode], + ); + + const handleDeleteCollection = useCallback( + (collectionName: string) => { + const current = datasetRef.current; + onDatasetChange({ + ...current, + collections: current.collections.filter( + (c) => c.name !== collectionName, + ), + }); + if (focusedCollection === collectionName) { + setFocusedCollection(null); + } + setSelectedNodeId(null); + }, + [onDatasetChange, focusedCollection], + ); + + const handleDeleteField = useCallback( + (collectionName: string, fieldPath: string) => { + const current = datasetRef.current; + const segments = fieldPath.split("."); + onDatasetChange({ + ...current, + collections: current.collections.map((c) => { + if (c.name !== collectionName) { + return c; + } + return { ...c, fields: removeFieldAtPath(c.fields, segments) }; + }), + }); + setSelectedNodeId(null); + }, + [onDatasetChange], + ); + + const editorActions: DatasetEditorActions = useMemo( + () => ({ + addCollection: () => { + const currentDataset = datasetRef.current; + setAddModal({ + open: true, + title: "Add Collection", + existingNames: currentDataset.collections.map((c) => c.name), + mode: "collection", + onConfirm: (name: string) => { + handleAddCollection(name); + setAddModal(CLOSED_MODAL); + }, + }); + }, + addField: (collectionName: string, parentFieldPath?: string) => { + const currentDataset = datasetRef.current; + const collection = currentDataset.collections.find( + (c) => c.name === collectionName, + ); + const siblingFields = parentFieldPath + ? getFieldsAtPath( + collection?.fields ?? [], + parentFieldPath.split("."), + ) + : (collection?.fields ?? []); + const label = parentFieldPath + ? `Add nested field to "${parentFieldPath}"` + : `Add field to "${collectionName}"`; + setAddModal({ + open: true, + title: label, + existingNames: siblingFields.map((f) => f.name), + mode: "field", + onConfirm: (name: string, fieldData?: Partial) => { + handleAddField(collectionName, name, parentFieldPath, fieldData); + setAddModal(CLOSED_MODAL); + }, + }); + }, + deleteCollection: handleDeleteCollection, + deleteField: handleDeleteField, + }), + [ + handleAddCollection, + handleAddField, + handleDeleteCollection, + handleDeleteField, + ], + ); + + const datasetLabel = dataset.name || dataset.fides_key; + + return ( + + + {/* Toolbar / Breadcrumb navigation */} + + {focusedCollection ? ( + + + ), + }, + { title: focusedCollection }, + ]} + /> + + ) : ( +
+ )} + + +
+ + + + + + + +
+ {/* Collapsible YAML editor panel */} + {yamlPanelOpen && ( + + + + + YAML Editor + + {yamlError && ( + + {yamlError} + + )} + + - - - - - - Test results - - - - - ); -}; - -export default TestResultsSection; diff --git a/clients/admin-ui/src/features/test-datasets/context/DatasetEditorActionsContext.tsx b/clients/admin-ui/src/features/test-datasets/context/DatasetEditorActionsContext.tsx new file mode 100644 index 00000000000..d3071d0fdf4 --- /dev/null +++ b/clients/admin-ui/src/features/test-datasets/context/DatasetEditorActionsContext.tsx @@ -0,0 +1,18 @@ +import { createContext } from "react"; + +export interface DatasetEditorActions { + addCollection: () => void; + /** Add a field. If parentFieldPath is provided, adds a nested sub-field. */ + addField: (collectionName: string, parentFieldPath?: string) => void; + deleteCollection: (collectionName: string) => void; + deleteField: (collectionName: string, fieldPath: string) => void; +} + +const DatasetEditorActionsContext = createContext({ + addCollection: () => {}, + addField: () => {}, + deleteCollection: () => {}, + deleteField: () => {}, +}); + +export default DatasetEditorActionsContext; diff --git a/clients/admin-ui/src/features/test-datasets/context/DatasetTreeHoverContext.tsx b/clients/admin-ui/src/features/test-datasets/context/DatasetTreeHoverContext.tsx new file mode 100644 index 00000000000..37855e9edbe --- /dev/null +++ b/clients/admin-ui/src/features/test-datasets/context/DatasetTreeHoverContext.tsx @@ -0,0 +1,117 @@ +import { Edge } from "@xyflow/react"; +import { + createContext, + ReactNode, + useCallback, + useMemo, + useState, +} from "react"; + +export enum DatasetNodeHoverStatus { + DEFAULT = "DEFAULT", + ACTIVE_HOVER = "ACTIVE_HOVER", + PARENT_OF_HOVER = "PARENT_OF_HOVER", + INACTIVE = "INACTIVE", +} + +interface DatasetTreeHoverContextType { + activeNodeId: string | null; + onMouseEnter: (nodeId: string) => void; + onMouseLeave: () => void; + getNodeHoverStatus: (nodeId: string) => DatasetNodeHoverStatus; +} + +export const DatasetTreeHoverContext = + createContext({ + activeNodeId: null, + onMouseEnter: () => {}, + onMouseLeave: () => {}, + getNodeHoverStatus: () => DatasetNodeHoverStatus.DEFAULT, + }); + +/** + * Build ancestor and descendant sets from the edge list so hover + * highlighting correctly flows through the full path to the root. + */ +const buildAncestryMaps = (edges: Edge[]) => { + // parentOf: child → parent + const parentOf = new Map(); + edges.forEach((edge) => { + parentOf.set(edge.target, edge.source); + }); + + const getAncestors = (nodeId: string): Set => { + const ancestors = new Set(); + let current = parentOf.get(nodeId); + while (current) { + ancestors.add(current); + current = parentOf.get(current); + } + return ancestors; + }; + + return { getAncestors }; +}; + +export const DatasetTreeHoverProvider = ({ + edges, + children, +}: { + edges: Edge[]; + children: ReactNode; +}) => { + const [activeNodeId, setActiveNodeId] = useState(null); + + const { getAncestors } = useMemo( + () => buildAncestryMaps(edges), + [edges], + ); + + const onMouseEnter = useCallback((nodeId: string) => { + setActiveNodeId((prev) => (prev === nodeId ? prev : nodeId)); + }, []); + + const onMouseLeave = useCallback(() => { + setActiveNodeId(null); + }, []); + + // Precompute ancestor/descendant sets once per hover change + const ancestorSet = useMemo( + () => (activeNodeId ? getAncestors(activeNodeId) : new Set()), + [activeNodeId, getAncestors], + ); + const getNodeHoverStatus = useCallback( + (nodeId: string): DatasetNodeHoverStatus => { + if (!activeNodeId) { + return DatasetNodeHoverStatus.DEFAULT; + } + + if (nodeId === activeNodeId) { + return DatasetNodeHoverStatus.ACTIVE_HOVER; + } + + if (ancestorSet.has(nodeId)) { + return DatasetNodeHoverStatus.PARENT_OF_HOVER; + } + + return DatasetNodeHoverStatus.INACTIVE; + }, + [activeNodeId, ancestorSet], + ); + + const value = useMemo( + () => ({ + activeNodeId, + onMouseEnter, + onMouseLeave, + getNodeHoverStatus, + }), + [activeNodeId, onMouseEnter, onMouseLeave, getNodeHoverStatus], + ); + + return ( + + {children} + + ); +}; diff --git a/clients/admin-ui/src/features/test-datasets/dataset-field-helpers.ts b/clients/admin-ui/src/features/test-datasets/dataset-field-helpers.ts new file mode 100644 index 00000000000..5f86e588dcb --- /dev/null +++ b/clients/admin-ui/src/features/test-datasets/dataset-field-helpers.ts @@ -0,0 +1,73 @@ +import { DatasetField } from "~/types/api"; + +/** Walk nested fields to find and update the one at fieldPath */ +export const updateFieldAtPath = ( + fields: DatasetField[], + segments: string[], + updates: Partial, +): DatasetField[] => + fields.map((f) => { + if (f.name !== segments[0]) { + return f; + } + if (segments.length === 1) { + return { ...f, ...updates }; + } + return { + ...f, + fields: updateFieldAtPath(f.fields ?? [], segments.slice(1), updates), + }; + }); + +/** Get the children fields of a field at the given path */ +export const getFieldsAtPath = ( + fields: DatasetField[], + segments: string[], +): DatasetField[] => { + const match = fields.find((f) => f.name === segments[0]); + if (!match) { + return []; + } + if (segments.length === 1) { + return match.fields ?? []; + } + return getFieldsAtPath(match.fields ?? [], segments.slice(1)); +}; + +/** Append a new child field to the parent at the given path */ +export const addNestedField = ( + fields: DatasetField[], + segments: string[], + newField: DatasetField, +): DatasetField[] => + fields.map((f) => { + if (f.name !== segments[0]) { + return f; + } + if (segments.length === 1) { + return { ...f, fields: [...(f.fields ?? []), newField] }; + } + return { + ...f, + fields: addNestedField(f.fields ?? [], segments.slice(1), newField), + }; + }); + +/** Recursively remove a field at the given path */ +export const removeFieldAtPath = ( + fields: DatasetField[], + segments: string[], +): DatasetField[] => { + if (segments.length === 1) { + return fields.filter((f) => f.name !== segments[0]); + } + return fields.map((f) => { + if (f.name !== segments[0]) { + return f; + } + return { + ...f, + fields: removeFieldAtPath(f.fields ?? [], segments.slice(1)), + }; + }); +}; diff --git a/clients/admin-ui/src/features/test-datasets/dataset-test.slice.ts b/clients/admin-ui/src/features/test-datasets/dataset-test.slice.ts index 0e5caf9f6ac..13da9926e1d 100644 --- a/clients/admin-ui/src/features/test-datasets/dataset-test.slice.ts +++ b/clients/admin-ui/src/features/test-datasets/dataset-test.slice.ts @@ -4,100 +4,18 @@ import type { RootState } from "~/app/store"; import { DatasetConfigSchema } from "~/types/api"; interface DatasetTestState { - privacyRequestId: string | null; currentDataset: DatasetConfigSchema | null; - isReachable: boolean; - testInputs: Record>; - testResults: Record; - isTestRunning: boolean; currentPolicyKey?: string; - logs: Array<{ - timestamp: string; - level: string; - module_info: string; - message: string; - }>; } const initialState: DatasetTestState = { - privacyRequestId: null, currentDataset: null, - isReachable: false, - testInputs: {}, - testResults: {}, - isTestRunning: false, - logs: [], }; export const datasetTestSlice = createSlice({ name: "datasetTest", initialState, reducers: { - startTest: (draftState, action: PayloadAction) => { - draftState.testResults = { - ...draftState.testResults, - [action.payload]: "", - }; - draftState.isTestRunning = true; - draftState.logs = []; - }, - setPrivacyRequestId: (draftState, action: PayloadAction) => { - draftState.privacyRequestId = action.payload; - }, - finishTest: (draftState) => { - draftState.privacyRequestId = null; - draftState.isTestRunning = false; - }, - interruptTest: (draftState) => { - if (draftState.currentDataset?.fides_key) { - draftState.testResults = { - ...draftState.testResults, - [draftState.currentDataset.fides_key]: "", - }; - } - draftState.logs = []; - draftState.privacyRequestId = null; - draftState.isTestRunning = false; - }, - setTestInputs: ( - draftState, - action: PayloadAction<{ - datasetKey: string; - values: Record; - }>, - ) => { - const existingValues = - draftState.testInputs[action.payload.datasetKey] || {}; - const inputsData = action.payload.values || {}; - - // Skip processing if we're trying to set empty values over existing ones - if ( - Object.keys(existingValues).length > 0 && - Object.keys(inputsData).length === 0 - ) { - return; - } - - // Start with input data - const mergedValues = { ...inputsData }; - - // For each key in the existing values - Object.entries(existingValues).forEach(([key, existingValue]) => { - // If the new value is null and we have an existing non-null value, keep the existing value - if ( - key in mergedValues && - mergedValues[key] === null && - existingValue !== null - ) { - mergedValues[key] = existingValue; - } - }); - - draftState.testInputs = { - ...draftState.testInputs, - [action.payload.datasetKey]: mergedValues, - }; - }, setCurrentPolicyKey: (draftState, action: PayloadAction) => { draftState.currentPolicyKey = action.payload; }, @@ -106,70 +24,16 @@ export const datasetTestSlice = createSlice({ action: PayloadAction, ) => { draftState.currentDataset = action.payload; - draftState.privacyRequestId = null; - }, - setReachability: (draftState, action: PayloadAction) => { - draftState.isReachable = action.payload; - }, - setTestResults: ( - draftState, - action: PayloadAction<{ - datasetKey: string; - values: string; - }>, - ) => { - draftState.testResults = { - ...draftState.testResults, - [action.payload.datasetKey]: action.payload.values, - }; - }, - setLogs: (draftState, action: PayloadAction) => { - 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" }, ]} /> - - - - - - - + );