refactor(ui): revert to using single tree for form data

This commit is contained in:
psychedelicious
2025-02-19 14:28:22 +10:00
parent 1461c88c12
commit 148bd70a24
11 changed files with 399 additions and 633 deletions

View File

@@ -4,23 +4,14 @@ import { useAppDispatch } from 'app/store/storeHooks';
import { ContainerElementSettings } from 'features/nodes/components/sidePanel/builder/ContainerElementSettings';
import { useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
import { NodeFieldElementSettings } from 'features/nodes/components/sidePanel/builder/NodeFieldElementSettings';
import { useIsRootElement } from 'features/nodes/components/sidePanel/builder/shared';
import { formElementRemoved } from 'features/nodes/store/workflowSlice';
import { type FormElement, isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow';
import { startCase } from 'lodash-es';
import { memo, useCallback } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
const getHeaderLabel = (el: FormElement) => {
if (isContainerElement(el)) {
if (el.data.layout === 'column') {
return 'Container (column layout)';
}
return 'Container (row layout)';
}
return startCase(el.type);
};
const sx: SystemStyleObject = {
w: 'full',
ps: 2,
@@ -44,28 +35,44 @@ export const FormElementEditModeHeader = memo(
const { t } = useTranslation();
const depth = useDepthContext();
const dispatch = useAppDispatch();
const isRootElement = useIsRootElement(element.id);
const removeElement = useCallback(() => {
if (isRootElement) {
return;
}
dispatch(formElementRemoved({ id: element.id }));
}, [dispatch, element.id]);
}, [dispatch, element.id, isRootElement]);
const label = useMemo(() => {
if (isContainerElement(element)) {
const baseLabel = isRootElement ? 'Root Container' : 'Container';
if (element.data.layout === 'column') {
return `${baseLabel} (column layout)`;
}
return `${baseLabel} (row layout)`;
}
return startCase(element.type);
}, [element, isRootElement]);
return (
<Flex ref={ref} sx={sx} data-depth={depth}>
<Text fontWeight="semibold" noOfLines={1} wordBreak="break-all">
{getHeaderLabel(element)}
{label}
</Text>
<Spacer />
{isContainerElement(element) && <ContainerElementSettings element={element} />}
{isNodeFieldElement(element) && <NodeFieldElementSettings element={element} />}
<IconButton
tooltip={t('common.delete')}
aria-label={t('common.delete')}
onClick={removeElement}
icon={<PiXBold />}
variant="link"
size="sm"
alignSelf="stretch"
colorScheme="error"
/>
{!isRootElement && (
<IconButton
tooltip={t('common.delete')}
aria-label={t('common.delete')}
onClick={removeElement}
icon={<PiXBold />}
variant="link"
size="sm"
alignSelf="stretch"
colorScheme="error"
/>
)}
</Flex>
);
})

View File

@@ -4,7 +4,11 @@ import { useContainerContext } from 'features/nodes/components/sidePanel/builder
import { useFormElementDnd } from 'features/nodes/components/sidePanel/builder/dnd';
import { DndListDropIndicator } from 'features/nodes/components/sidePanel/builder/DndListDropIndicator';
import { FormElementEditModeHeader } from 'features/nodes/components/sidePanel/builder/FormElementEditModeHeader';
import { EDIT_MODE_WRAPPER_CLASS_NAME, getEditModeWrapperId } from 'features/nodes/components/sidePanel/builder/shared';
import {
EDIT_MODE_WRAPPER_CLASS_NAME,
getEditModeWrapperId,
useIsRootElement,
} from 'features/nodes/components/sidePanel/builder/shared';
import type { FormElement } from 'features/nodes/types/workflow';
import type { PropsWithChildren } from 'react';
import { memo, useRef } from 'react';
@@ -46,6 +50,7 @@ export const FormElementEditModeWrapper = memo(({ element, children }: PropsWith
const dragHandleRef = useRef<HTMLDivElement>(null);
const [activeDropRegion, isDragging] = useFormElementDnd(element.id, draggableRef, dragHandleRef);
const containerCtx = useContainerContext();
const isRootElement = useIsRootElement(element.id);
return (
<Flex
@@ -53,6 +58,7 @@ export const FormElementEditModeWrapper = memo(({ element, children }: PropsWith
ref={draggableRef}
className={EDIT_MODE_WRAPPER_CLASS_NAME}
sx={wrapperSx}
data-is-root={isRootElement}
data-element-type={element.type}
data-layout={containerCtx?.layout}
>

View File

@@ -1,17 +1,12 @@
import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine';
import { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { Alert, AlertDescription, AlertIcon, Button, ButtonGroup, Flex, Spacer, Text } from '@invoke-ai/ui-library';
import { Alert, AlertDescription, AlertIcon, Button, ButtonGroup, Flex, Spacer } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { firefoxDndFix } from 'features/dnd/util';
import { FormElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
import {
buildFormElementDndData,
useBuilderDndMonitor,
useRootDnd,
} from 'features/nodes/components/sidePanel/builder/dnd';
import { getEditModeWrapperId } from 'features/nodes/components/sidePanel/builder/shared';
import { formReset, selectFormIsEmpty, selectFormLayout } from 'features/nodes/store/workflowSlice';
import { buildFormElementDndData, useBuilderDndMonitor } from 'features/nodes/components/sidePanel/builder/dnd';
import { formReset, selectFormRootElementId } from 'features/nodes/store/workflowSlice';
import type { FormElement } from 'features/nodes/types/workflow';
import { buildContainer, buildDivider, buildHeading, buildText } from 'features/nodes/types/workflow';
import { startCase } from 'lodash-es';
@@ -24,7 +19,7 @@ import { assert } from 'tsafe';
export const WorkflowBuilder = memo(() => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isEmpty = useAppSelector(selectFormIsEmpty);
const rootElementId = useAppSelector(selectFormRootElementId);
useBuilderDndMonitor();
const resetForm = useCallback(() => {
@@ -49,8 +44,9 @@ export const WorkflowBuilder = memo(() => {
{t('common.reset')}
</Button>
</ButtonGroup>
{!isEmpty && <FormLayout />}
{isEmpty && <EmptyStateEditMode />}
<Flex>
<FormElementComponent id={rootElementId} />
</Flex>
</Flex>
</Flex>
</ScrollableContent>
@@ -58,44 +54,6 @@ export const WorkflowBuilder = memo(() => {
});
WorkflowBuilder.displayName = 'WorkflowBuilder';
export const FormLayout = memo(() => {
const layout = useAppSelector(selectFormLayout);
return (
<Flex flexDir="column" gap={4} w="full" borderRadius="base">
{layout.map((id) => (
<FormElementComponent key={id} id={id} />
))}
</Flex>
);
});
FormLayout.displayName = 'FormLayout';
const EmptyStateEditMode = memo(() => {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const isDragging = useRootDnd(ref);
return (
<Flex
id={getEditModeWrapperId('root')}
ref={ref}
w="full"
h="full"
bg={isDragging ? 'base.800' : undefined}
p={4}
borderRadius="base"
alignItems="center"
justifyContent="center"
>
<Text variant="subtext" fontSize="md">
{t('workflows.builder.emptyRootPlaceholderEditMode')}
</Text>
</Flex>
);
});
EmptyStateEditMode.displayName = 'EmptyStateEditMode';
const useAddFormElementDnd = (
type: Exclude<FormElement['type'], 'node-field'>,
draggableRef: RefObject<HTMLElement>

View File

@@ -7,7 +7,7 @@ import {
import { getReorderDestinationIndex } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index';
import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge';
import { logger } from 'app/logging/logger';
import { getStore } from 'app/store/nanostores/store';
import { useAppStore } from 'app/store/nanostores/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
@@ -20,16 +20,17 @@ import {
} from 'features/nodes/components/sidePanel/builder/center-or-closest-edge';
import { getEditModeWrapperId } from 'features/nodes/components/sidePanel/builder/shared';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState } from 'features/nodes/store/types';
import {
formContainerChildrenReordered,
formElementAdded,
formElementReparented,
formRootReordered,
selectFormIsEmpty,
selectFormRootElementId,
selectWorkflowSlice,
} from 'features/nodes/store/workflowSlice';
import type { FieldIdentifier, FieldInputTemplate, StatefulFieldValue } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type { ElementId, FormElement } from 'features/nodes/types/workflow';
import type { BuilderForm, ElementId, FormElement } from 'features/nodes/types/workflow';
import { buildNodeFieldElement, isContainerElement, isNodeFieldElement } from 'features/nodes/types/workflow';
import type { RefObject } from 'react';
import { useCallback, useEffect, useState } from 'react';
@@ -39,17 +40,6 @@ import { assert } from 'tsafe';
const log = logger('dnd');
const uniqueRootKey = Symbol('root');
type RootDndData = {
[uniqueRootKey]: true;
};
const buildRootDndData = (): RootDndData => ({
[uniqueRootKey]: true,
});
const isRootDndData = (data: Record<string | symbol, unknown>): data is RootDndData => {
return uniqueRootKey in data;
};
const uniqueFormElementDndKey = Symbol('form-element');
type FormElementDndData = {
[uniqueFormElementDndKey]: true;
@@ -63,12 +53,16 @@ const isFormElementDndData = (data: Record<string | symbol, unknown>): data is F
return uniqueFormElementDndKey in data;
};
const elementExists = (id: ElementId): boolean => {
return getStore().getState().workflow.form?.elements[id] !== undefined;
const elementExists = (form: BuilderForm, id: ElementId): boolean => {
return form.elements[id] !== undefined;
};
const getElement = <T extends FormElement>(id: ElementId, guard?: (el: FormElement) => el is T): T => {
const el = getStore().getState().workflow.form?.elements[id];
const getElement = <T extends FormElement>(
form: BuilderForm,
id: ElementId,
guard?: (el: FormElement) => el is T
): T => {
const el = form.elements[id];
assert(el);
if (guard) {
assert(guard(el));
@@ -78,12 +72,11 @@ const getElement = <T extends FormElement>(id: ElementId, guard?: (el: FormEleme
}
};
const getInitialValue = (el: FormElement): StatefulFieldValue => {
const getInitialValue = (nodes: NodesState['nodes'], el: FormElement): StatefulFieldValue => {
if (!isNodeFieldElement(el)) {
return undefined;
}
const { nodeId, fieldName } = el.data.fieldIdentifier;
const { nodes } = selectNodesSlice(getStore().getState());
const node = nodes.find((n) => n.id === nodeId);
// The node or input not existing as we add it to the builder indicates something is wrong, but we don't want to
@@ -101,10 +94,6 @@ const getInitialValue = (el: FormElement): StatefulFieldValue => {
return input.value;
};
const getLayout = () => {
return getStore().getState().workflow.form.layout;
};
const flashElement = (elementId: ElementId) => {
const element = document.querySelector(`#${getEditModeWrapperId(elementId)}`);
if (element instanceof HTMLElement) {
@@ -112,7 +101,7 @@ const flashElement = (elementId: ElementId) => {
}
};
const getAllowedDropRegions = (element: FormElement): CenterOrEdge[] => {
const getAllowedDropRegions = (form: BuilderForm, element: FormElement): CenterOrEdge[] => {
const dropRegions: CenterOrEdge[] = [];
if (isContainerElement(element) && element.data.children.length === 0) {
@@ -121,7 +110,7 @@ const getAllowedDropRegions = (element: FormElement): CenterOrEdge[] => {
// Parent is a container
if (element.parentId !== undefined) {
const parentContainer = getElement(element.parentId, isContainerElement);
const parentContainer = getElement(form, element.parentId, isContainerElement);
if (parentContainer.data.layout === 'row') {
dropRegions.push('left', 'right');
} else {
@@ -138,6 +127,54 @@ const getAllowedDropRegions = (element: FormElement): CenterOrEdge[] => {
return dropRegions;
};
const useGetElement = () => {
const store = useAppStore();
const _getElement = useCallback(
<T extends FormElement>(id: ElementId, guard?: (el: FormElement) => el is T): T => {
const { form } = selectWorkflowSlice(store.getState());
return getElement(form, id, guard);
},
[store]
);
return _getElement;
};
const useElementExists = () => {
const store = useAppStore();
const _elementExists = useCallback(
(id: ElementId): boolean => {
const { form } = selectWorkflowSlice(store.getState());
return elementExists(form, id);
},
[store]
);
return _elementExists;
};
const useGetAllowedDropRegions = () => {
const store = useAppStore();
const _getAllowedDropRegions = useCallback(
(element: FormElement): CenterOrEdge[] => {
const { form } = selectWorkflowSlice(store.getState());
return getAllowedDropRegions(form, element);
},
[store]
);
return _getAllowedDropRegions;
};
const useGetInitialValue = () => {
const store = useAppStore();
const _getInitialValue = useCallback(
(element: FormElement): StatefulFieldValue => {
const { nodes } = selectNodesSlice(store.getState());
return getInitialValue(nodes, element);
},
[store]
);
return _getInitialValue;
};
export const useBuilderDndMonitor = () => {
useAssertSingleton('useBuilderDndMonitor');
const dispatch = useAppDispatch();
@@ -152,6 +189,10 @@ export const useBuilderDndMonitor = () => {
[dispatch]
);
const getElement = useGetElement();
const getInitialValue = useGetInitialValue();
const elementExists = useElementExists();
useEffect(() => {
return monitorForElements({
canMonitor: ({ source }) => isFormElementDndData(source.data),
@@ -171,37 +212,6 @@ export const useBuilderDndMonitor = () => {
const isAddingNewElement = !elementExists(sourceElement.id);
//#region Dragging onto the root
if (isRootDndData(targetData)) {
if (isAddingNewElement) {
log.trace('Adding new element to empty root');
sourceElement.parentId = undefined;
dispatchAndFlash(
formElementAdded({
element: sourceElement,
index: undefined,
initialValue: getInitialValue(sourceElement),
}),
sourceElement.id
);
return;
}
if (!isAddingNewElement && sourceElement.parentId !== undefined) {
log.trace('Reparenting element from container to empty root');
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: undefined,
index: undefined,
}),
sourceElement.id
);
return;
}
}
//#endregion
//#region Form elements
if (isFormElementDndData(targetData)) {
const targetElement = targetData.element;
@@ -211,354 +221,121 @@ export const useBuilderDndMonitor = () => {
}
const closestEdgeOfTarget = extractClosestCenterOrEdge(targetData);
if (isAddingNewElement && targetElement.parentId === undefined && closestEdgeOfTarget === 'center') {
log.trace('Adding new element to empty container');
assert(isContainerElement(targetElement), 'Expected target to be a container');
sourceElement.parentId = targetElement.id;
dispatchAndFlash(
formElementAdded({
element: sourceElement,
initialValue: getInitialValue(sourceElement),
}),
sourceElement.id
);
return;
/**
* There are 5 cases to handle:
* 1. Adding a new element to an empty container
* 2. Adding a new element to a container with children (dropped on edge of container child)
* 3. Reparenting an element to an empty container
* 4. Reparenting an element to a container with children (dropped on edge of container child)
* 5. Moving an element within a container
*
* We can determine which case we're in by checking the following:
* - Check if the element already exists in the form. If it doesn't, we're adding a new element.
* - Check if the closest edge of the target is 'center'. If it is, we're either adding a new element or reparenting to an empty container.
* - If the closest edge of the target is not 'center', we're either reparenting to a container with children or moving an element within a container, in which case we compare the parent of the source and target elements.
*
*/
if (isAddingNewElement) {
if (closestEdgeOfTarget === 'center') {
log.trace('Adding new element to empty container');
assert(isContainerElement(targetElement), 'Expected target to be a container');
sourceElement.parentId = targetElement.id;
dispatchAndFlash(
formElementAdded({
element: sourceElement,
initialValue: getInitialValue(sourceElement),
index: 0,
}),
sourceElement.id
);
return;
} else {
log.trace('Adding new element to container with children (dropped on edge of container child)');
assert(targetElement.parentId !== undefined, 'Expected target to have a parent');
const parent = getElement(targetElement.parentId, isContainerElement);
const indexOfTarget = parent.data.children.indexOf(targetElement.id);
const index = getReorderDestinationIndex({
startIndex: indexOfTarget + 1,
indexOfTarget,
closestEdgeOfTarget,
axis: parent.data.layout === 'row' ? 'horizontal' : 'vertical',
});
sourceElement.parentId = parent.id;
dispatchAndFlash(
formElementAdded({
element: sourceElement,
index,
initialValue: getInitialValue(sourceElement),
}),
sourceElement.id
);
return;
}
} else {
if (closestEdgeOfTarget === 'center') {
log.trace('Reparenting element to an empty container');
assert(isContainerElement(targetElement), 'Expected target to be a container');
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: targetElement.id,
index: 0,
}),
sourceElement.id
);
return;
} else if (targetElement.parentId === sourceElement.parentId) {
log.trace('Moving element within container');
assert(targetElement.parentId !== undefined, 'Expected target to have a parent');
const container = getElement(targetElement.parentId, isContainerElement);
const startIndex = container.data.children.indexOf(sourceElement.id);
const indexOfTarget = container.data.children.indexOf(targetElement.id);
const reorderedLayout = reorderWithEdge({
list: container.data.children,
startIndex,
indexOfTarget,
closestEdgeOfTarget,
axis: container.data.layout === 'row' ? 'horizontal' : 'vertical',
});
dispatchAndFlash(
formContainerChildrenReordered({
id: container.id,
children: reorderedLayout,
}),
sourceElement.id
);
return;
} else if (targetElement.parentId !== sourceElement.parentId) {
log.trace('Reparenting element to container with children (dropped on edge of container child)');
assert(targetElement.parentId !== undefined, 'Expected target to have a parent');
const container = getElement(targetElement.parentId, isContainerElement);
const indexOfTarget = container.data.children.indexOf(targetElement.id);
const index = getReorderDestinationIndex({
startIndex: container.data.children.length + 1,
indexOfTarget,
closestEdgeOfTarget,
axis: container.data.layout === 'row' ? 'horizontal' : 'vertical',
});
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: container.id,
index,
}),
sourceElement.id
);
return;
}
}
if (isAddingNewElement && targetElement.parentId === undefined && closestEdgeOfTarget !== 'center') {
log.trace('Inserting new element into root');
const layout = getLayout();
const indexOfTarget = layout.indexOf(targetElement.id);
const index = getReorderDestinationIndex({
startIndex: indexOfTarget + 1,
indexOfTarget,
closestEdgeOfTarget,
axis: 'vertical',
});
sourceElement.parentId = undefined;
dispatchAndFlash(
formElementAdded({
element: sourceElement,
index,
initialValue: getInitialValue(sourceElement),
}),
sourceElement.id
);
return;
}
//#endregion
if (isAddingNewElement && targetElement.parentId !== undefined && closestEdgeOfTarget === 'center') {
log.trace('Adding new element to empty container');
assert(isContainerElement(targetElement), 'Expected target to be a container');
sourceElement.parentId = targetElement.id;
dispatchAndFlash(
formElementAdded({
element: sourceElement,
index: undefined,
initialValue: getInitialValue(sourceElement),
}),
sourceElement.id
);
return;
}
if (isAddingNewElement && targetElement.parentId !== undefined && closestEdgeOfTarget !== 'center') {
log.trace('Inserting new element into container');
const container = getElement(targetElement.parentId, isContainerElement);
const indexOfTarget = container.data.children.indexOf(targetElement.id);
const index = getReorderDestinationIndex({
startIndex: indexOfTarget + 1,
indexOfTarget,
closestEdgeOfTarget,
axis: container.data.layout === 'row' ? 'horizontal' : 'vertical',
});
sourceElement.parentId = container.id;
dispatchAndFlash(
formElementAdded({
element: sourceElement,
index,
initialValue: getInitialValue(sourceElement),
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId === undefined &&
sourceElement.parentId === undefined &&
closestEdgeOfTarget !== 'center'
) {
log.trace('Moving element within root');
const layout = getLayout();
const startIndex = layout.indexOf(sourceElement.id);
const indexOfTarget = layout.indexOf(targetElement.id);
const reorderedLayout = reorderWithEdge({
list: layout,
startIndex,
indexOfTarget,
closestEdgeOfTarget,
axis: 'vertical',
});
dispatchAndFlash(
formRootReordered({
layout: reorderedLayout,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId !== undefined &&
sourceElement.parentId !== undefined &&
targetElement.parentId === sourceElement.parentId &&
closestEdgeOfTarget === 'center'
) {
log.trace('Reparenting element from a container to an empty container with same parent');
assert(isContainerElement(targetElement), 'Expected target to be a container');
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: targetElement.id,
index: undefined,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId !== undefined &&
sourceElement.parentId !== undefined &&
targetElement.parentId === sourceElement.parentId &&
closestEdgeOfTarget !== 'center'
) {
log.trace('Moving element within container');
const container = getElement(targetElement.parentId, isContainerElement);
const startIndex = container.data.children.indexOf(sourceElement.id);
const indexOfTarget = container.data.children.indexOf(targetElement.id);
const reorderedLayout = reorderWithEdge({
list: container.data.children,
startIndex,
indexOfTarget,
closestEdgeOfTarget,
axis: container.data.layout === 'row' ? 'horizontal' : 'vertical',
});
dispatchAndFlash(
formContainerChildrenReordered({
id: container.id,
children: reorderedLayout,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId !== undefined &&
sourceElement.parentId !== undefined &&
targetElement.parentId !== sourceElement.parentId &&
closestEdgeOfTarget === 'center'
) {
log.trace('Reparenting element from one container to an empty container with different parent');
assert(isContainerElement(targetElement), 'Expected target to be a container');
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: targetElement.id,
index: undefined,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId !== undefined &&
sourceElement.parentId !== undefined &&
targetElement.parentId !== sourceElement.parentId &&
closestEdgeOfTarget !== 'center'
) {
log.trace('Moving element from one container to another');
const container = getElement(targetElement.parentId, isContainerElement);
const indexOfTarget = container.data.children.indexOf(targetElement.id);
const index = getReorderDestinationIndex({
startIndex: container.data.children.length + 1,
indexOfTarget,
closestEdgeOfTarget,
axis: container.data.layout === 'row' ? 'horizontal' : 'vertical',
});
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: container.id,
index,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId === undefined &&
sourceElement.parentId !== undefined &&
closestEdgeOfTarget === 'center'
) {
log.trace('Reparenting element from container to empty container');
assert(isContainerElement(targetElement), 'Expected target to be a container');
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: targetElement.id,
index: undefined,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId === undefined &&
sourceElement.parentId !== undefined &&
closestEdgeOfTarget !== 'center'
) {
log.trace('Reparenting element from container to root');
const layout = getLayout();
const indexOfTarget = layout.indexOf(targetElement.id);
const index = getReorderDestinationIndex({
startIndex: layout.length + 1,
indexOfTarget,
closestEdgeOfTarget,
axis: 'vertical',
});
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: undefined,
index,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId === undefined &&
sourceElement.parentId === undefined &&
closestEdgeOfTarget === 'center'
) {
log.trace('Reparenting element from root to empty container');
assert(isContainerElement(targetElement), 'Expected target to be a container');
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: targetElement.id,
index: undefined,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId !== undefined &&
sourceElement.parentId === undefined &&
closestEdgeOfTarget === 'center'
) {
log.trace('Reparenting element from root to empty container');
assert(isContainerElement(targetElement), 'Expected target to be a container');
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: targetElement.id,
index: undefined,
}),
sourceElement.id
);
return;
}
if (
!isAddingNewElement &&
targetElement.parentId !== undefined &&
sourceElement.parentId === undefined &&
closestEdgeOfTarget !== 'center'
) {
log.trace('Reparenting element from root to container');
const container = getElement(targetElement.parentId, isContainerElement);
const indexOfTarget = container.data.children.indexOf(targetElement.id);
const index = getReorderDestinationIndex({
startIndex: indexOfTarget + 1,
indexOfTarget,
closestEdgeOfTarget,
axis: container.data.layout === 'row' ? 'horizontal' : 'vertical',
});
dispatchAndFlash(
formElementReparented({
id: sourceElement.id,
newParentId: targetElement.parentId,
index,
}),
sourceElement.id
);
return;
}
log.warn(parseify({ target, source }), 'Unhandled drop event!');
}
//#endregion
log.warn(parseify({ target, source }), 'Unhandled drop event!');
},
});
}, [dispatchAndFlash]);
};
export const useRootDnd = (ref: RefObject<HTMLElement>) => {
const [isDraggingOver, setIsDraggingOver] = useState(false);
const isEmpty = useAppSelector(selectFormIsEmpty);
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
return dropTargetForElements({
element,
canDrop: ({ source }) => isFormElementDndData(source.data) && isEmpty,
getData: () => buildRootDndData(),
onDrag: ({ location, source }) => {
const innermostDropTargetElement = location.current.dropTargets.at(0)?.element;
// If the innermost target is not this draggable element, bail. We only want to react when dragging over _this_ element.
if (!innermostDropTargetElement || innermostDropTargetElement !== element) {
setIsDraggingOver(false);
return;
}
// Don't allow reparanting to the same container
if (source.element === element) {
setIsDraggingOver(false);
return;
}
setIsDraggingOver(true);
},
onDragLeave: () => {
setIsDraggingOver(false);
},
onDrop: () => {
setIsDraggingOver(false);
},
});
}, [isEmpty, ref]);
return isDraggingOver;
}, [dispatchAndFlash, elementExists, getElement, getInitialValue]);
};
export const useFormElementDnd = (
@@ -566,8 +343,11 @@ export const useFormElementDnd = (
draggableRef: RefObject<HTMLElement>,
dragHandleRef: RefObject<HTMLElement>
) => {
const rootElementId = useAppSelector(selectFormRootElementId);
const [isDragging, setIsDragging] = useState(false);
const [activeDropRegion, setActiveDropRegion] = useState<CenterOrEdge | null>(null);
const getElement = useGetElement();
const getAllowedDropRegions = useGetAllowedDropRegions();
useEffect(() => {
const draggableElement = draggableRef.current;
@@ -575,11 +355,11 @@ export const useFormElementDnd = (
if (!draggableElement || !dragHandleElement) {
return;
}
const _element = getElement(elementId);
return combine(
firefoxDndFix(draggableElement),
draggable({
canDrag: () => elementId !== rootElementId,
element: draggableElement,
dragHandle: dragHandleElement,
getInitialData: () => {
@@ -638,7 +418,7 @@ export const useFormElementDnd = (
},
})
);
}, [dragHandleRef, draggableRef, elementId]);
}, [dragHandleRef, draggableRef, elementId, getAllowedDropRegions, getElement, rootElementId]);
return [activeDropRegion, isDragging] as const;
};

View File

@@ -1,5 +1,14 @@
import { useAppSelector } from 'app/store/storeHooks';
import { getPrefixedId } from 'features/controlLayers/konva/util';
import { selectFormRootElementId } from 'features/nodes/store/workflowSlice';
import { useMemo } from 'react';
export const EDIT_MODE_WRAPPER_CLASS_NAME = getPrefixedId('edit-mode-wrapper', '-');
export const getEditModeWrapperId = (id: string) => `${id}-edit-mode-wrapper`;
export const useIsRootElement = (id: string) => {
const rootElementId = useAppSelector(selectFormRootElementId);
const isRootElement = useMemo(() => rootElementId === id, [rootElementId, id]);
return isRootElement;
};

View File

@@ -2,9 +2,9 @@ import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { FormLayout } from 'features/nodes/components/sidePanel/builder/WorkflowBuilder';
import { FormElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
import { ViewContextProvider } from 'features/nodes/contexts/ViewContext';
import { selectFormIsEmpty } from 'features/nodes/store/workflowSlice';
import { selectFormRootElementId } from 'features/nodes/store/workflowSlice';
import { t } from 'i18next';
import { memo } from 'react';
import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo';
@@ -30,16 +30,16 @@ ViewModeLeftPanelContent.displayName = 'ViewModeLeftPanelContent';
const ViewModeLeftPanelContentInner = memo(() => {
const { isLoading } = useGetOpenAPISchemaQuery();
const isEmpty = useAppSelector(selectFormIsEmpty);
const rootElementId = useAppSelector(selectFormRootElementId);
if (isLoading) {
return <IAINoContentFallback label={t('nodes.loadingNodes')} icon={null} />;
}
if (isEmpty) {
if (!rootElementId) {
return <EmptyState />;
}
return <FormLayout />;
return <FormElementComponent id={rootElementId} />;
});
ViewModeLeftPanelContentInner.displayName = ' ViewModeLeftPanelContentInner';

View File

@@ -9,6 +9,7 @@ import type { WorkflowMode, WorkflowsState as WorkflowState } from 'features/nod
import type { FieldIdentifier, StatefulFieldValue } from 'features/nodes/types/field';
import { isInvocationNode } from 'features/nodes/types/invocation';
import type {
BuilderForm,
ContainerElement,
ElementId,
FormElement,
@@ -18,7 +19,14 @@ import type {
WorkflowCategory,
WorkflowV3,
} from 'features/nodes/types/workflow';
import { isContainerElement, isHeadingElement, isNodeFieldElement, isTextElement } from 'features/nodes/types/workflow';
import {
buildContainer,
getDefaultForm,
isContainerElement,
isHeadingElement,
isNodeFieldElement,
isTextElement,
} from 'features/nodes/types/workflow';
import { isEqual } from 'lodash-es';
import { useMemo } from 'react';
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
@@ -55,10 +63,7 @@ const getBlankWorkflow = (): Omit<WorkflowV3, 'nodes' | 'edges'> => {
exposedFields: [],
meta: { version: '3.0.0', category: 'user' },
id: undefined,
form: {
elements: {},
layout: [],
},
form: getDefaultForm(),
};
};
@@ -134,9 +139,10 @@ export const workflowSlice = createSlice({
state.isTouched = false;
},
formReset: (state) => {
const rootElement = buildContainer('column', []);
state.form = {
elements: {},
layout: [],
elements: { [rootElement.id]: rootElement },
rootElementId: rootElement.id,
};
},
formElementNodeFieldInitialValueChanged: (
@@ -154,7 +160,7 @@ export const workflowSlice = createSlice({
state,
action: PayloadAction<{
element: FormElement;
index?: number;
index: number;
initialValue?: StatefulFieldValue;
}>
) => {
@@ -171,19 +177,16 @@ export const workflowSlice = createSlice({
removeElement({ id, form });
delete state.formFieldInitialValues[id];
},
formRootReordered: (state, action: PayloadAction<{ layout: string[] }>) => {
const { layout } = action.payload;
state.form.layout = layout;
},
formContainerChildrenReordered: (state, action: PayloadAction<{ id: string; children: string[] }>) => {
const { form } = state;
const { id, children } = action.payload;
const container = state.form.elements[id];
const container = form.elements[id];
if (!container || !isContainerElement(container)) {
return;
}
container.data.children = children;
},
formElementReparented: (state, action: PayloadAction<{ id: string; newParentId?: string; index?: number }>) => {
formElementReparented: (state, action: PayloadAction<{ id: string; newParentId: string; index: number }>) => {
const { form } = state;
const { id, newParentId, index } = action.payload;
reparentElement({ form, id, newParentId, index });
@@ -207,25 +210,27 @@ export const workflowSlice = createSlice({
const formFieldInitialValues: Record<string, StatefulFieldValue> = {};
for (const el of Object.values(workflowExtra.form.elements)) {
if (!isNodeFieldElement(el)) {
continue;
if (workflowExtra.form) {
for (const el of Object.values(workflowExtra.form.elements)) {
if (!isNodeFieldElement(el)) {
continue;
}
const { nodeId, fieldName } = el.data.fieldIdentifier;
const node = nodes.find((n) => n.id === nodeId);
if (!isInvocationNode(node)) {
continue;
}
const field = node.data.inputs[fieldName];
if (!field) {
continue;
}
formFieldInitialValues[el.id] = field.value;
}
const { nodeId, fieldName } = el.data.fieldIdentifier;
const node = nodes.find((n) => n.id === nodeId);
if (!isInvocationNode(node)) {
continue;
}
const field = node.data.inputs[fieldName];
if (!field) {
continue;
}
formFieldInitialValues[el.id] = field.value;
}
return {
@@ -264,15 +269,19 @@ export const workflowSlice = createSlice({
});
state.exposedFields = state.exposedFields.filter((field) => !fieldsToRemove.some((f) => isEqual(f, field)));
for (const el of Object.values(state.form?.elements || {})) {
if (!isNodeFieldElement(el)) {
continue;
}
const { nodeId } = el.data.fieldIdentifier;
const removeIndex = action.payload.findLastIndex((change) => change.type === 'remove' && change.id === nodeId);
const addIndex = action.payload.findLastIndex((change) => change.type === 'add' && change.item.id === nodeId);
if (removeIndex > addIndex) {
removeElement({ form: state.form, id: el.id });
if (state.form) {
for (const el of Object.values(state.form?.elements || {})) {
if (!isNodeFieldElement(el)) {
continue;
}
const { nodeId } = el.data.fieldIdentifier;
const removeIndex = action.payload.findLastIndex(
(change) => change.type === 'remove' && change.id === nodeId
);
const addIndex = action.payload.findLastIndex((change) => change.type === 'add' && change.item.id === nodeId);
if (removeIndex > addIndex) {
removeElement({ form: state.form, id: el.id });
}
}
}
@@ -324,7 +333,6 @@ export const {
formElementAdded,
formElementRemoved,
formElementReparented,
formRootReordered,
formContainerChildrenReordered,
formElementHeadingDataChanged,
formElementTextDataChanged,
@@ -367,8 +375,9 @@ export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflo
return noNodes && !isTouched && !savedWorkflow;
});
export const selectFormLayout = createWorkflowSelector((workflow) => workflow.form.layout);
export const selectFormIsEmpty = createWorkflowSelector((workflow) => workflow.form.layout.length === 0);
export const selectFormRootElementId = createWorkflowSelector((workflow) => {
return workflow.form.rootElementId;
});
const buildSelectElement = (id: string) => createWorkflowSelector((workflow) => workflow.form?.elements[id]);
export const useElement = (id: string): FormElement | undefined => {
const selector = useMemo(() => buildSelectElement(id), [id]);
@@ -376,7 +385,7 @@ export const useElement = (id: string): FormElement | undefined => {
return element;
};
const removeElement = (args: { form: NonNullable<WorkflowV3['form']>; id: string }) => {
const removeElement = (args: { form: BuilderForm; id: string }) => {
const { id, form } = args;
const element = form.elements[id];
@@ -386,13 +395,13 @@ const removeElement = (args: { form: NonNullable<WorkflowV3['form']>; id: string
return;
}
delete form.elements[id];
if (!element.parentId) {
form.layout = form.layout.filter((elId) => elId !== id);
if (form.rootElementId === id || !element.parentId) {
// Can't remove the root element
return;
}
delete form.elements[id];
const parent = form.elements[element.parentId];
if (!parent || !isContainerElement(parent)) {
return;
@@ -400,12 +409,7 @@ const removeElement = (args: { form: NonNullable<WorkflowV3['form']>; id: string
parent.data.children = parent.data.children.filter((childId) => childId !== id);
};
const reparentElement = (args: {
form: NonNullable<WorkflowV3['form']>;
id: string;
newParentId?: string;
index?: number;
}) => {
const reparentElement = (args: { form: BuilderForm; id: string; newParentId: string; index: number }) => {
const { form, id, newParentId, index } = args;
const { elements } = form;
@@ -416,70 +420,47 @@ const reparentElement = (args: {
return;
}
if (!element.parentId) {
// Can't reparent the root element
return;
}
if (newParentId === element.parentId) {
// Nothing to do
return;
}
// Reparenting from container to root
if (newParentId === undefined && element.parentId !== undefined) {
const oldParent = elements[element.parentId];
if (!oldParent || !isContainerElement(oldParent)) {
// This should never happen
return;
}
form.layout.splice(index ?? form.layout.length, 0, id);
oldParent.data.children = oldParent.data.children.filter((elementId) => elementId !== id);
element.parentId = newParentId;
const oldParent = elements[element.parentId];
if (!oldParent || !isContainerElement(oldParent)) {
// This should never happen
return;
}
// Reparenting from one container to another container
if (newParentId !== undefined && element.parentId !== undefined) {
const oldParent = elements[element.parentId];
if (!oldParent || !isContainerElement(oldParent)) {
return;
}
const newParent = elements[newParentId];
if (!newParent || !isContainerElement(newParent)) {
return;
}
newParent.data.children.splice(index ?? newParent.data.children.length, 0, id);
oldParent.data.children = oldParent.data.children.filter((elementId) => elementId !== id);
element.parentId = newParentId;
const newParent = elements[newParentId];
if (!newParent || !isContainerElement(newParent)) {
return;
}
// Reparenting from root to container
if (newParentId !== undefined && element.parentId === undefined) {
const newParent = elements[newParentId];
if (!newParent || !isContainerElement(newParent)) {
return;
}
newParent.data.children.splice(index ?? newParent.data.children.length, 0, id);
form.layout = form.layout.filter((elementId) => elementId !== id);
element.parentId = newParentId;
}
// We should never get here!
newParent.data.children.splice(index, 0, id);
oldParent.data.children = oldParent.data.children.filter((elementId) => elementId !== id);
element.parentId = newParentId;
};
const addElement = (args: { form: NonNullable<WorkflowV3['form']>; element: FormElement; index?: number }): boolean => {
const addElement = (args: { form: BuilderForm; element: FormElement; index: number }): boolean => {
const { form, element, index } = args;
const { elements } = form;
// Adding to the root
if (!element.parentId) {
form.elements[element.id] = element;
form.layout.splice(index ?? form.layout.length, 0, element.id);
return true;
// We cannot add a root element
return false;
}
const container = elements[element.parentId];
if (!container || !isContainerElement(container)) {
const parent = elements[element.parentId];
if (!parent || !isContainerElement(parent)) {
return false;
}
elements[element.id] = element;
container.data.children.splice(index ?? container.data.children.length, 0, element.id);
parent.data.children.splice(index, 0, element.id);
return true;
};

View File

@@ -57,34 +57,6 @@ const zWorkflowEdgeCollapsed = zWorkflowEdgeBase.extend({
const zWorkflowEdge = z.union([zWorkflowEdgeDefault, zWorkflowEdgeCollapsed]);
// #endregion
// #region Workflow
export const zWorkflowV3 = z.object({
id: z.string().min(1).optional(),
name: z.string(),
author: z.string(),
description: z.string(),
version: z.string(),
contact: z.string(),
tags: z.string(),
notes: z.string(),
nodes: z.array(zWorkflowNode),
edges: z.array(zWorkflowEdge),
exposedFields: z.array(zFieldIdentifier),
meta: z.object({
category: zWorkflowCategory.default('user'),
version: z.literal('3.0.0'),
}),
form: z
.object({
elements: z.record(z.lazy(() => zFormElement)),
layout: z.array(z.lazy(() => zElementId)),
})
// Catch must be a function else changes to the workflows parsed with this schema will mutate the catch value D:
.catch(() => ({ elements: {}, layout: [] })),
});
export type WorkflowV3 = z.infer<typeof zWorkflowV3>;
// #endregion
// #region Workflow Builder
const zElementId = z.string().trim().min(1);
export type ElementId = z.infer<typeof zElementId>;
@@ -257,3 +229,44 @@ export const buildContainer = (
const zFormElement = z.union([zContainerElement, zNodeFieldElement, zHeadingElement, zTextElement, zDividerElement]);
export type FormElement = z.infer<typeof zFormElement>;
export const getDefaultForm = () => {
const rootElement = buildContainer('column', []);
return {
elements: {
[rootElement.id]: rootElement,
},
rootElementId: rootElement.id,
};
};
const zBuilderForm = z
.object({
elements: z.record(zFormElement),
rootElementId: zElementId,
})
.default(getDefaultForm);
export type BuilderForm = z.infer<typeof zBuilderForm>;
//# endregion
// #region Workflow
export const zWorkflowV3 = z.object({
id: z.string().min(1).optional(),
name: z.string(),
author: z.string(),
description: z.string(),
version: z.string(),
contact: z.string(),
tags: z.string(),
notes: z.string(),
nodes: z.array(zWorkflowNode),
edges: z.array(zWorkflowEdge),
exposedFields: z.array(zFieldIdentifier),
meta: z.object({
category: zWorkflowCategory.default('user'),
version: z.literal('3.0.0'),
}),
form: zBuilderForm,
});
export type WorkflowV3 = z.infer<typeof zWorkflowV3>;
// #endregion

View File

@@ -4,6 +4,7 @@ import { $templates } from 'features/nodes/store/nodesSlice';
import { NODE_WIDTH } from 'features/nodes/types/constants';
import type { FieldInputInstance } from 'features/nodes/types/field';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { getDefaultForm } from 'features/nodes/types/workflow';
import { buildFieldInputInstance } from 'features/nodes/util/schema/buildFieldInputInstance';
import { forEach } from 'lodash-es';
import type { NonNullableGraph } from 'services/api/types';
@@ -37,10 +38,7 @@ export const graphToWorkflow = (graph: NonNullableGraph, autoLayout = true): Wor
exposedFields: [],
edges: [],
nodes: [],
form: {
elements: {},
layout: [],
},
form: getDefaultForm(),
};
// Convert nodes

View File

@@ -1,9 +1,11 @@
import { img_resize, main_model_loader } from 'features/nodes/store/util/testUtils';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { getDefaultForm } from 'features/nodes/types/workflow';
import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow';
import { get } from 'lodash-es';
import { describe, expect, it } from 'vitest';
//TODO(psyche): Test workflow validation for form builder fields
describe('validateWorkflow', () => {
const workflow: WorkflowV3 = {
name: '',
@@ -14,10 +16,7 @@ describe('validateWorkflow', () => {
tags: '',
notes: '',
exposedFields: [],
form: {
elements: {},
layout: [],
},
form: getDefaultForm(),
meta: { version: '3.0.0', category: 'user' },
nodes: [
{

View File

@@ -8,7 +8,12 @@ import {
isModelIdentifierFieldInputInstance,
} from 'features/nodes/types/field';
import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { buildNodeFieldElement, isNodeFieldElement, isWorkflowInvocationNode } from 'features/nodes/types/workflow';
import {
buildContainer,
buildNodeFieldElement,
isNodeFieldElement,
isWorkflowInvocationNode,
} from 'features/nodes/types/workflow';
import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate';
import { t } from 'i18next';
import type { JsonObject } from 'type-fest';
@@ -224,8 +229,15 @@ export const validateWorkflow = async (
}
});
if (_workflow.exposedFields.length > 0 && Object.values(_workflow.form.elements).length === 0) {
// Migrated exposed fields to form elements
// Migrated exposed fields to form elements if they exist and the form does not
if (_workflow.exposedFields.length > 0 && !_workflow.form) {
const rootElement = buildContainer('row', []);
_workflow.form = {
elements: { [rootElement.id]: rootElement },
rootElementId: rootElement.id,
};
for (const { nodeId, fieldName } of _workflow.exposedFields) {
const node = nodes.find(({ id }) => id === nodeId);
if (!node) {
@@ -241,24 +253,27 @@ export const validateWorkflow = async (
}
const element = buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);
_workflow.form.elements[element.id] = element;
_workflow.form.layout.push(element.id);
rootElement.data.children.push(element.id);
}
}
for (const element of Object.values(_workflow.form.elements)) {
if (!isNodeFieldElement(element)) {
continue;
}
const { nodeId, fieldName } = element.data.fieldIdentifier;
const node = nodes.filter(isWorkflowInvocationNode).find(({ id }) => id === nodeId);
const field = node?.data.inputs[fieldName];
if (!field) {
// The form element field no longer exists on the node
delete _workflow.form.elements[element.id];
warnings.push({
message: t('nodes.deletedMissingNodeFieldFormElement', { nodeId, fieldName }),
data: { nodeId, fieldName },
});
// If the form exists, remove any form elements that no longer have a corresponding node field
if (_workflow.form) {
for (const element of Object.values(_workflow.form.elements)) {
if (!isNodeFieldElement(element)) {
continue;
}
const { nodeId, fieldName } = element.data.fieldIdentifier;
const node = nodes.filter(isWorkflowInvocationNode).find(({ id }) => id === nodeId);
const field = node?.data.inputs[fieldName];
if (!field) {
// The form element field no longer exists on the node
delete _workflow.form.elements[element.id];
warnings.push({
message: t('nodes.deletedMissingNodeFieldFormElement', { nodeId, fieldName }),
data: { nodeId, fieldName },
});
}
}
}