feat(ui): add form structure validation and tests

This commit is contained in:
psychedelicious
2025-02-21 06:42:37 +10:00
parent d142a94b67
commit 58ae9ed8a5
3 changed files with 241 additions and 9 deletions

View File

@@ -6,8 +6,9 @@ import {
getElement,
removeElement,
reparentElement,
validateFormStructure,
} from 'features/nodes/components/sidePanel/builder/form-manipulation';
import type { ContainerElement } from 'features/nodes/types/workflow';
import type { BuilderForm, ContainerElement } from 'features/nodes/types/workflow';
import {
buildContainer,
buildText,
@@ -756,4 +757,155 @@ describe('workflow builder form manipulation', () => {
expect(form).toEqual(prevForm);
});
});
describe('validateFormStructure', () => {
it('should be happy with the default form', () => {
const form = getDefaultForm();
expect(validateFormStructure(form)).toBe(true);
});
it('should return true if all children are reachable and there are no extra elements', () => {
const form = getDefaultForm();
const container1 = buildContainer('row', []);
addElement({
form,
element: container1,
parentId: form.rootElementId,
index: 0,
});
const container2 = buildContainer('row', []);
addElement({
form,
element: container2,
parentId: container1.id,
index: 0,
});
const child = buildText('foo');
addElement({
form,
element: child,
parentId: container2.id,
index: 0,
});
expect(validateFormStructure(form)).toBe(true);
});
it('should return false if a child is not reachable', () => {
const form = getDefaultForm();
const parent = buildContainer('row', []);
addElement({
form,
element: parent,
parentId: form.rootElementId,
index: 0,
});
const el = buildText('foo');
addElement({
form,
element: el,
parentId: parent.id,
index: 0,
});
el.parentId = 'non-existent-parent';
parent.data.children = ['non-existent-child'];
expect(validateFormStructure(form)).toBe(false);
});
it("should return false if a non-root child's parent does not exist", () => {
const form = getDefaultForm();
const parent = buildContainer('row', []);
addElement({
form,
element: parent,
parentId: form.rootElementId,
index: 0,
});
const el = buildText('foo');
addElement({
form,
element: el,
parentId: parent.id,
index: 0,
});
el.parentId = 'non-existent-parent';
expect(validateFormStructure(form)).toBe(false);
});
it('should be OK with the root not having a parent', () => {
// This test is the same as the default form
const form = getDefaultForm();
const rootElement = form.elements[form.rootElementId];
assert(rootElement !== undefined);
expect(rootElement.parentId).toBeUndefined();
expect(validateFormStructure(form)).toBe(true);
});
it("should return false if a child's parent is not a container", () => {
const form = getDefaultForm();
const notAContainer = buildText('foo');
addElement({
form,
element: notAContainer,
parentId: form.rootElementId,
index: 0,
});
const el = buildText('bar');
addElement({
form,
element: el,
parentId: form.rootElementId,
index: 0,
});
el.parentId = notAContainer.id;
expect(validateFormStructure(form)).toBe(false);
});
it("should return false if the child's parent does not have the child in its children list", () => {
const form = getDefaultForm();
const parent = buildContainer('row', []);
addElement({
form,
element: parent,
parentId: form.rootElementId,
index: 0,
});
const el = buildText('foo');
addElement({
form,
element: el,
parentId: parent.id,
index: 0,
});
parent.data.children = [];
expect(validateFormStructure(form)).toBe(false);
});
it('should return false if there are extra elements', () => {
const form = getDefaultForm();
const parent = buildContainer('row', []);
addElement({
form,
element: parent,
parentId: form.rootElementId,
index: 0,
});
const el = buildText('foo');
el.parentId = parent.id;
form.elements[el.id] = el;
expect(validateFormStructure(form)).toBe(false);
});
it('should return false if the root element is not a container', () => {
const el = buildText('foo');
const form: BuilderForm = {
elements: {
[el.id]: el,
},
rootElementId: el.id,
};
expect(validateFormStructure(form)).toBe(false);
});
});
});

View File

@@ -245,3 +245,72 @@ export const getAllowedDropRegions = (form: BuilderForm, element: FormElement):
return dropRegions;
};
/**
* Validates the structure of a form.
*
* The form structure is valid if:
* - The root element is a container
* - Starting from the root element, all children referenced are reachable
* - There are no extra elements in the form that are not reachable from the root element
* - The root element has no parentId and is a container
* - Non-root elements have a parentId
* - All parent elements are containers
* - All elements with a parentId are children of their parent
*
* @param form The form to validate
*
* @returns True if the form structure is valid, false otherwise
*/
export const validateFormStructure = (form: BuilderForm): boolean => {
const { elements, rootElementId } = form;
const rootElement = elements[rootElementId];
const isRootElementAContainer = rootElement !== undefined && isContainerElement(rootElement);
const childrenFoundInTree = new Set<string>();
const findChildren = (elementId: string): boolean => {
const element = elements[elementId];
if (!element) {
// Element not found
return false;
}
childrenFoundInTree.add(elementId);
if (element.id === rootElementId) {
// Special handling for root
if (element.parentId !== undefined) {
// Root element must not have a parent
return false;
}
} else {
// Handling for all other elements
if (element.parentId === undefined) {
// Element must have a parent
return false;
}
const parent = elements[element.parentId];
if (!parent) {
// Parent must exist
return false;
}
if (!isContainerElement(parent)) {
// Parent must be a container
return false;
}
if (!parent.data.children.includes(elementId)) {
// Element must be a child of its parent
return false;
}
}
if (isContainerElement(element) && element.data.children.length > 0) {
return element.data.children.every(findChildren);
}
return true;
};
const noMissingChildren = findChildren(rootElementId);
const noExtraElements = Object.keys(elements).length === childrenFoundInTree.size;
return isRootElementAContainer && noMissingChildren && noExtraElements;
};

View File

@@ -1,4 +1,5 @@
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { validateFormStructure } from 'features/nodes/components/sidePanel/builder/form-manipulation';
import { z } from 'zod';
import type { FieldType } from './field';
@@ -230,7 +231,7 @@ const zFormElement = z.union([zContainerElement, zNodeFieldElement, zHeadingElem
export type FormElement = z.infer<typeof zFormElement>;
export const getDefaultForm = () => {
export const getDefaultForm = (): BuilderForm => {
const rootElement = buildContainer('column', []);
return {
elements: {
@@ -240,13 +241,22 @@ export const getDefaultForm = () => {
};
};
const zBuilderForm = z
.object({
elements: z.record(zFormElement),
rootElementId: zElementId,
})
.default(getDefaultForm);
const zBuilderForm = z.object({
elements: z.record(zFormElement),
rootElementId: zElementId,
});
export type BuilderForm = z.infer<typeof zBuilderForm>;
// Need to separate the form vaidation from the schema due to circular dependencies
const zValidatedBuilderForm = zBuilderForm
.default(getDefaultForm)
.refine((val) => val.rootElementId in val.elements, {
message: 'rootElementId must be a valid element id',
})
.refine((val) => validateFormStructure(val), {
message: 'Form structure is invalid',
});
//# endregion
// #region Workflow
@@ -266,7 +276,8 @@ export const zWorkflowV3 = z.object({
category: zWorkflowCategory.default('user'),
version: z.literal('3.0.0'),
}),
form: zBuilderForm,
// Use the validated form schema!
form: zValidatedBuilderForm,
});
export type WorkflowV3 = z.infer<typeof zWorkflowV3>;
// #endregion