mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-04-23 03:00:31 -04:00
refactor(ui): revert to using single tree for form data
This commit is contained in:
@@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user