Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
de7fa7e
ENG-564: Replace YAML editor with node-based dataset UI
Linker44 Mar 13, 2026
79c7edf
ENG-564: Add drill-down navigation, CRUD operations, and fides_meta e…
Linker44 Mar 16, 2026
405ddff
Conditionally render node editor for SaaS, keep YAML editor for DB
Linker44 Mar 16, 2026
4218b27
Add field metadata to add-field modal, restrict add buttons to drill-…
Linker44 Mar 17, 2026
e932d2c
Extract shared field metadata form items, constants, and build logic
Linker44 Mar 17, 2026
9cc0537
Add data category suggestions to add/edit field selects
Linker44 Mar 17, 2026
9582703
Fix fitView triggering on metadata edits
Linker44 Mar 17, 2026
11fece1
Briefly highlight newly added nodes in yellow
Linker44 Mar 17, 2026
9c09bc8
Include array data types (e.g. string[], object[]) in data type options
Linker44 Mar 17, 2026
e38ce53
Change add node modal to a side drawer, add array data types
Linker44 Mar 17, 2026
0316e2d
Move Create button to bottom of add node drawer
Linker44 Mar 17, 2026
4535cc1
Add collapsible YAML editor panel to node-based dataset editor
Linker44 Mar 17, 2026
f5f1b39
Fix review findings: stabilize hover context, dedupe types, improve a11y
Linker44 Mar 17, 2026
880a720
Fix stale node data, debounce detail panel, guard YAML parse errors
Linker44 Mar 17, 2026
f8764a6
Remove return_all_elements, length, and custom_request_field from nod…
Linker44 Mar 18, 2026
d8207d9
Fix node editor review findings: stale closures, a11y, dead code, cle…
Linker44 Mar 18, 2026
3dcfcfd
Guard drill-down collections.find against null YAML entries
Linker44 Mar 18, 2026
c2c42c2
Guard collection.fields.length against undefined from malformed YAML
Linker44 Mar 18, 2026
8dfc5c1
Fix review issues: stale closures, flush debounce on close, dead code…
Linker44 Mar 23, 2026
de5469c
Merge branch 'main' into ENG-564-node-based-dataset-editor
Linker44 Mar 23, 2026
6126fb6
Address PR review feedback
Linker44 Mar 23, 2026
50d0d72
Remove dead imports for deleted TestLogsSection and TestRunnerSection
Linker44 Mar 23, 2026
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
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -290,10 +290,11 @@ export const ConnectorParametersForm = ({
</Button>
) : null}
{isPlusEnabled &&
SystemType.DATABASE === connectionOption.type &&
(SystemType.DATABASE === connectionOption.type ||
SystemType.SAAS === connectionOption.type) &&
!_.isEmpty(initialDatasets) && (
<Button onClick={() => onTestDatasetsClick()}>
Test datasets
Edit dataset
</Button>
)}
{connectionOption.authorization_required && !authorized ? (
Expand Down
139 changes: 139 additions & 0 deletions clients/admin-ui/src/features/test-datasets/AddNodeModal.tsx
Original file line number Diff line number Diff line change
@@ -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<DatasetField>) => void;
onCancel: () => void;
}

const buildFieldData = (
values: Record<string, unknown>,
): Partial<DatasetField> | 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 (
<Drawer
title={title}
placement="right"
width={400}
open={open}
onClose={onCancel}
mask={false}
>
<Form form={form} layout="vertical" size="small">
<Form.Item
label="Name"
name="name"
rules={[
{ required: true, message: "Name is required" },
{
validator: (_, value) => {
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",
},
]}
>
<Input placeholder="Enter a unique name" autoFocus />
</Form.Item>

{mode === "field" && (
<>
<Form.Item label="Description" name="description">
<Input.TextArea rows={2} placeholder="Add a description..." />
</Form.Item>

<Form.Item label="Data Categories" name="data_categories">
<DataCategoryTagSelect />
</Form.Item>

<Collapse
size="small"
items={[
{
key: "field-meta",
label: "Field Metadata (fides_meta)",
children: <FieldMetadataFormItems />,
},
]}
/>
</>
)}

<div style={{ marginTop: 24 }}>
<Button type="primary" size="small" block onClick={handleOk}>
Create
</Button>
</div>
</Form>
</Drawer>
);
};

export default AddNodeModal;
Loading
Loading