mirror of
https://github.com/invoke-ai/InvokeAI.git
synced 2026-02-12 07:15:17 -05:00
feat(ui): iterate on builder (WIP)
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
import type { ContainerElement } from 'features/nodes/types/workflow';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const ContainerContext = createContext<ContainerElement['data'] | null>(null);
|
||||
|
||||
export const useContainerContext = () => {
|
||||
const containerDirection = useContext(ContainerContext);
|
||||
return containerDirection;
|
||||
};
|
||||
@@ -1,63 +1,120 @@
|
||||
import { Flex, type SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { ContainerContext } from 'features/nodes/components/sidePanel/builder/ContainerContext';
|
||||
import { Flex, IconButton, type SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import { ContainerContext, DepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { DividerElementComponent } from 'features/nodes/components/sidePanel/builder/DividerElementComponent';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { HeadingElementComponent } from 'features/nodes/components/sidePanel/builder/HeadingElementComponent';
|
||||
import { NodeFieldElementComponent } from 'features/nodes/components/sidePanel/builder/NodeFieldElementComponent';
|
||||
import { TextElementComponent } from 'features/nodes/components/sidePanel/builder/TextElementComponent';
|
||||
import { useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { formElementAdded, selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { ContainerElement } from 'features/nodes/types/workflow';
|
||||
import {
|
||||
container,
|
||||
CONTAINER_CLASS_NAME,
|
||||
DIVIDER_CLASS_NAME,
|
||||
isContainerElement,
|
||||
isDividerElement,
|
||||
isHeadingElement,
|
||||
isNodeFieldElement,
|
||||
isTextElement,
|
||||
} from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import { PiPlusBold } from 'react-icons/pi';
|
||||
import type { Equals } from 'tsafe';
|
||||
import { assert } from 'tsafe';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
gap: 4,
|
||||
flex: '1 1 0',
|
||||
'&[data-container-direction="column"]': {
|
||||
flexDir: 'column',
|
||||
'> :last-child': {
|
||||
flex: '1 0 0',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
},
|
||||
'&[data-container-direction="row"]': {
|
||||
'> *': {
|
||||
flex: '1 1 0',
|
||||
},
|
||||
},
|
||||
[`& > .${DIVIDER_CLASS_NAME}`]: {
|
||||
flex: '0 0 1px',
|
||||
flexDir: 'row',
|
||||
},
|
||||
};
|
||||
|
||||
export const ContainerElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowFormMode);
|
||||
|
||||
if (!el || !isContainerElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { children, direction } = el.data;
|
||||
if (mode === 'view') {
|
||||
return <ContainerElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContainerContext.Provider value={el.data}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
</Flex>
|
||||
</ContainerContext.Provider>
|
||||
);
|
||||
// mode === 'edit'
|
||||
return <ContainerElementComponentEditMode el={el} />;
|
||||
});
|
||||
ContainerElementComponent.displayName = 'ContainerElementComponent';
|
||||
|
||||
export const ContainerElementComponentViewMode = memo(({ el }: { el: ContainerElement }) => {
|
||||
const depth = useContext(DepthContext);
|
||||
const { id, data } = el;
|
||||
const { children, direction } = data;
|
||||
|
||||
return (
|
||||
<DepthContext.Provider value={depth + 1}>
|
||||
<ContainerContext.Provider value={data}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
</Flex>
|
||||
</ContainerContext.Provider>{' '}
|
||||
</DepthContext.Provider>
|
||||
);
|
||||
});
|
||||
ContainerElementComponentViewMode.displayName = 'ContainerElementComponentViewMode';
|
||||
|
||||
export const ContainerElementComponentEditMode = memo(({ el }: { el: ContainerElement }) => {
|
||||
const depth = useContext(DepthContext);
|
||||
|
||||
const { id, data } = el;
|
||||
const { children, direction } = data;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<DepthContext.Provider value={depth + 1}>
|
||||
<ContainerContext.Provider value={data}>
|
||||
<Flex id={id} className={CONTAINER_CLASS_NAME} sx={sx} data-container-direction={direction}>
|
||||
{children.map((childId) => (
|
||||
<FormElementComponent key={childId} id={childId} />
|
||||
))}
|
||||
{direction === 'row' && children.length < 3 && depth < 2 && <AddColumnButton containerId={id} />}
|
||||
{direction === 'column' && depth < 1 && <AddRowButton containerId={id} />}
|
||||
</Flex>
|
||||
</ContainerContext.Provider>
|
||||
</DepthContext.Provider>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
ContainerElementComponentEditMode.displayName = 'ContainerElementComponentEditMode';
|
||||
|
||||
const AddColumnButton = ({ containerId }: { containerId: string }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
const element = container('column', []);
|
||||
dispatch(formElementAdded({ element, containerId }));
|
||||
}, [containerId, dispatch]);
|
||||
return (
|
||||
<IconButton onClick={onClick} aria-label="add column" icon={<PiPlusBold />} h="unset" variant="ghost" size="sm" />
|
||||
);
|
||||
};
|
||||
|
||||
const AddRowButton = ({ containerId }: { containerId: string }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const onClick = useCallback(() => {
|
||||
const element = container('row', []);
|
||||
dispatch(formElementAdded({ element, containerId }));
|
||||
}, [containerId, dispatch]);
|
||||
return (
|
||||
<IconButton onClick={onClick} aria-label="add row" icon={<PiPlusBold />} w="unset" variant="ghost" size="sm" />
|
||||
);
|
||||
};
|
||||
|
||||
// TODO(psyche): Can we move this into a separate file and avoid circular dependencies between it and ContainerElementComponent?
|
||||
export const FormElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
|
||||
@@ -1,22 +1,74 @@
|
||||
import type { SystemStyleObject } from '@invoke-ai/ui-library';
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { ContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { DividerElement } from 'features/nodes/types/workflow';
|
||||
import { DIVIDER_CLASS_NAME, isDividerElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
import { memo, useContext } from 'react';
|
||||
|
||||
const sx: SystemStyleObject = {
|
||||
bg: 'base.700',
|
||||
flexShrink: 0,
|
||||
'&[data-orientation="horizontal"]': {
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
},
|
||||
'&[data-orientation="vertical"]': {
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
},
|
||||
};
|
||||
|
||||
export const DividerElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowFormMode);
|
||||
|
||||
if (!el || !isDividerElement(el)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <Flex id={id} className={DIVIDER_CLASS_NAME} sx={sx} />;
|
||||
if (mode === 'view') {
|
||||
return <DividerElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <DividerElementComponentEditMode el={el} />;
|
||||
});
|
||||
|
||||
DividerElementComponent.displayName = 'DividerElementComponent';
|
||||
|
||||
export const DividerElementComponentViewMode = memo(({ el }: { el: DividerElement }) => {
|
||||
const container = useContext(ContainerContext);
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
id={id}
|
||||
className={DIVIDER_CLASS_NAME}
|
||||
sx={sx}
|
||||
data-orientation={container?.direction === 'column' ? 'horizontal' : 'vertical'}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DividerElementComponentViewMode.displayName = 'DividerElementComponentViewMode';
|
||||
|
||||
export const DividerElementComponentEditMode = memo(({ el }: { el: DividerElement }) => {
|
||||
const container = useContext(ContainerContext);
|
||||
const { id } = el;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex
|
||||
id={id}
|
||||
className={DIVIDER_CLASS_NAME}
|
||||
sx={sx}
|
||||
data-orientation={container?.direction === 'column' ? 'horizontal' : 'vertical'}
|
||||
/>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
DividerElementComponentEditMode.displayName = 'DividerElementComponentEditMode';
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Flex, type FlexProps, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { getPrefixedId } from 'features/controlLayers/konva/util';
|
||||
import { DepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
|
||||
import { formElementRemoved } from 'features/nodes/store/workflowSlice';
|
||||
import { type FormElement, isContainerElement } from 'features/nodes/types/workflow';
|
||||
import { startCase } from 'lodash-es';
|
||||
import { memo, useCallback, useContext } from 'react';
|
||||
import { PiXBold } from 'react-icons/pi';
|
||||
|
||||
export const EDIT_MODE_WRAPPER_CLASS_NAME = getPrefixedId('edit-mode-wrapper', '-');
|
||||
|
||||
const getHeaderBgColor = (depth: number) => {
|
||||
if (depth <= 1) {
|
||||
return 'base.800';
|
||||
}
|
||||
if (depth === 2) {
|
||||
return 'base.750';
|
||||
}
|
||||
return 'base.700';
|
||||
};
|
||||
|
||||
const getHeaderLabel = (el: FormElement) => {
|
||||
if (isContainerElement(el)) {
|
||||
if (el.data.direction === 'column') {
|
||||
return 'Column';
|
||||
}
|
||||
return 'Row';
|
||||
}
|
||||
return startCase(el.type);
|
||||
};
|
||||
|
||||
export const FormElementEditModeWrapper = memo(
|
||||
({ element, children, ...rest }: { element: FormElement } & FlexProps) => {
|
||||
const depth = useContext(DepthContext);
|
||||
const dispatch = useAppDispatch();
|
||||
const removeElement = useCallback(() => {
|
||||
dispatch(formElementRemoved({ id: element.id }));
|
||||
}, [dispatch, element.id]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={EDIT_MODE_WRAPPER_CLASS_NAME}
|
||||
flexDir="column"
|
||||
borderWidth={1}
|
||||
borderRadius="base"
|
||||
borderColor="base.750"
|
||||
alignItems="center"
|
||||
justifyContent="flex-start"
|
||||
w="full"
|
||||
h="full"
|
||||
{...rest}
|
||||
>
|
||||
<Flex
|
||||
w="full"
|
||||
ps={2}
|
||||
h={8}
|
||||
bg={getHeaderBgColor(depth)}
|
||||
borderTopRadius="inherit"
|
||||
borderBottomWidth={1}
|
||||
borderColor="inherit"
|
||||
alignItems="center"
|
||||
cursor="grab"
|
||||
>
|
||||
<Text fontWeight="semibold" noOfLines={1} wordBreak="break-all">
|
||||
{getHeaderLabel(element)}
|
||||
</Text>
|
||||
<Spacer />
|
||||
<IconButton
|
||||
aria-label="delete"
|
||||
onClick={removeElement}
|
||||
icon={<PiXBold />}
|
||||
variant="link"
|
||||
size="sm"
|
||||
alignSelf="stretch"
|
||||
colorScheme="error"
|
||||
/>
|
||||
</Flex>
|
||||
<Flex w="full" p={4} alignItems="center" gap={4}>
|
||||
{children}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FormElementEditModeWrapper.displayName = 'FormElementEditModeWrapper';
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Flex, Heading } from '@invoke-ai/ui-library';
|
||||
import { useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { HeadingElement } from 'features/nodes/types/workflow';
|
||||
import { HEADING_CLASS_NAME, isHeadingElement } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
@@ -13,12 +16,25 @@ const LEVEL_TO_SIZE = {
|
||||
|
||||
export const HeadingElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowFormMode);
|
||||
|
||||
if (!el || !isHeadingElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { content, level } = el.data;
|
||||
if (mode === 'view') {
|
||||
return <HeadingElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <HeadingElementComponentEditMode el={el} />;
|
||||
});
|
||||
|
||||
HeadingElementComponent.displayName = 'HeadingElementComponent';
|
||||
|
||||
export const HeadingElementComponentViewMode = memo(({ el }: { el: HeadingElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content, level } = data;
|
||||
|
||||
return (
|
||||
<Flex id={id} className={HEADING_CLASS_NAME}>
|
||||
@@ -27,4 +43,19 @@ export const HeadingElementComponent = memo(({ id }: { id: string }) => {
|
||||
);
|
||||
});
|
||||
|
||||
HeadingElementComponent.displayName = 'HeadingElementComponent';
|
||||
HeadingElementComponentViewMode.displayName = 'HeadingElementComponentViewMode';
|
||||
|
||||
export const HeadingElementComponentEditMode = memo(({ el }: { el: HeadingElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content, level } = data;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex id={id} className={HEADING_CLASS_NAME}>
|
||||
<Heading size={LEVEL_TO_SIZE[level]}>{content}</Heading>
|
||||
</Flex>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
HeadingElementComponentEditMode.displayName = 'HeadingElementComponentEditMode';
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
|
||||
import { InputFieldViewMode } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldViewMode';
|
||||
import { useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { NodeFieldElement } from 'features/nodes/types/workflow';
|
||||
import { isNodeFieldElement, NODE_FIELD_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const NodeFieldElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowFormMode);
|
||||
|
||||
if (!el || !isNodeFieldElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { fieldIdentifier } = el.data;
|
||||
if (mode === 'view') {
|
||||
return <NodeFieldElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <NodeFieldElementComponentEditMode el={el} />;
|
||||
});
|
||||
|
||||
NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';
|
||||
|
||||
export const NodeFieldElementComponentViewMode = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { id, data } = el;
|
||||
const { fieldIdentifier } = data;
|
||||
|
||||
return (
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME}>
|
||||
@@ -23,4 +39,21 @@ export const NodeFieldElementComponent = memo(({ id }: { id: string }) => {
|
||||
);
|
||||
});
|
||||
|
||||
NodeFieldElementComponent.displayName = 'NodeFieldElementComponent';
|
||||
NodeFieldElementComponentViewMode.displayName = 'NodeFieldElementComponentViewMode';
|
||||
|
||||
export const NodeFieldElementComponentEditMode = memo(({ el }: { el: NodeFieldElement }) => {
|
||||
const { id, data } = el;
|
||||
const { fieldIdentifier } = data;
|
||||
|
||||
return (
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Flex id={id} className={NODE_FIELD_CLASS_NAME} w='full'>
|
||||
<InputFieldGate nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
|
||||
<InputFieldViewMode nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName} />
|
||||
</InputFieldGate>
|
||||
</Flex>
|
||||
</FormElementEditModeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
NodeFieldElementComponentEditMode.displayName = 'NodeFieldElementComponentEditMode';
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
import { Flex, Text } from '@invoke-ai/ui-library';
|
||||
import { useElement } from 'features/nodes/store/workflowSlice';
|
||||
import { useAppSelector } from 'app/store/storeHooks';
|
||||
import { FormElementEditModeWrapper } from 'features/nodes/components/sidePanel/builder/FormElementEditModeWrapper';
|
||||
import { selectWorkflowFormMode, useElement } from 'features/nodes/store/workflowSlice';
|
||||
import type { TextElement } from 'features/nodes/types/workflow';
|
||||
import { isTextElement, TEXT_CLASS_NAME } from 'features/nodes/types/workflow';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const TextElementComponent = memo(({ id }: { id: string }) => {
|
||||
const el = useElement(id);
|
||||
const mode = useAppSelector(selectWorkflowFormMode);
|
||||
|
||||
if (!el || !isTextElement(el)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { content, fontSize } = el.data;
|
||||
if (mode === 'view') {
|
||||
return <TextElementComponentViewMode el={el} />;
|
||||
}
|
||||
|
||||
// mode === 'edit'
|
||||
return <TextElementComponentEditMode el={el} />;
|
||||
});
|
||||
TextElementComponent.displayName = 'TextElementComponent';
|
||||
|
||||
export const TextElementComponentViewMode = memo(({ el }: { el: TextElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content, fontSize } = data;
|
||||
|
||||
return (
|
||||
<Flex id={id} className={TEXT_CLASS_NAME}>
|
||||
@@ -18,5 +33,18 @@ export const TextElementComponent = memo(({ id }: { id: string }) => {
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
TextElementComponentViewMode.displayName = 'TextElementComponentViewMode';
|
||||
|
||||
TextElementComponent.displayName = 'TextElementComponent';
|
||||
export const TextElementComponentEditMode = memo(({ el }: { el: TextElement }) => {
|
||||
const { id, data } = el;
|
||||
const { content, fontSize } = data;
|
||||
|
||||
return (
|
||||
<Flex id={id} className={TEXT_CLASS_NAME}>
|
||||
<FormElementEditModeWrapper element={el}>
|
||||
<Text fontSize={fontSize}>{content}</Text>
|
||||
</FormElementEditModeWrapper>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
TextElementComponentEditMode.displayName = 'TextElementComponentEditMode';
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch } from 'app/store/storeHooks';
|
||||
import { Button, Flex } from '@invoke-ai/ui-library';
|
||||
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
|
||||
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
|
||||
import { FormElementComponent } from 'features/nodes/components/sidePanel/builder/ContainerElementComponent';
|
||||
import { formLoaded } from 'features/nodes/store/workflowSlice';
|
||||
import { formLoaded, formModeToggled, selectWorkflowFormMode } from 'features/nodes/store/workflowSlice';
|
||||
import { elements, rootElementId } from 'features/nodes/types/workflow';
|
||||
import { memo, useEffect } from 'react';
|
||||
import { memo, useCallback, useEffect } from 'react';
|
||||
|
||||
export const WorkflowBuilder = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const mode = useAppSelector(selectWorkflowFormMode);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(formLoaded({ elements, rootElementId }));
|
||||
}, [dispatch]);
|
||||
return (
|
||||
<ScrollableContent>
|
||||
<Flex w="full" justifyContent="center">
|
||||
<Flex w="full" maxW={512}>
|
||||
<Flex flexDir="column" w={mode === 'view' ? '768px' : 'min-content'} minW='768px'>
|
||||
<ToggleModeButton />
|
||||
{rootElementId && <FormElementComponent id={rootElementId} />}
|
||||
</Flex>
|
||||
</Flex>
|
||||
@@ -23,3 +26,15 @@ export const WorkflowBuilder = memo(() => {
|
||||
});
|
||||
|
||||
WorkflowBuilder.displayName = 'WorkflowBuilder';
|
||||
|
||||
const ToggleModeButton = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const mode = useAppSelector(selectWorkflowFormMode);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
dispatch(formModeToggled());
|
||||
}, [dispatch]);
|
||||
|
||||
return <Button onClick={onClick}>{mode === 'view' ? 'Edit' : 'View'}</Button>;
|
||||
});
|
||||
ToggleModeButton.displayName = 'ToggleModeButton';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { ContainerElement } from 'features/nodes/types/workflow';
|
||||
import { createContext } from 'react';
|
||||
|
||||
export const ContainerContext = createContext<ContainerElement['data'] | null>(null);
|
||||
export const DepthContext = createContext<number>(0);
|
||||
@@ -13,7 +13,7 @@ const WorkflowFieldsLinearViewPanel = () => {
|
||||
<Flex layerStyle="first" flexDir="column" w="full" h="full" borderRadius="base" p={2} gap={2}>
|
||||
<Tabs variant="line" display="flex" w="full" h="full" flexDir="column">
|
||||
<TabList>
|
||||
<Tab>Builder</Tab>
|
||||
<Tab>{t('common.builder')}</Tab>
|
||||
<Tab>{t('common.linear')}</Tab>
|
||||
<Tab>{t('common.details')}</Tab>
|
||||
<Tab>JSON</Tab>
|
||||
|
||||
@@ -39,6 +39,7 @@ export type WorkflowsState = Omit<WorkflowV3, 'nodes' | 'edges'> & {
|
||||
_version: 1;
|
||||
isTouched: boolean;
|
||||
mode: WorkflowMode;
|
||||
formMode: WorkflowMode;
|
||||
originalExposedFieldValues: FieldIdentifierWithValue[];
|
||||
searchTerm: string;
|
||||
orderBy?: WorkflowRecordOrderBy;
|
||||
|
||||
@@ -12,7 +12,13 @@ import type {
|
||||
} from 'features/nodes/store/types';
|
||||
import type { FieldIdentifier } from 'features/nodes/types/field';
|
||||
import { isInvocationNode } from 'features/nodes/types/invocation';
|
||||
import type { ContainerElement, FormElement, WorkflowCategory, WorkflowV3 } from 'features/nodes/types/workflow';
|
||||
import {
|
||||
type ContainerElement,
|
||||
type FormElement,
|
||||
isContainerElement,
|
||||
type WorkflowCategory,
|
||||
type WorkflowV3,
|
||||
} from 'features/nodes/types/workflow';
|
||||
import { isEqual, omit, uniqBy } from 'lodash-es';
|
||||
import { useMemo } from 'react';
|
||||
import type { SQLiteDirection, WorkflowRecordOrderBy } from 'services/api/types';
|
||||
@@ -42,6 +48,7 @@ const initialWorkflowState: WorkflowState = {
|
||||
orderBy: undefined, // initial value is decided in component
|
||||
orderDirection: 'DESC',
|
||||
categorySections: {},
|
||||
formMode: 'view',
|
||||
...blankWorkflow,
|
||||
};
|
||||
|
||||
@@ -138,31 +145,39 @@ export const workflowSlice = createSlice({
|
||||
rootElementId: container.id,
|
||||
};
|
||||
},
|
||||
formElementAdded: (state, action: PayloadAction<{ element: FormElement; containerId: string; index: number }>) => {
|
||||
formElementAdded: (state, action: PayloadAction<{ element: FormElement; containerId: string; index?: number }>) => {
|
||||
if (!state.form) {
|
||||
// Cannot add an element if the form has not been created
|
||||
return;
|
||||
}
|
||||
const { elements } = state.form;
|
||||
const { element, containerId, index } = action.payload;
|
||||
const containerElement = state.form.elements[containerId];
|
||||
if (containerElement?.type !== 'container') {
|
||||
return;
|
||||
}
|
||||
state.form.elements[element.id] = element;
|
||||
containerElement.data.children.splice(index, 0, element.id);
|
||||
addElement(elements, element, containerId, index);
|
||||
},
|
||||
formElementRemoved: (state, action: PayloadAction<{ id: string }>) => {
|
||||
if (!state.form) {
|
||||
// Cannot remove an element if the form has not been created
|
||||
return;
|
||||
}
|
||||
const form = state.form;
|
||||
const { elements, rootElementId } = state.form;
|
||||
const { id } = action.payload;
|
||||
buildRemoveElementById(form.elements)(id, form.rootElementId);
|
||||
removeElement(elements, id, rootElementId);
|
||||
},
|
||||
formElementMoved: (state, action: PayloadAction<{ id: string; containerId: string; index: number }>) => {
|
||||
if (!state.form) {
|
||||
// Cannot move an element if the form has not been created
|
||||
return;
|
||||
}
|
||||
const { elements } = state.form;
|
||||
const { id, containerId, index } = action.payload;
|
||||
moveElement(elements, id, containerId, index);
|
||||
},
|
||||
formReset: (state) => {
|
||||
state.form = undefined;
|
||||
},
|
||||
formModeToggled: (state) => {
|
||||
state.formMode = state.formMode === 'edit' ? 'view' : 'edit';
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder.addCase(workflowLoaded, (state, action) => {
|
||||
@@ -281,6 +296,7 @@ export const {
|
||||
formElementAdded,
|
||||
formElementRemoved,
|
||||
formReset,
|
||||
formModeToggled,
|
||||
} = workflowSlice.actions;
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
||||
@@ -305,6 +321,7 @@ const createWorkflowSelector = <T>(selector: Selector<WorkflowState, T>) =>
|
||||
export const selectWorkflowName = createWorkflowSelector((workflow) => workflow.name);
|
||||
export const selectWorkflowId = createWorkflowSelector((workflow) => workflow.id);
|
||||
export const selectWorkflowMode = createWorkflowSelector((workflow) => workflow.mode);
|
||||
export const selectWorkflowFormMode = createWorkflowSelector((workflow) => workflow.formMode);
|
||||
export const selectWorkflowIsTouched = createWorkflowSelector((workflow) => workflow.isTouched);
|
||||
export const selectWorkflowSearchTerm = createWorkflowSelector((workflow) => workflow.searchTerm);
|
||||
export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workflow.orderBy);
|
||||
@@ -326,28 +343,66 @@ export const useElement = (id: string): FormElement | undefined => {
|
||||
return element;
|
||||
};
|
||||
|
||||
const buildRemoveElementById = (elements: NonNullable<WorkflowV3['form']>['elements']) => {
|
||||
const removeElementById = (id: string, containerId: string): boolean => {
|
||||
const container = elements[containerId] as ContainerElement;
|
||||
const addElement = (
|
||||
elements: NonNullable<WorkflowV3['form']>['elements'],
|
||||
element: FormElement,
|
||||
containerId: string,
|
||||
index?: number
|
||||
) => {
|
||||
const container = elements[containerId];
|
||||
if (!container || !isContainerElement(container)) {
|
||||
return;
|
||||
}
|
||||
elements[element.id] = element;
|
||||
if (index === undefined) {
|
||||
container.data.children.push(element.id);
|
||||
} else {
|
||||
container.data.children.splice(index, 0, element.id);
|
||||
}
|
||||
};
|
||||
|
||||
if (container.type !== 'container') {
|
||||
return false;
|
||||
}
|
||||
const removeElement = (
|
||||
elements: NonNullable<WorkflowV3['form']>['elements'],
|
||||
id: string,
|
||||
containerId: string
|
||||
): boolean => {
|
||||
const container = elements[containerId];
|
||||
|
||||
const index = container.data.children.indexOf(id);
|
||||
if (index !== -1) {
|
||||
container.data.children.splice(index, 1);
|
||||
delete elements[id];
|
||||
if (!container || !isContainerElement(container)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const index = container.data.children.indexOf(id);
|
||||
if (index !== -1) {
|
||||
container.data.children.splice(index, 1);
|
||||
delete elements[id];
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const childId of container.data.children) {
|
||||
if (removeElement(elements, id, childId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const childId of container.data.children) {
|
||||
if (removeElementById(id, childId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
return removeElementById;
|
||||
return false;
|
||||
};
|
||||
|
||||
const moveElement = (
|
||||
elements: NonNullable<WorkflowV3['form']>['elements'],
|
||||
id: string,
|
||||
containerId: string,
|
||||
index: number
|
||||
) => {
|
||||
const element = elements[id];
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
const container = elements[containerId];
|
||||
if (!container || !isContainerElement(container)) {
|
||||
return;
|
||||
}
|
||||
|
||||
removeElement(elements, id, containerId);
|
||||
addElement(elements, element, containerId, index);
|
||||
};
|
||||
|
||||
@@ -118,6 +118,10 @@ const nodeField = (
|
||||
fieldIdentifier: { nodeId, fieldName },
|
||||
},
|
||||
};
|
||||
return element;
|
||||
};
|
||||
const _nodeField = (...args: Parameters<typeof nodeField>): NodeFieldElement => {
|
||||
const element = nodeField(...args);
|
||||
addElement(element);
|
||||
return element;
|
||||
};
|
||||
@@ -145,6 +149,10 @@ const heading = (
|
||||
level,
|
||||
},
|
||||
};
|
||||
return element;
|
||||
};
|
||||
const _heading = (...args: Parameters<typeof heading>): HeadingElement => {
|
||||
const element = heading(...args);
|
||||
addElement(element);
|
||||
return element;
|
||||
};
|
||||
@@ -172,6 +180,11 @@ const text = (content: TextElement['data']['content'], fontSize: TextElement['da
|
||||
addElement(element);
|
||||
return element;
|
||||
};
|
||||
const _text = (...args: Parameters<typeof text>): TextElement => {
|
||||
const element = text(...args);
|
||||
addElement(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
const DIVIDER_TYPE = 'divider';
|
||||
export const DIVIDER_CLASS_NAME = getPrefixedId(DIVIDER_TYPE, '-');
|
||||
@@ -188,6 +201,11 @@ const divider = (): DividerElement => {
|
||||
addElement(element);
|
||||
return element;
|
||||
};
|
||||
const _divider = (...args: Parameters<typeof divider>): DividerElement => {
|
||||
const element = divider(...args);
|
||||
addElement(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
export type ContainerElement = {
|
||||
id: string;
|
||||
@@ -208,7 +226,7 @@ const zContainerElement: z.ZodType<ContainerElement> = zElementBase.extend({
|
||||
}),
|
||||
});
|
||||
export const isContainerElement = (el: FormElement): el is ContainerElement => el.type === CONTAINER_TYPE;
|
||||
const container = (
|
||||
export const container = (
|
||||
direction: ContainerElement['data']['direction'],
|
||||
children: ContainerElement['data']['children']
|
||||
): ContainerElement => {
|
||||
@@ -220,6 +238,10 @@ const container = (
|
||||
children,
|
||||
},
|
||||
};
|
||||
return element;
|
||||
};
|
||||
export const _container = (...args: Parameters<typeof container>): ContainerElement => {
|
||||
const element = container(...args);
|
||||
addElement(element);
|
||||
return element;
|
||||
};
|
||||
@@ -228,55 +250,53 @@ const zFormElement = z.union([zContainerElement, zNodeFieldElement, zHeadingElem
|
||||
|
||||
export type FormElement = z.infer<typeof zFormElement>;
|
||||
|
||||
export const rootElementId: string = container('column', [
|
||||
heading('My Cool Workflow', 1).id,
|
||||
text('This is a description of what my workflow does. It does things.', 'md').id,
|
||||
divider().id,
|
||||
heading('First Section', 2).id,
|
||||
text('The first section includes fields relevant to the first section. This note describes that fact.', 'sm').id,
|
||||
divider().id,
|
||||
container('row', [
|
||||
nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
divider().id,
|
||||
nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
divider().id,
|
||||
nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
export const rootElementId: string = _container('column', [
|
||||
_heading('My Cool Workflow', 1).id,
|
||||
_text('This is a description of what my workflow does. It does things.', 'md').id,
|
||||
_divider().id,
|
||||
_heading('First Section', 2).id,
|
||||
_text('The first section includes fields relevant to the first section. This note describes that fact.', 'sm').id,
|
||||
_divider().id,
|
||||
_container('row', [
|
||||
_nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
_nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
_nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
]).id,
|
||||
nodeField('9c058600-8d73-4702-912b-0ccf37403bfd', 'value').id,
|
||||
nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id,
|
||||
nodeField('4e16cbf6-457c-46fb-9ab7-9cb262fa1e03', 'value').id,
|
||||
nodeField('39cb5272-a9d7-4da9-9c35-32e02b46bb34', 'color').id,
|
||||
container('row', [
|
||||
container('column', [
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_nodeField('9c058600-8d73-4702-912b-0ccf37403bfd', 'value').id,
|
||||
_nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id,
|
||||
_nodeField('4e16cbf6-457c-46fb-9ab7-9cb262fa1e03', 'value').id,
|
||||
_nodeField('39cb5272-a9d7-4da9-9c35-32e02b46bb34', 'color').id,
|
||||
_container('row', [
|
||||
_container('column', [
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
]).id,
|
||||
container('column', [
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_container('column', [
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
]).id,
|
||||
container('column', [
|
||||
container('row', [
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_container('column', [
|
||||
_container('row', [
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
]).id,
|
||||
container('row', [
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_container('row', [
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
_nodeField('4f609a81-0e25-47d1-ba0d-f24fedd5273f', 'value').id,
|
||||
]).id,
|
||||
]).id,
|
||||
]).id,
|
||||
nodeField('14744f68-9000-4694-b4d6-cbe83ee231ee', 'model').id,
|
||||
divider().id,
|
||||
text('These are some text that are definitely super helpful.', 'sm').id,
|
||||
divider().id,
|
||||
container('row', [
|
||||
container('column', [
|
||||
nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
_nodeField('14744f68-9000-4694-b4d6-cbe83ee231ee', 'model').id,
|
||||
_divider().id,
|
||||
_text('These are some text that are definitely super helpful.', 'sm').id,
|
||||
_divider().id,
|
||||
_container('row', [
|
||||
_container('column', [
|
||||
_nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
_nodeField('7aed1a5f-7fd7-4184-abe8-ddea0ea5e706', 'image').id,
|
||||
]).id,
|
||||
container('column', [nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id]).id,
|
||||
_container('column', [_nodeField('7a8bbab2-6919-4cfc-bd7c-bcfda3c79ecf', 'value').id]).id,
|
||||
]).id,
|
||||
]).id;
|
||||
|
||||
Reference in New Issue
Block a user