perf(ui): optimize redux selectors for workflow editor

- Build selectors for each node in a react context so components can
re-use the same selectors
- Cache the selectors in the context
This commit is contained in:
psychedelicious
2025-07-16 01:01:17 +10:00
parent 79f65e57eb
commit 55b14c8aaf
97 changed files with 907 additions and 778 deletions

View File

@@ -15,6 +15,8 @@ import { useDndMonitor } from 'features/dnd/useDndMonitor';
import { useDynamicPromptsWatcher } from 'features/dynamicPrompts/hooks/useDynamicPromptsWatcher';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { useWorkflowBuilderWatcher } from 'features/nodes/components/sidePanel/workflow/IsolatedWorkflowBuilderWatcher';
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import { useSyncNodeErrors } from 'features/nodes/store/util/fieldValidators';
import { useReadinessWatcher } from 'features/queue/store/readiness';
import { configChanged } from 'features/system/store/configSlice';
import { selectLanguage } from 'features/system/store/systemSelectors';
@@ -47,10 +49,12 @@ export const GlobalHookIsolator = memo(
useCloseChakraTooltipsOnDragFix();
useNavigationApi();
useDndMonitor();
useSyncNodeErrors();
// Persistent subscription to the queue counts query - canvas relies on this to know if there are pending
// and/or in progress canvas sessions.
useGetQueueCountsByDestinationQuery(queueCountArg);
useSyncExecutionState();
useEffect(() => {
i18n.changeLanguage(language);

View File

@@ -18,3 +18,5 @@ export const getSelectorsOptions = {
argsMemoize: lruMemoize,
}),
};
export const createLruSelector = createSelectorCreator(lruMemoize);

View File

@@ -1,7 +1,6 @@
import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library';
import { Combobox, ConfirmationAlertDialog, Flex, FormControl, Text } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAssertSingleton } from 'common/hooks/useAssertSingleton';
import {
@@ -14,7 +13,7 @@ import { useTranslation } from 'react-i18next';
import { useListAllBoardsQuery } from 'services/api/endpoints/boards';
import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images';
const selectImagesToChange = createMemoizedSelector(
const selectImagesToChange = createSelector(
selectChangeBoardModalSlice,
(changeBoardModal) => changeBoardModal.image_names
);

View File

@@ -22,7 +22,6 @@ import { useConnection } from 'features/nodes/hooks/useConnection';
import { useIsValidConnection } from 'features/nodes/hooks/useIsValidConnection';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useNodeCopyPaste } from 'features/nodes/hooks/useNodeCopyPaste';
import { useSyncExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
import {
$addNodeCmdk,
$cursorPos,
@@ -83,23 +82,16 @@ export const Flow = memo(() => {
const nodes = useAppSelector(selectNodes);
const edges = useAppSelector(selectEdges);
const viewport = useStore($viewport);
const needsFit = useStore($needsFit);
const mayUndo = useAppSelector(selectMayUndo);
const mayRedo = useAppSelector(selectMayRedo);
const shouldSnapToGrid = useAppSelector(selectShouldSnapToGrid);
const selectionMode = useAppSelector(selectSelectionMode);
const { onConnectStart, onConnect, onConnectEnd } = useConnection();
const flowWrapper = useRef<HTMLDivElement>(null);
const isValidConnection = useIsValidConnection();
const cancelConnection = useReactFlowStore(selectCancelConnection);
const updateNodeInternals = useUpdateNodeInternals();
const store = useAppStore();
const isWorkflowsFocused = useIsRegionFocused('workflows');
const isLocked = useIsWorkflowEditorLocked();
useFocusRegion('workflows', flowWrapper);
useSyncExecutionState();
const [borderRadius] = useToken('radii', ['base']);
const flowStyles = useMemo<CSSProperties>(() => ({ borderRadius }), [borderRadius]);
@@ -110,12 +102,12 @@ export const Flow = memo(() => {
if (!flow) {
return;
}
if (needsFit) {
if ($needsFit.get()) {
$needsFit.set(false);
flow.fitView();
}
},
[dispatch, needsFit]
[dispatch]
);
const onEdgesChange: OnEdgesChange<AnyEdge> = useCallback(
@@ -214,6 +206,83 @@ export const Flow = memo(() => {
// #endregion
const onNodeClick = useCallback<NodeMouseHandler<AnyNode>>((e, node) => {
if (!$isSelectingOutputNode.get()) {
return;
}
if (!isInvocationNode(node)) {
return;
}
const { id } = node.data;
$outputNodeId.set(id);
$isSelectingOutputNode.set(false);
}, []);
return (
<>
<ReactFlow<AnyNode, AnyEdge>
id="workflow-editor"
ref={flowWrapper}
defaultViewport={viewport}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
onInit={onInit}
onNodeClick={onNodeClick}
onMouseMove={onMouseMove}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnectStart={onConnectStart}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
onMoveEnd={handleMoveEnd}
connectionLineComponent={CustomConnectionLine}
isValidConnection={isValidConnection}
edgesFocusable={!isLocked}
edgesReconnectable={!isLocked}
nodesDraggable={!isLocked}
nodesConnectable={!isLocked}
nodesFocusable={!isLocked}
elementsSelectable={!isLocked}
minZoom={0.1}
snapToGrid={shouldSnapToGrid}
snapGrid={snapGrid}
connectionRadius={30}
proOptions={proOptions}
style={flowStyles}
onPaneClick={handlePaneClick}
deleteKeyCode={null}
selectionMode={selectionMode}
elevateEdgesOnSelect
nodeDragThreshold={1}
noDragClassName={NO_DRAG_CLASS}
noWheelClassName={NO_WHEEL_CLASS}
noPanClassName={NO_PAN_CLASS}
>
<Background />
</ReactFlow>
<HotkeyIsolator />
</>
);
});
Flow.displayName = 'Flow';
const HotkeyIsolator = memo(() => {
const isLocked = useIsWorkflowEditorLocked();
const mayUndo = useAppSelector(selectMayUndo);
const mayRedo = useAppSelector(selectMayRedo);
const cancelConnection = useReactFlowStore(selectCancelConnection);
const store = useAppStore();
const isWorkflowsFocused = useIsRegionFocused('workflows');
const { copySelection, pasteSelection, pasteSelectionWithEdges } = useNodeCopyPaste();
useRegisteredHotkeys({
@@ -239,12 +308,12 @@ export const Flow = memo(() => {
}
});
if (nodeChanges.length > 0) {
dispatch(nodesChanged(nodeChanges));
store.dispatch(nodesChanged(nodeChanges));
}
if (edgeChanges.length > 0) {
dispatch(edgesChanged(edgeChanges));
store.dispatch(edgesChanged(edgeChanges));
}
}, [dispatch, store]);
}, [store]);
useRegisteredHotkeys({
id: 'selectAll',
category: 'workflows',
@@ -273,20 +342,20 @@ export const Flow = memo(() => {
id: 'undo',
category: 'workflows',
callback: () => {
dispatch(undo());
store.dispatch(undo());
},
options: { enabled: isWorkflowsFocused && !isLocked && mayUndo, preventDefault: true },
dependencies: [mayUndo, isLocked, isWorkflowsFocused],
dependencies: [store, mayUndo, isLocked, isWorkflowsFocused],
});
useRegisteredHotkeys({
id: 'redo',
category: 'workflows',
callback: () => {
dispatch(redo());
store.dispatch(redo());
},
options: { enabled: isWorkflowsFocused && !isLocked && mayRedo, preventDefault: true },
dependencies: [mayRedo, isLocked, isWorkflowsFocused],
dependencies: [store, mayRedo, isLocked, isWorkflowsFocused],
});
const onEscapeHotkey = useCallback(() => {
@@ -313,12 +382,12 @@ export const Flow = memo(() => {
edgeChanges.push({ type: 'remove', id });
});
if (nodeChanges.length > 0) {
dispatch(nodesChanged(nodeChanges));
store.dispatch(nodesChanged(nodeChanges));
}
if (edgeChanges.length > 0) {
dispatch(edgesChanged(edgeChanges));
store.dispatch(edgesChanged(edgeChanges));
}
}, [dispatch, store]);
}, [store]);
useRegisteredHotkeys({
id: 'deleteSelection',
category: 'workflows',
@@ -327,65 +396,6 @@ export const Flow = memo(() => {
dependencies: [deleteSelection, isWorkflowsFocused, isLocked],
});
const onNodeClick = useCallback<NodeMouseHandler<AnyNode>>((e, node) => {
if (!$isSelectingOutputNode.get()) {
return;
}
if (!isInvocationNode(node)) {
return;
}
const { id } = node.data;
$outputNodeId.set(id);
$isSelectingOutputNode.set(false);
}, []);
return (
<ReactFlow<AnyNode, AnyEdge>
id="workflow-editor"
ref={flowWrapper}
defaultViewport={viewport}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodes}
edges={edges}
onInit={onInit}
onNodeClick={onNodeClick}
onMouseMove={onMouseMove}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onConnectStart={onConnectStart}
onConnect={onConnect}
onConnectEnd={onConnectEnd}
onMoveEnd={handleMoveEnd}
connectionLineComponent={CustomConnectionLine}
isValidConnection={isValidConnection}
edgesFocusable={!isLocked}
edgesReconnectable={!isLocked}
nodesDraggable={!isLocked}
nodesConnectable={!isLocked}
nodesFocusable={!isLocked}
elementsSelectable={!isLocked}
minZoom={0.1}
snapToGrid={shouldSnapToGrid}
snapGrid={snapGrid}
connectionRadius={30}
proOptions={proOptions}
style={flowStyles}
onPaneClick={handlePaneClick}
deleteKeyCode={null}
selectionMode={selectionMode}
elevateEdgesOnSelect
nodeDragThreshold={1}
noDragClassName={NO_DRAG_CLASS}
noWheelClassName={NO_WHEEL_CLASS}
noPanClassName={NO_PAN_CLASS}
>
<Background />
</ReactFlow>
);
return null;
});
Flow.displayName = 'Flow';
HotkeyIsolator.displayName = 'HotkeyIsolator';

View File

@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectNodes } from 'features/nodes/store/selectors';
import type { Templates } from 'features/nodes/store/types';
import { selectWorkflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice';
import { isInvocationNode } from 'features/nodes/types/invocation';
@@ -8,9 +8,9 @@ import { isInvocationNode } from 'features/nodes/types/invocation';
import { getFieldColor } from './getEdgeColor';
export const buildSelectAreConnectedNodesSelected = (source: string, target: string) =>
createSelector(selectNodesSlice, (nodes): boolean => {
const sourceNode = nodes.nodes.find((node) => node.id === source);
const targetNode = nodes.nodes.find((node) => node.id === target);
createSelector(selectNodes, (nodes): boolean => {
const sourceNode = nodes.find((node) => node.id === source);
const targetNode = nodes.find((node) => node.id === target);
return Boolean(sourceNode?.selected || targetNode?.selected);
});
@@ -22,10 +22,13 @@ export const buildSelectEdgeColor = (
target: string,
targetHandleId: string | null | undefined
) =>
createSelector(selectNodesSlice, selectWorkflowSettingsSlice, (nodes, workflowSettings): string => {
createSelector(selectNodes, selectWorkflowSettingsSlice, (nodes, workflowSettings): string => {
const { shouldColorEdges } = workflowSettings;
const sourceNode = nodes.nodes.find((node) => node.id === source);
const targetNode = nodes.nodes.find((node) => node.id === target);
if (!shouldColorEdges) {
return colorTokenToCssVar('base.500');
}
const sourceNode = nodes.find((node) => node.id === source);
const targetNode = nodes.find((node) => node.id === target);
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
return colorTokenToCssVar('base.500');
@@ -37,7 +40,7 @@ export const buildSelectEdgeColor = (
const outputFieldTemplate = sourceNodeTemplate?.outputs[sourceHandleId];
const sourceType = isInvocationToInvocationEdge ? outputFieldTemplate?.type : undefined;
return sourceType && shouldColorEdges ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
return sourceType ? getFieldColor(sourceType) : colorTokenToCssVar('base.500');
});
export const buildSelectEdgeLabel = (
@@ -47,9 +50,9 @@ export const buildSelectEdgeLabel = (
target: string,
targetHandleId: string | null | undefined
) =>
createSelector(selectNodesSlice, (nodes): string | null => {
const sourceNode = nodes.nodes.find((node) => node.id === source);
const targetNode = nodes.nodes.find((node) => node.id === target);
createSelector(selectNodes, (nodes): string | null => {
const sourceNode = nodes.find((node) => node.id === source);
const targetNode = nodes.find((node) => node.id === target);
if (!sourceNode || !sourceHandleId || !targetNode || !targetHandleId) {
return null;

View File

@@ -37,7 +37,7 @@ const sx: SystemStyleObject = {
};
const InvocationNode = ({ nodeId, isOpen }: Props) => {
const withFooter = useWithFooter(nodeId);
const withFooter = useWithFooter();
return (
<>
@@ -64,7 +64,7 @@ const InvocationNode = ({ nodeId, isOpen }: Props) => {
export default memo(InvocationNode);
const ConnectionFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useInputFieldNamesConnection(nodeId);
const fieldNames = useInputFieldNamesConnection();
return (
<>
{fieldNames.map((fieldName, i) => (
@@ -80,7 +80,7 @@ const ConnectionFields = memo(({ nodeId }: { nodeId: string }) => {
ConnectionFields.displayName = 'ConnectionFields';
const AnyOrDirectFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useInputFieldNamesAnyOrDirect(nodeId);
const fieldNames = useInputFieldNamesAnyOrDirect();
return (
<>
{fieldNames.map((fieldName) => (
@@ -94,7 +94,7 @@ const AnyOrDirectFields = memo(({ nodeId }: { nodeId: string }) => {
AnyOrDirectFields.displayName = 'AnyOrDirectFields';
const MissingFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useInputFieldNamesMissing(nodeId);
const fieldNames = useInputFieldNamesMissing();
return (
<>
{fieldNames.map((fieldName) => (
@@ -108,7 +108,7 @@ const MissingFields = memo(({ nodeId }: { nodeId: string }) => {
MissingFields.displayName = 'MissingFields';
const OutputFields = memo(({ nodeId }: { nodeId: string }) => {
const fieldNames = useOutputFieldNames(nodeId);
const fieldNames = useOutputFieldNames();
return (
<>
{fieldNames.map((fieldName, i) => (

View File

@@ -10,7 +10,7 @@ interface Props {
}
const InvocationNodeClassificationIcon = ({ nodeId }: Props) => {
const classification = useNodeClassification(nodeId);
const classification = useNodeClassification();
if (!classification || classification === 'stable') {
return null;

View File

@@ -19,7 +19,7 @@ const collapsedHandleStyles: CSSProperties = {
};
const InvocationNodeCollapsedHandles = ({ nodeId }: Props) => {
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplateOrThrow();
if (!template) {
return null;

View File

@@ -16,8 +16,8 @@ type Props = {
const props: ChakraProps = { w: 'unset' };
const InvocationNodeFooter = ({ nodeId }: Props) => {
const hasImageOutput = useNodeHasImageOutput(nodeId);
const isExecutableNode = useIsExecutableNode(nodeId);
const hasImageOutput = useNodeHasImageOutput();
const isExecutableNode = useIsExecutableNode();
const isCacheEnabled = useFeatureStatus('invocationCache');
return (
<Flex

View File

@@ -32,7 +32,7 @@ const sx: SystemStyleObject = {
};
const InvocationNodeHeader = ({ nodeId, isOpen }: Props) => {
const isInvalid = useNodeIsInvalid(nodeId);
const isInvalid = useNodeIsInvalid();
return (
<Flex layerStyle="nodeHeader" sx={sx} data-is-open={isOpen} data-is-invalid={isInvalid}>

View File

@@ -14,7 +14,7 @@ interface Props {
}
export const InvocationNodeInfoIcon = memo(({ nodeId }: Props) => {
const needsUpdate = useNodeNeedsUpdate(nodeId);
const needsUpdate = useNodeNeedsUpdate();
return (
<Tooltip label={<TooltipContent nodeId={nodeId} />} placement="top" shouldWrapChildren>
@@ -26,10 +26,10 @@ export const InvocationNodeInfoIcon = memo(({ nodeId }: Props) => {
InvocationNodeInfoIcon.displayName = 'InvocationNodeInfoIcon';
const TooltipContent = memo(({ nodeId }: { nodeId: string }) => {
const notes = useInvocationNodeNotes(nodeId);
const label = useNodeUserTitleSafe(nodeId);
const version = useNodeVersion(nodeId);
const nodeTemplate = useNodeTemplateOrThrow(nodeId);
const notes = useInvocationNodeNotes();
const label = useNodeUserTitleSafe();
const version = useNodeVersion();
const nodeTemplate = useNodeTemplateOrThrow();
const { t } = useTranslation();
const title = useMemo(() => {

View File

@@ -13,7 +13,7 @@ type Props = {
export const InvocationNodeNotesTextarea = memo(({ nodeId }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const notes = useInvocationNodeNotes(nodeId);
const notes = useInvocationNodeNotes();
const handleNotesChanged = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
dispatch(nodeNotesChanged({ nodeId, notes: e.target.value }));

View File

@@ -14,7 +14,7 @@ type Props = {
const InvocationNodeUnknownFallback = ({ nodeId, isOpen, label, type }: Props) => {
const { t } = useTranslation();
const nodePack = useNodePack(nodeId);
const nodePack = useNodePack();
return (
<>
<Flex

View File

@@ -11,8 +11,8 @@ import { useTranslation } from 'react-i18next';
const SaveToGalleryCheckbox = ({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const hasImageOutput = useNodeHasImageOutput(nodeId);
const isIntermediate = useNodeIsIntermediate(nodeId);
const hasImageOutput = useNodeHasImageOutput();
const isIntermediate = useNodeIsIntermediate();
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(

View File

@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
const UseCacheCheckbox = ({ nodeId }: { nodeId: string }) => {
const dispatch = useAppDispatch();
const useCache = useUseCache(nodeId);
const useCache = useUseCache();
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
dispatch(

View File

@@ -0,0 +1,222 @@
import { useStore } from '@nanostores/react';
import type { Selector } from '@reduxjs/toolkit';
import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectEdges, selectNodes } from 'features/nodes/store/selectors';
import type { InvocationNode, InvocationTemplate } from 'features/nodes/types/invocation';
import type { PropsWithChildren } from 'react';
import { createContext, memo, useContext, useMemo } from 'react';
type InvocationNodeContextValue = {
nodeId: string;
selectNodeSafe: Selector<RootState, InvocationNode | null>;
selectNodeDataSafe: Selector<RootState, InvocationNode['data'] | null>;
selectNodeTypeSafe: Selector<RootState, string | null>;
selectNodeTemplateSafe: Selector<RootState, InvocationTemplate | null>;
selectNodeInputsSafe: Selector<RootState, InvocationNode['data']['inputs'] | null>;
buildSelectInputFieldSafe: (
fieldName: string
) => Selector<RootState, InvocationNode['data']['inputs'][string] | null>;
buildSelectInputFieldTemplateSafe: (
fieldName: string
) => Selector<RootState, InvocationTemplate['inputs'][string] | null>;
buildSelectOutputFieldTemplateSafe: (
fieldName: string
) => Selector<RootState, InvocationTemplate['outputs'][string] | null>;
selectNodeOrThrow: Selector<RootState, InvocationNode>;
selectNodeDataOrThrow: Selector<RootState, InvocationNode['data']>;
selectNodeTypeOrThrow: Selector<RootState, string>;
selectNodeTemplateOrThrow: Selector<RootState, InvocationTemplate>;
selectNodeInputsOrThrow: Selector<RootState, InvocationNode['data']['inputs']>;
buildSelectInputFieldOrThrow: (fieldName: string) => Selector<RootState, InvocationNode['data']['inputs'][string]>;
buildSelectInputFieldTemplateOrThrow: (
fieldName: string
) => Selector<RootState, InvocationTemplate['inputs'][string]>;
buildSelectOutputFieldTemplateOrThrow: (
fieldName: string
) => Selector<RootState, InvocationTemplate['outputs'][string]>;
buildSelectIsInputFieldConnected: (fieldName: string) => Selector<RootState, boolean>;
};
const InvocationNodeContext = createContext<InvocationNodeContextValue | null>(null);
const getSelectorFromCache = <T,>(
cache: Map<string, Selector<RootState, T>>,
key: string,
fallback: () => Selector<RootState, T>
): Selector<RootState, T> => {
let selector = cache.get(key);
if (!selector) {
selector = fallback();
cache.set(key, selector);
}
return selector;
};
export const InvocationNodeContextProvider = memo(({ nodeId, children }: PropsWithChildren<{ nodeId: string }>) => {
const templates = useStore($templates);
const value = useMemo(() => {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const cache: Map<string, Selector<RootState, any>> = new Map();
const selectNodeSafe = getSelectorFromCache(cache, 'selectNodeSafe', () =>
createSelector(selectNodes, (nodes) => {
return (nodes.find(({ id, type }) => type === 'invocation' && id === nodeId) ?? null) as InvocationNode | null;
})
);
const selectNodeDataSafe = getSelectorFromCache(cache, 'selectNodeDataSafe', () =>
createSelector(selectNodeSafe, (node) => {
return node?.data ?? null;
})
);
const selectNodeTypeSafe = getSelectorFromCache(cache, 'selectNodeTypeSafe', () =>
createSelector(selectNodeDataSafe, (data) => {
return data?.type ?? null;
})
);
const selectNodeTemplateSafe = getSelectorFromCache(cache, 'selectNodeTemplateSafe', () =>
createSelector(selectNodeTypeSafe, (type) => {
return type ? (templates[type] ?? null) : null;
})
);
const selectNodeInputsSafe = getSelectorFromCache(cache, 'selectNodeInputsSafe', () =>
createSelector(selectNodeDataSafe, (data) => {
return data?.inputs ?? null;
})
);
const buildSelectInputFieldSafe = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectInputFieldSafe-${fieldName}`, () =>
createSelector(selectNodeInputsSafe, (inputs) => {
return inputs?.[fieldName] ?? null;
})
);
const buildSelectInputFieldTemplateSafe = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectInputFieldTemplateSafe-${fieldName}`, () =>
createSelector(selectNodeTemplateSafe, (template) => {
return template?.inputs?.[fieldName] ?? null;
})
);
const buildSelectOutputFieldTemplateSafe = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectOutputFieldTemplateSafe-${fieldName}`, () =>
createSelector(selectNodeTemplateSafe, (template) => {
return template?.outputs?.[fieldName] ?? null;
})
);
const selectNodeOrThrow = getSelectorFromCache(cache, 'selectNodeOrThrow', () =>
createSelector(selectNodes, (nodes) => {
const node = nodes.find(({ id, type }) => type === 'invocation' && id === nodeId) as InvocationNode | undefined;
if (node === undefined) {
throw new Error(`Cannot find node with id ${nodeId}`);
}
return node;
})
);
const selectNodeDataOrThrow = getSelectorFromCache(cache, 'selectNodeDataOrThrow', () =>
createSelector(selectNodeOrThrow, (node) => {
return node.data;
})
);
const selectNodeTypeOrThrow = getSelectorFromCache(cache, 'selectNodeTypeOrThrow', () =>
createSelector(selectNodeDataOrThrow, (data) => {
return data.type;
})
);
const selectNodeTemplateOrThrow = getSelectorFromCache(cache, 'selectNodeTemplateOrThrow', () =>
createSelector(selectNodeTypeOrThrow, (type) => {
const template = templates[type];
if (template === undefined) {
throw new Error(`Cannot find template for node with id ${nodeId} with type ${type}`);
}
return template;
})
);
const selectNodeInputsOrThrow = getSelectorFromCache(cache, 'selectNodeInputsOrThrow', () =>
createSelector(selectNodeDataOrThrow, (data) => {
return data.inputs;
})
);
const buildSelectInputFieldOrThrow = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectInputFieldOrThrow-${fieldName}`, () =>
createSelector(selectNodeInputsOrThrow, (inputs) => {
const field = inputs[fieldName];
if (field === undefined) {
throw new Error(`Cannot find input field with name ${fieldName} in node ${nodeId}`);
}
return field;
})
);
const buildSelectInputFieldTemplateOrThrow = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectInputFieldTemplateOrThrow-${fieldName}`, () =>
createSelector(selectNodeTemplateOrThrow, (template) => {
const fieldTemplate = template.inputs[fieldName];
if (fieldTemplate === undefined) {
throw new Error(`Cannot find input field template with name ${fieldName} in node ${nodeId}`);
}
return fieldTemplate;
})
);
const buildSelectOutputFieldTemplateOrThrow = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectOutputFieldTemplateOrThrow-${fieldName}`, () =>
createSelector(selectNodeTemplateOrThrow, (template) => {
const fieldTemplate = template.outputs[fieldName];
if (fieldTemplate === undefined) {
throw new Error(`Cannot find output field template with name ${fieldName} in node ${nodeId}`);
}
return fieldTemplate;
})
);
const buildSelectIsInputFieldConnected = (fieldName: string) =>
getSelectorFromCache(cache, `buildSelectIsInputFieldConnected-${fieldName}`, () =>
createSelector(selectEdges, (edges) => {
return edges.some((edge) => {
return edge.target === nodeId && edge.targetHandle === fieldName;
});
})
);
return {
nodeId,
selectNodeSafe,
selectNodeDataSafe,
selectNodeTypeSafe,
selectNodeTemplateSafe,
selectNodeInputsSafe,
buildSelectInputFieldSafe,
buildSelectInputFieldTemplateSafe,
buildSelectOutputFieldTemplateSafe,
selectNodeOrThrow,
selectNodeDataOrThrow,
selectNodeTypeOrThrow,
selectNodeTemplateOrThrow,
selectNodeInputsOrThrow,
buildSelectInputFieldOrThrow,
buildSelectInputFieldTemplateOrThrow,
buildSelectOutputFieldTemplateOrThrow,
buildSelectIsInputFieldConnected,
} satisfies InvocationNodeContextValue;
}, [nodeId, templates]);
return <InvocationNodeContext.Provider value={value}>{children}</InvocationNodeContext.Provider>;
});
export const useInvocationNodeContext = () => {
const context = useContext(InvocationNodeContext);
if (!context) {
throw new Error('useInvocationNodeContext must be used within an InvocationNodeProvider');
}
return context;
};

View File

@@ -48,7 +48,7 @@ InputFieldDescriptionPopover.displayName = 'InputFieldDescriptionPopover';
const Content = memo(({ nodeId, fieldName }: Props) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const description = useInputFieldUserDescriptionSafe(nodeId, fieldName);
const description = useInputFieldUserDescriptionSafe(fieldName);
const onChange = useCallback(
(e: ChangeEvent<HTMLTextAreaElement>) => {
dispatch(fieldDescriptionChanged({ nodeId, fieldName, val: e.target.value }));

View File

@@ -22,9 +22,9 @@ interface Props {
}
export const InputFieldEditModeNodes = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const isInvalid = useInputFieldIsInvalid(nodeId, fieldName);
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldName);
const isInvalid = useInputFieldIsInvalid(fieldName);
const isConnected = useInputFieldIsConnected(fieldName);
if (fieldTemplate.input === 'connection' || isConnected) {
return (

View File

@@ -15,8 +15,8 @@ type Props = PropsWithChildren<{
}>;
export const InputFieldGate = memo(({ nodeId, fieldName, children, fallback, formatLabel }: Props) => {
const hasInstance = useInputFieldInstanceExists(nodeId, fieldName);
const hasTemplate = useInputFieldTemplateExists(nodeId, fieldName);
const hasInstance = useInputFieldInstanceExists(fieldName);
const hasTemplate = useInputFieldTemplateExists(fieldName);
if (!hasTemplate || !hasInstance) {
// fallback may be null, indicating we should render nothing at all - must check for undefined explicitly
@@ -54,7 +54,7 @@ const Fallback = memo(
hasInstance: boolean;
}) => {
const { t } = useTranslation();
const name = useInputFieldNameSafe(nodeId, fieldName);
const name = useInputFieldNameSafe(fieldName);
const label = useMemo(() => {
if (formatLabel) {
return formatLabel(name);

View File

@@ -63,7 +63,7 @@ const handleStyles = {
} satisfies CSSProperties;
export const InputFieldHandle = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
const isModelField = useMemo(() => isModelFieldType(fieldTemplate.type), [fieldTemplate.type]);

View File

@@ -150,8 +150,8 @@ type Props = {
};
export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) => {
const field = useInputFieldInstance(nodeId, fieldName);
const template = useInputFieldTemplateOrThrow(nodeId, fieldName);
const field = useInputFieldInstance(fieldName);
const template = useInputFieldTemplateOrThrow(fieldName);
// When deciding which component to render, first we check the type of the template, which is more efficient than the
// instance type check. The instance type check uses zod and is slower.

View File

@@ -11,7 +11,7 @@ type Props = {
export const InputFieldResetToDefaultValueIconButton = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const { isValueChanged, resetToDefaultValue } = useInputFieldDefaultValue(nodeId, fieldName);
const { isValueChanged, resetToDefaultValue } = useInputFieldDefaultValue(fieldName);
return (
<IconButton

View File

@@ -43,10 +43,10 @@ interface Props {
export const InputFieldTitle = memo((props: Props) => {
const { nodeId, fieldName, isInvalid, isDragging } = props;
const inputRef = useRef<HTMLInputElement>(null);
const label = useInputFieldUserTitleSafe(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
const label = useInputFieldUserTitleSafe(fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(fieldName);
const { t } = useTranslation();
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
const isConnected = useInputFieldIsConnected(fieldName);
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'target');
const isConnectionInProgress = useIsConnectionInProgress();
const connectionError = useConnectionErrorTKey(nodeId, fieldName, 'target');

View File

@@ -15,10 +15,10 @@ interface Props {
export const InputFieldTooltipContent = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const fieldInstance = useInputFieldInstance(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const fieldInstance = useInputFieldInstance(fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldErrors = useInputFieldErrors(nodeId, fieldName);
const fieldErrors = useInputFieldErrors(fieldName);
const fieldTitle = useMemo(() => {
if (fieldInstance.label && fieldTemplate.title) {

View File

@@ -12,7 +12,7 @@ type Props = PropsWithChildren<{
}>;
export const OutputFieldGate = memo(({ nodeId, fieldName, children }: Props) => {
const hasTemplate = useOutputFieldTemplateExists(nodeId, fieldName);
const hasTemplate = useOutputFieldTemplateExists(fieldName);
if (!hasTemplate) {
return <Fallback nodeId={nodeId} fieldName={fieldName} />;
@@ -25,7 +25,7 @@ OutputFieldGate.displayName = 'OutputFieldGate';
const Fallback = memo(({ nodeId, fieldName }: Props) => {
const { t } = useTranslation();
const name = useOutputFieldName(nodeId, fieldName);
const name = useOutputFieldName(fieldName);
return (
<OutputFieldWrapper>

View File

@@ -63,7 +63,7 @@ const handleStyles = {
} satisfies CSSProperties;
export const OutputFieldHandle = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
const fieldTemplate = useOutputFieldTemplate(fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const fieldColor = useMemo(() => getFieldColor(fieldTemplate.type), [fieldTemplate.type]);
const isModelField = useMemo(() => isModelFieldType(fieldTemplate.type), [fieldTemplate.type]);

View File

@@ -27,8 +27,8 @@ type Props = {
};
export const OutputFieldTitle = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
const isConnected = useInputFieldIsConnected(nodeId, fieldName);
const fieldTemplate = useOutputFieldTemplate(fieldName);
const isConnected = useInputFieldIsConnected(fieldName);
const isConnectionStartField = useIsConnectionStartField(nodeId, fieldName, 'source');
const isConnectionInProgress = useIsConnectionInProgress();
const connectionErrorTKey = useConnectionErrorTKey(nodeId, fieldName, 'source');

View File

@@ -10,7 +10,7 @@ interface Props {
}
export const OutputFieldTooltipContent = memo(({ nodeId, fieldName }: Props) => {
const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
const fieldTemplate = useOutputFieldTemplate(fieldName);
const fieldTypeName = useFieldTypeName(fieldTemplate.type);
const { t } = useTranslation();

View File

@@ -40,7 +40,7 @@ export const FloatFieldCollectionInputComponent = memo(
const store = useAppStore();
const { t } = useTranslation();
const isInvalid = useInputFieldIsInvalid(nodeId, field.name);
const isInvalid = useInputFieldIsInvalid(field.name);
const onChangeValue = useCallback(
(value: FloatFieldCollectionInputInstance['value']) => {

View File

@@ -40,7 +40,7 @@ export const ImageFieldCollectionInputComponent = memo(
const { nodeId, field } = props;
const store = useAppStore();
const isInvalid = useInputFieldIsInvalid(nodeId, field.name);
const isInvalid = useInputFieldIsInvalid(field.name);
const dndTargetData = useMemo<AddImagesToNodeImageFieldCollection>(
() =>

View File

@@ -43,7 +43,7 @@ export const IntegerFieldCollectionInputComponent = memo(
const store = useAppStore();
const { t } = useTranslation();
const isInvalid = useInputFieldIsInvalid(nodeId, field.name);
const isInvalid = useInputFieldIsInvalid(field.name);
const onChangeValue = useCallback(
(value: IntegerFieldCollectionInputInstance['value']) => {

View File

@@ -33,7 +33,7 @@ export const StringFieldCollectionInputComponent = memo(
const { t } = useTranslation();
const store = useAppStore();
const isInvalid = useInputFieldIsInvalid(nodeId, field.name);
const isInvalid = useInputFieldIsInvalid(field.name);
const onRemoveString = useCallback(
(index: number) => {

View File

@@ -17,10 +17,10 @@ type Props = {
const NodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
const label = useNodeUserTitleSafe(nodeId);
const label = useNodeUserTitleSafe();
const batchGroupId = useBatchGroupId(nodeId);
const batchGroupColorToken = useBatchGroupColorToken(batchGroupId);
const templateTitle = useNodeTemplateTitleSafe(nodeId);
const templateTitle = useNodeTemplateTitleSafe();
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);

View File

@@ -1,6 +1,7 @@
import type { ChakraProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, useGlobalMenuClose } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useIsWorkflowEditorLocked } from 'features/nodes/hooks/useIsWorkflowEditorLocked';
import { useMouseOverFormField, useMouseOverNode } from 'features/nodes/hooks/useMouseOverNode';
import { useNodeExecutionState } from 'features/nodes/hooks/useNodeExecutionState';
@@ -137,24 +138,26 @@ const NodeWrapper = (props: NodeWrapperProps) => {
);
return (
<Box
onClick={globalMenu.onCloseGlobal}
onDoubleClick={onDoubleClick}
onMouseOver={mouseOverNode.handleMouseOver}
onMouseOut={mouseOverNode.handleMouseOut}
className={DRAG_HANDLE_CLASSNAME}
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
data-is-editor-locked={isLocked}
data-is-selected={selected}
data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField}
>
<Box sx={shadowsSx} />
<Box sx={inProgressSx} data-is-in-progress={isInProgress} />
{children}
<Box className="node-selection-overlay" />
</Box>
<InvocationNodeContextProvider nodeId={nodeId}>
<Box
onClick={globalMenu.onCloseGlobal}
onDoubleClick={onDoubleClick}
onMouseOver={mouseOverNode.handleMouseOver}
onMouseOut={mouseOverNode.handleMouseOut}
className={DRAG_HANDLE_CLASSNAME}
sx={containerSx}
width={width || NODE_WIDTH}
opacity={opacity}
data-is-editor-locked={isLocked}
data-is-selected={selected}
data-is-mouse-over-form-field={mouseOverFormField.isMouseOverFormField}
>
<Box sx={shadowsSx} />
<Box sx={inProgressSx} data-is-in-progress={isInProgress} />
{children}
<Box className="node-selection-overlay" />
</Box>
</InvocationNodeContextProvider>
);
};

View File

@@ -2,6 +2,7 @@ import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { camelCase } from 'es-toolkit/compat';
import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { ContainerElementSettings } from 'features/nodes/components/sidePanel/builder/ContainerElementSettings';
import { useDepthContext } from 'features/nodes/components/sidePanel/builder/contexts';
@@ -49,14 +50,16 @@ export const FormElementEditModeHeader = memo(({ element, dragHandleRef, ...rest
<Spacer />
{isContainerElement(element) && <ContainerElementSettings element={element} />}
{isNodeFieldElement(element) && (
<InputFieldGate
nodeId={element.data.fieldIdentifier.nodeId}
fieldName={element.data.fieldIdentifier.fieldName}
fallback={null} // Do not render these buttons if the field is not found
>
<ZoomToNodeButton element={element} />
<NodeFieldElementSettings element={element} />
</InputFieldGate>
<InvocationNodeContextProvider nodeId={element.data.fieldIdentifier.nodeId}>
<InputFieldGate
nodeId={element.data.fieldIdentifier.nodeId}
fieldName={element.data.fieldIdentifier.fieldName}
fallback={null} // Do not render these buttons if the field is not found
>
<ZoomToNodeButton element={element} />
<NodeFieldElementSettings element={element} />
</InputFieldGate>
</InvocationNodeContextProvider>
)}
<RemoveElementButton element={element} />
</Flex>

View File

@@ -1,4 +1,5 @@
import { useAppSelector } from 'app/store/storeHooks';
import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context';
import { NodeFieldElementEditMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementEditMode';
import { NodeFieldElementViewMode } from 'features/nodes/components/sidePanel/builder/NodeFieldElementViewMode';
import { useElement } from 'features/nodes/components/sidePanel/builder/use-element';
@@ -15,11 +16,19 @@ export const NodeFieldElement = memo(({ id }: { id: string }) => {
}
if (mode === 'view') {
return <NodeFieldElementViewMode el={el} />;
return (
<InvocationNodeContextProvider nodeId={el.data.fieldIdentifier.nodeId}>
<NodeFieldElementViewMode el={el} />
</InvocationNodeContextProvider>
);
}
// mode === 'edit'
return <NodeFieldElementEditMode el={el} />;
return (
<InvocationNodeContextProvider nodeId={el.data.fieldIdentifier.nodeId}>
<NodeFieldElementEditMode el={el} />
</InvocationNodeContextProvider>
);
});
NodeFieldElement.displayName = 'NodeFieldElement';

View File

@@ -13,8 +13,8 @@ export const NodeFieldElementDescriptionEditable = memo(({ el }: { el: NodeField
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.fieldName);
const inputRef = useRef<HTMLTextAreaElement>(null);
const onChange = useCallback(

View File

@@ -1,5 +1,6 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Box, Divider, Flex, FormControl } from '@invoke-ai/ui-library';
import { InvocationNodeContextProvider } from 'features/nodes/components/flow/nodes/Invocation/context';
import { InputFieldGate } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldGate';
import { InputFieldRenderer } from 'features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer';
import { useContainerContext } from 'features/nodes/components/sidePanel/builder/contexts';
@@ -63,25 +64,27 @@ const NodeFieldElementEditModeContent = memo(
<>
<FormElementEditModeHeader dragHandleRef={dragHandleRef} element={el} data-is-dragging={isDragging} />
<FormElementEditModeContent data-is-dragging={isDragging} p={4}>
<InputFieldGate nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<FormControl flex="1 1 0" orientation="vertical">
<NodeFieldElementLabelEditable el={el} />
<Flex w="full" gap={4}>
<InputFieldRenderer
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
settings={data.settings}
/>
</Flex>
{showDescription && <NodeFieldElementDescriptionEditable el={el} />}
{data.settings?.type === 'string-field-config' && data.settings.component === 'dropdown' && (
<>
<Divider />
<NodeFieldElementStringDropdownSettings id={id} settings={data.settings} />
</>
)}
</FormControl>
</InputFieldGate>
<InvocationNodeContextProvider nodeId={fieldIdentifier.nodeId}>
<InputFieldGate nodeId={fieldIdentifier.nodeId} fieldName={fieldIdentifier.fieldName}>
<FormControl flex="1 1 0" orientation="vertical">
<NodeFieldElementLabelEditable el={el} />
<Flex w="full" gap={4}>
<InputFieldRenderer
nodeId={fieldIdentifier.nodeId}
fieldName={fieldIdentifier.fieldName}
settings={data.settings}
/>
</Flex>
{showDescription && <NodeFieldElementDescriptionEditable el={el} />}
{data.settings?.type === 'string-field-config' && data.settings.component === 'dropdown' && (
<>
<Divider />
<NodeFieldElementStringDropdownSettings id={id} settings={data.settings} />
</>
)}
</FormControl>
</InputFieldGate>
</InvocationNodeContextProvider>
</FormElementEditModeContent>
</>
);

View File

@@ -67,7 +67,7 @@ SettingComponent.displayName = 'SettingComponent';
const SettingMin = memo(({ id, settings, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<FloatFieldInputInstance>(nodeId, fieldName);
const field = useInputFieldInstance<FloatFieldInputInstance>(fieldName);
const floatField = useFloatField(nodeId, fieldName, fieldTemplate);
@@ -129,7 +129,7 @@ SettingMin.displayName = 'SettingMin';
const SettingMax = memo(({ id, settings, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<FloatFieldInputInstance>(nodeId, fieldName);
const field = useInputFieldInstance<FloatFieldInputInstance>(fieldName);
const floatField = useFloatField(nodeId, fieldName, fieldTemplate);

View File

@@ -68,7 +68,7 @@ SettingComponent.displayName = 'SettingComponent';
const SettingMin = memo(({ id, settings, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
const field = useInputFieldInstance<IntegerFieldInputInstance>(fieldName);
const integerField = useIntegerField(nodeId, fieldName, fieldTemplate);
@@ -131,7 +131,7 @@ SettingMin.displayName = 'SettingMin';
const SettingMax = memo(({ id, settings, nodeId, fieldName, fieldTemplate }: Props) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const field = useInputFieldInstance<IntegerFieldInputInstance>(nodeId, fieldName);
const field = useInputFieldInstance<IntegerFieldInputInstance>(fieldName);
const integerField = useIntegerField(nodeId, fieldName, fieldTemplate);

View File

@@ -8,8 +8,8 @@ import { memo, useMemo } from 'react';
export const NodeFieldElementLabel = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier } = data;
const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const label = useInputFieldUserTitleSafe(fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.fieldName);
const _label = useMemo(() => label || fieldTemplate.title, [label, fieldTemplate.title]);

View File

@@ -12,8 +12,8 @@ export const NodeFieldElementLabelEditable = memo(({ el }: { el: NodeFieldElemen
const { data } = el;
const { fieldIdentifier } = data;
const dispatch = useAppDispatch();
const label = useInputFieldUserTitleSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const label = useInputFieldUserTitleSafe(fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.fieldName);
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(

View File

@@ -36,7 +36,7 @@ export const NodeFieldElementSettings = memo(({ element }: { element: NodeFieldE
const { id, data } = element;
const { showDescription, fieldIdentifier } = data;
const { nodeId, fieldName } = fieldIdentifier;
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldName);
const { t } = useTranslation();
const dispatch = useAppDispatch();

View File

@@ -37,8 +37,8 @@ const useFormatFallbackLabel = () => {
export const NodeFieldElementViewMode = memo(({ el }: { el: NodeFieldElement }) => {
const { id, data } = el;
const { fieldIdentifier, showDescription } = data;
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateSafe(fieldIdentifier.fieldName);
const containerCtx = useContainerContext();
const formatFallbackLabel = useFormatFallbackLabel();
@@ -70,8 +70,8 @@ NodeFieldElementViewMode.displayName = 'NodeFieldElementViewMode';
const NodeFieldElementViewModeContent = memo(({ el }: { el: NodeFieldElement }) => {
const { data } = el;
const { fieldIdentifier, showDescription } = data;
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.nodeId, fieldIdentifier.fieldName);
const description = useInputFieldUserDescriptionSafe(fieldIdentifier.fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldIdentifier.fieldName);
const _description = useMemo(
() => description || fieldTemplate.description,

View File

@@ -9,8 +9,8 @@ import { useCallback } from 'react';
export const useAddNodeFieldToRoot = (nodeId: string, fieldName: string) => {
const dispatch = useAppDispatch();
const rootElementId = useAppSelector(selectFormRootElementId);
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const field = useInputFieldInstance(nodeId, fieldName);
const fieldTemplate = useInputFieldTemplateOrThrow(fieldName);
const field = useInputFieldInstance(fieldName);
const addNodeFieldToRoot = useCallback(() => {
const element = buildNodeFieldElement(nodeId, fieldName, fieldTemplate.type);

View File

@@ -35,9 +35,9 @@ export default memo(InspectorDetailsTab);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const version = useNodeVersion(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
const needsUpdate = useNodeNeedsUpdate(nodeId);
const version = useNodeVersion();
const template = useNodeTemplateOrThrow();
const needsUpdate = useNodeNeedsUpdate();
return (
<Box position="relative" w="full" h="full">

View File

@@ -37,7 +37,7 @@ const getKey = (result: AnyInvocationOutput, i: number) => `${result.type}-${i}`
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplateOrThrow();
const nes = useNodeExecutionState(nodeId);
if (!nes || nes.outputs.length === 0) {

View File

@@ -14,8 +14,8 @@ type Props = {
const InspectorTabEditableNodeTitle = ({ nodeId, title }: Props) => {
const dispatch = useAppDispatch();
const label = useNodeUserTitleSafe(nodeId);
const templateTitle = useNodeTemplateTitleSafe(nodeId);
const label = useNodeUserTitleSafe();
const templateTitle = useNodeTemplateTitleSafe();
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(

View File

@@ -29,7 +29,7 @@ export default memo(NodeTemplateInspector);
const Content = memo(({ nodeId }: { nodeId: string }) => {
const { t } = useTranslation();
const template = useNodeTemplateOrThrow(nodeId);
const template = useNodeTemplateOrThrow();
return <DataViewer data={template} label={t('nodes.nodeTemplate')} bg="base.850" color="base.200" />;
});

View File

@@ -6,8 +6,8 @@ import { memo } from 'react';
// easier to handle cases where we are missing a node template in the inspector.
export const TemplateGate = memo(
({ nodeId, fallback, children }: PropsWithChildren<{ nodeId: string; fallback: ReactNode }>) => {
const template = useNodeTemplateSafe(nodeId);
({ fallback, children }: PropsWithChildren<{ nodeId: string; fallback: ReactNode }>) => {
const template = useNodeTemplateSafe();
if (!template) {
return fallback;

View File

@@ -96,7 +96,7 @@ const OutputFields = memo(() => {
OutputFields.displayName = 'OutputFields';
const OutputFieldsContent = memo(({ outputNodeId }: { outputNodeId: string }) => {
const outputFieldNames = useOutputFieldNames(outputNodeId);
const outputFieldNames = useOutputFieldNames();
return (
<>
@@ -291,10 +291,10 @@ PublishWorkflowButton.displayName = 'DoValidationRunButton';
const NodeInputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
const mouseOverFormField = useMouseOverFormField(nodeId);
const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
const fieldUserTitle = useInputFieldUserTitleOrThrow(nodeId, fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(nodeId, fieldName);
const nodeUserTitle = useNodeUserTitleOrThrow();
const nodeTemplateTitle = useNodeTemplateTitleOrThrow();
const fieldUserTitle = useInputFieldUserTitleOrThrow(fieldName);
const fieldTemplateTitle = useInputFieldTemplateTitleOrThrow(fieldName);
const zoomToNode = useZoomToNode(nodeId);
return (
@@ -317,9 +317,9 @@ NodeInputFieldPreview.displayName = 'NodeInputFieldPreview';
const NodeOutputFieldPreview = memo(({ nodeId, fieldName }: { nodeId: string; fieldName: string }) => {
const mouseOverFormField = useMouseOverFormField(nodeId);
const nodeUserTitle = useNodeUserTitleOrThrow(nodeId);
const nodeTemplateTitle = useNodeTemplateTitleOrThrow(nodeId);
const fieldTemplate = useOutputFieldTemplate(nodeId, fieldName);
const nodeUserTitle = useNodeUserTitleOrThrow();
const nodeTemplateTitle = useNodeTemplateTitleOrThrow();
const fieldTemplate = useOutputFieldTemplate(fieldName);
const zoomToNode = useZoomToNode(nodeId);
return (

View File

@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectNodes } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { getNeedsUpdate } from 'features/nodes/util/node/nodeUpdate';
import { useMemo } from 'react';
@@ -11,8 +11,11 @@ export const useGetNodesNeedUpdate = () => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) =>
nodes.nodes.filter(isInvocationNode).some((node) => {
createSelector(selectNodes, (nodes) =>
nodes.some((node) => {
if (!isInvocationNode(node)) {
return false; // Invocation nodes do not need updates
}
const template = templates[node.data.type];
if (!template) {
return false;

View File

@@ -1,33 +1,34 @@
import { objectEquals } from '@observ33r/object-equals';
import { createSelector } from '@reduxjs/toolkit';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useInputFieldTemplateOrThrow } from 'features/nodes/hooks/useInputFieldTemplateOrThrow';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { fieldValueReset } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useCallback, useMemo } from 'react';
export const useInputFieldDefaultValue = (nodeId: string, fieldName: string) => {
export const useInputFieldDefaultValue = (fieldName: string) => {
const dispatch = useAppDispatch();
const ctx = useInvocationNodeContext();
const selectDefaultValue = useMemo(
() => createSelector(ctx.buildSelectInputFieldTemplateOrThrow(fieldName), (fieldTemplate) => fieldTemplate.default),
[ctx, fieldName]
);
const defaultValue = useAppSelector(selectDefaultValue);
const fieldTemplate = useInputFieldTemplateOrThrow(nodeId, fieldName);
const selectIsChanged = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const node = nodes.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return;
createSelector(
[ctx.buildSelectInputFieldOrThrow(fieldName), selectDefaultValue],
(fieldInstance, defaultValue) => {
return !objectEquals(fieldInstance.value, defaultValue);
}
const value = node.data.inputs[fieldName]?.value;
return !objectEquals(value, fieldTemplate.default);
}),
[fieldName, fieldTemplate.default, nodeId]
),
[fieldName, selectDefaultValue, ctx]
);
const isValueChanged = useAppSelector(selectIsChanged);
const resetToDefaultValue = useCallback(() => {
dispatch(fieldValueReset({ nodeId, fieldName, value: fieldTemplate.default }));
}, [dispatch, fieldName, fieldTemplate.default, nodeId]);
dispatch(fieldValueReset({ nodeId: ctx.nodeId, fieldName, value: defaultValue }));
}, [dispatch, fieldName, defaultValue, ctx.nodeId]);
return { defaultValue: fieldTemplate.default, isValueChanged, resetToDefaultValue };
return { defaultValue, isValueChanged, resetToDefaultValue };
};

View File

@@ -1,44 +1,36 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useDebouncedAppSelector } from 'app/store/use-debounced-app-selector';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectFieldInputInstance, selectInvocationNodeSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { getFieldErrors } from 'features/nodes/store/util/fieldValidators';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { FieldError } from 'features/nodes/store/util/fieldValidators';
import { $nodeErrors } from 'features/nodes/store/util/fieldValidators';
import { computed } from 'nanostores';
import { useMemo } from 'react';
import { assert } from 'tsafe';
/**
* A hook that returns the errors for a given input field. The errors calculation is debounced.
*
* @param nodeId The id of the node
* @param fieldName The name of the field
* @returns An array of FieldError objects
*/
export const useInputFieldErrors = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
const selectFieldErrors = useMemo(
export const useInputFieldErrors = (fieldName: string): FieldError[] => {
const ctx = useInvocationNodeContext();
const $errors = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const node = selectInvocationNodeSafe(nodes, nodeId);
if (!node) {
// If the node is not found, return an empty array - might happen during node deletion
return [];
computed($nodeErrors, (nodeErrors) => {
const thisNodeErrors = nodeErrors[ctx.nodeId];
if (!thisNodeErrors) {
return EMPTY_ARRAY;
}
const field = selectFieldInputInstance(nodes, nodeId, fieldName);
const nodeTemplate = templates[node.data.type];
assert(nodeTemplate, `Template for input node type ${node.data.type} not found.`);
const fieldTemplate = nodeTemplate.inputs[fieldName];
assert(fieldTemplate, `Template for input field ${fieldName} not found.`);
return getFieldErrors(node, nodeTemplate, field, fieldTemplate, nodes);
const errors = thisNodeErrors.filter((error) => {
error.type === 'field-error' && error.fieldName === fieldName;
});
if (errors.length === 0) {
return EMPTY_ARRAY;
}
return errors as FieldError[];
}),
[nodeId, fieldName, templates]
[ctx, fieldName]
);
const fieldErrors = useDebouncedAppSelector(selectFieldErrors);
return fieldErrors;
return useStore($errors);
};

View File

@@ -1,22 +1,12 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { FieldInputInstance } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useInputFieldInstance = <T extends FieldInputInstance>(nodeId: string, fieldName: string): T => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const instance = selectFieldInputInstanceSafe(nodes, nodeId, fieldName);
assert(instance, `Instance for input field ${fieldName} not found`);
return instance;
}),
[fieldName, nodeId]
);
const instance = useAppSelector(selector);
return instance as T;
export const useInputFieldInstance = <T extends FieldInputInstance>(fieldName: string): T => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => {
return ctx.buildSelectInputFieldOrThrow(fieldName);
}, [ctx, fieldName]);
return useAppSelector(selector) as T;
};

View File

@@ -1,23 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectInvocationNodeSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useInputFieldInstanceExists = (nodeId: string, fieldName: string) => {
export const useInputFieldInstanceExists = (fieldName: string): boolean => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNodeSafe(nodesSlice, nodeId);
if (!node) {
return false;
}
const instance = node.data.inputs[fieldName];
return Boolean(instance);
createSelector(ctx.buildSelectInputFieldSafe(fieldName), (field) => {
return !!field;
}),
[fieldName, nodeId]
[ctx, fieldName]
);
const exists = useAppSelector(selector);
return exists;
return useAppSelector(selector);
};

View File

@@ -1,21 +1,10 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useInputFieldIsConnected = (nodeId: string, fieldName: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const firstConnectedEdge = nodes.edges.find((edge) => {
return edge.target === nodeId && edge.targetHandle === fieldName;
});
return firstConnectedEdge !== undefined;
}),
[fieldName, nodeId]
);
export const useInputFieldIsConnected = (fieldName: string) => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => ctx.buildSelectIsInputFieldConnected(fieldName), [fieldName, ctx]);
const isConnected = useAppSelector(selector);
return isConnected;
return useAppSelector(selector);
};

View File

@@ -1,46 +1,32 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useDebouncedAppSelector } from 'app/store/use-debounced-app-selector';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectFieldInputInstance, selectInvocationNodeSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { getFieldErrors } from 'features/nodes/store/util/fieldValidators';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { $nodeErrors } from 'features/nodes/store/util/fieldValidators';
import { computed } from 'nanostores';
import { useMemo } from 'react';
import { assert } from 'tsafe';
/**
* A hook that returns a boolean representing whether the field is invalid. A field is invalid if it has any errors.
* The errors calculation is debounced.
*
* @param nodeId The id of the node
* @param fieldName The name of the field
*
* @returns A boolean representing whether the field is invalid
*/
export const useInputFieldIsInvalid = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
const selectIsInvalid = useMemo(
export const useInputFieldIsInvalid = (fieldName: string) => {
const ctx = useInvocationNodeContext();
const $isInvalid = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const node = selectInvocationNodeSafe(nodes, nodeId);
if (!node) {
// If the node is not found, return false - might happen during node deletion
computed($nodeErrors, (nodeErrors) => {
const thisNodeErrors = nodeErrors[ctx.nodeId];
if (!thisNodeErrors) {
return false;
}
const field = selectFieldInputInstance(nodes, nodeId, fieldName);
const nodeTemplate = templates[node.data.type];
assert(nodeTemplate, `Template for input node type ${node.data.type} not found.`);
const fieldTemplate = nodeTemplate.inputs[fieldName];
assert(fieldTemplate, `Template for input field ${fieldName} not found.`);
return getFieldErrors(node, nodeTemplate, field, fieldTemplate, nodes).length > 0;
const isFieldInvalid = thisNodeErrors.some((error) => {
error.type === 'field-error' && error.fieldName === fieldName;
});
return isFieldInvalid;
}),
[nodeId, fieldName, templates]
[ctx, fieldName]
);
const isInvalid = useDebouncedAppSelector(selectIsInvalid);
return isInvalid;
return useStore($isInvalid);
};

View File

@@ -1,27 +1,21 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNodeSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useInputFieldNameSafe = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
export const useInputFieldNameSafe = (fieldName: string) => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNodeSafe(nodesSlice, nodeId);
if (!node) {
return fieldName;
createSelector(
[ctx.buildSelectInputFieldSafe(fieldName), ctx.buildSelectInputFieldTemplateSafe(fieldName)],
(fieldInstance, fieldTemplate) => {
const name = fieldInstance?.label || fieldTemplate?.title || fieldName;
return name;
}
const instance = node.data.inputs[fieldName];
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.inputs[fieldName];
const name = instance?.label || fieldTemplate?.title || fieldName;
return name;
}),
[fieldName, nodeId, templates]
),
[fieldName, ctx]
);
const name = useAppSelector(selector);

View File

@@ -1,11 +1,11 @@
import { useNodeData } from 'features/nodes/hooks/useNodeData';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { isSingleOrCollection } from 'features/nodes/types/field';
import { TEMPLATE_BUILDER_MAP } from 'features/nodes/util/schema/buildFieldInputTemplate';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
const isConnectionInputField = (field: FieldInputTemplate) => {
return (
(field.input === 'connection' && !isSingleOrCollection(field.type)) || !(field.type.name in TEMPLATE_BUILDER_MAP)
@@ -19,41 +19,52 @@ const isAnyOrDirectInputField = (field: FieldInputTemplate) => {
);
};
export const useInputFieldNamesMissing = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
const node = useNodeData(nodeId);
const fieldNames = useMemo(() => {
const instanceFields = new Set(Object.keys(node.inputs));
const allTemplateFields = new Set(Object.keys(template.inputs));
return Array.from(instanceFields.difference(allTemplateFields));
}, [node.inputs, template.inputs]);
return fieldNames;
export const useInputFieldNamesMissing = () => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector([ctx.selectNodeInputsOrThrow, ctx.selectNodeTemplateSafe], (inputs, template) => {
const instanceFieldNames = new Set(Object.keys(inputs));
const templateFieldNames = new Set(Object.keys(template?.inputs ?? {}));
return Array.from(instanceFieldNames.difference(templateFieldNames));
}),
[ctx]
);
return useAppSelector(selector);
};
export const useInputFieldNamesAnyOrDirect = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => {
const anyOrDirectFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {
if (isAnyOrDirectInputField(fieldTemplate)) {
anyOrDirectFields.push(fieldName);
}
}
return anyOrDirectFields;
}, [template.inputs]);
return fieldNames;
export const useInputFieldNamesAnyOrDirect = () => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector([ctx.selectNodeTemplateSafe], (template) => {
const fieldNames: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template?.inputs ?? {})) {
if (isAnyOrDirectInputField(fieldTemplate)) {
fieldNames.push(fieldName);
}
}
return fieldNames;
}),
[ctx]
);
return useAppSelector(selector);
};
export const useInputFieldNamesConnection = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => {
const connectionFields: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template.inputs)) {
if (isConnectionInputField(fieldTemplate)) {
connectionFields.push(fieldName);
}
}
return connectionFields;
}, [template.inputs]);
return fieldNames;
export const useInputFieldNamesConnection = () => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector([ctx.selectNodeTemplateSafe], (template) => {
const fieldNames: string[] = [];
for (const [fieldName, fieldTemplate] of Object.entries(template?.inputs ?? {})) {
if (isConnectionInputField(fieldTemplate)) {
fieldNames.push(fieldName);
}
}
return fieldNames;
}),
[ctx]
);
return useAppSelector(selector);
};

View File

@@ -1,28 +1,13 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNodeSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useInputFieldTemplateExists = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
export const useInputFieldTemplateExists = (fieldName: string): boolean => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNodeSafe(nodesSlice, nodeId);
if (!node) {
return false;
}
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.inputs[fieldName];
return Boolean(fieldTemplate);
}),
[fieldName, nodeId, templates]
() => createSelector(ctx.buildSelectInputFieldTemplateSafe(fieldName), (fieldTemplate) => !!fieldTemplate),
[ctx, fieldName]
);
const exists = useAppSelector(selector);
return exists;
return useAppSelector(selector);
};

View File

@@ -1,24 +1,18 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
/**
* Returns the template for a specific input field of a node.
*
* **Note:** This hook will throw an error if the template for the input field is not found.
*
* @param nodeId - The ID of the node.
* @param fieldName - The name of the input field.
* @throws Will throw an error if the template for the input field is not found.
*/
export const useInputFieldTemplateOrThrow = (nodeId: string, fieldName: string): FieldInputTemplate => {
const template = useNodeTemplateOrThrow(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.inputs[fieldName];
assert(_fieldTemplate, `Template for input field ${fieldName} not found.`);
return _fieldTemplate;
}, [fieldName, template.inputs]);
return fieldTemplate;
export const useInputFieldTemplateOrThrow = (fieldName: string): FieldInputTemplate => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => ctx.buildSelectInputFieldTemplateOrThrow(fieldName), [ctx, fieldName]);
return useAppSelector(selector);
};

View File

@@ -1,4 +1,5 @@
import { useNodeTemplateSafe } from 'features/nodes/hooks/useNodeTemplateSafe';
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { FieldInputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
@@ -10,8 +11,8 @@ import { useMemo } from 'react';
* @param nodeId - The ID of the node.
* @param fieldName - The name of the input field.
*/
export const useInputFieldTemplateSafe = (nodeId: string, fieldName: string): FieldInputTemplate | null => {
const template = useNodeTemplateSafe(nodeId);
const fieldTemplate = useMemo(() => template?.inputs[fieldName] ?? null, [fieldName, template?.inputs]);
return fieldTemplate;
export const useInputFieldTemplateSafe = (fieldName: string): FieldInputTemplate | null => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => ctx.buildSelectInputFieldTemplateSafe(fieldName), [ctx, fieldName]);
return useAppSelector(selector);
};

View File

@@ -1,16 +1,13 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
import { assert } from 'tsafe';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useInputFieldTemplateTitleOrThrow = (nodeId: string, fieldName: string): string => {
const template = useNodeTemplateOrThrow(nodeId);
const title = useMemo(() => {
const fieldTemplate = template.inputs[fieldName];
assert(fieldTemplate, `Template for input field ${fieldName} not found.`);
return fieldTemplate.title;
}, [fieldName, template.inputs]);
return title;
export const useInputFieldTemplateTitleOrThrow = (fieldName: string): string => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() => createSelector(ctx.buildSelectInputFieldTemplateOrThrow(fieldName), (fieldTemplate) => fieldTemplate.title),
[ctx, fieldName]
);
return useAppSelector(selector);
};

View File

@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
/**
@@ -8,19 +8,13 @@ import { useMemo } from 'react';
*
* If the node doesn't exist or is not an invocation node, an empty string is returned.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserDescriptionSafe = (nodeId: string, fieldName: string) => {
export const useInputFieldUserDescriptionSafe = (fieldName: string) => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(
selectNodesSlice,
(nodes) => selectFieldInputInstanceSafe(nodes, nodeId, fieldName)?.description ?? ''
),
[fieldName, nodeId]
() => createSelector(ctx.buildSelectInputFieldSafe(fieldName), (field) => field?.description ?? ''),
[ctx, fieldName]
);
const description = useAppSelector(selector);
return description;
return useAppSelector(selector);
};

View File

@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstance, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
/**
@@ -8,16 +8,13 @@ import { useMemo } from 'react';
*
* If the node doesn't exist or is not an invocation node, an error is thrown.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserTitleOrThrow = (nodeId: string, fieldName: string): string => {
export const useInputFieldUserTitleOrThrow = (fieldName: string): string => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() => createSelector(selectNodesSlice, (nodes) => selectFieldInputInstance(nodes, nodeId, fieldName).label),
[fieldName, nodeId]
() => createSelector(ctx.buildSelectInputFieldOrThrow(fieldName), (field) => field.label),
[ctx, fieldName]
);
const title = useAppSelector(selector);
return title;
return useAppSelector(selector);
};

View File

@@ -1,6 +1,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
/**
@@ -8,17 +8,13 @@ import { useMemo } from 'react';
*
* If the node doesn't exist or is not an invocation node, an empty string is returned.
*
* @param nodeId The ID of the node
* @param fieldName The name of the field
*/
export const useInputFieldUserTitleSafe = (nodeId: string, fieldName: string): string => {
export const useInputFieldUserTitleSafe = (fieldName: string): string => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => selectFieldInputInstanceSafe(nodes, nodeId, fieldName)?.label ?? ''),
[fieldName, nodeId]
() => createSelector(ctx.buildSelectInputFieldSafe(fieldName), (field) => field?.label ?? ''),
[ctx, fieldName]
);
const title = useAppSelector(selector);
return title;
return useAppSelector(selector);
};

View File

@@ -3,8 +3,8 @@ import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useIsExecutableNode = (nodeId: string) => {
const template = useNodeTemplateOrThrow(nodeId);
export const useIsExecutableNode = () => {
const template = useNodeTemplateOrThrow();
const isExecutableNode = useMemo(
() => !isBatchNodeType(template.type) && !isGeneratorNodeType(template.type),
[template]

View File

@@ -3,8 +3,8 @@ import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeClassification = (nodeId: string): Classification => {
const template = useNodeTemplateOrThrow(nodeId);
export const useNodeClassification = (): Classification => {
const template = useNodeTemplateOrThrow();
const classification = useMemo(() => template.classification, [template]);
return classification;
};

View File

@@ -1,19 +1,8 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { InvocationNodeData } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useNodeData = (nodeId: string): InvocationNodeData => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
return selectNodeData(nodes, nodeId);
}),
[nodeId]
);
const nodeData = useAppSelector(selector);
return nodeData;
export const useNodeData = (): InvocationNodeData => {
const ctx = useInvocationNodeContext();
return useAppSelector(ctx.selectNodeDataOrThrow);
};

View File

@@ -2,7 +2,7 @@ import { useStore } from '@nanostores/react';
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppSelector } from 'app/store/storeHooks';
import { deepClone } from 'common/util/deepClone';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { selectNodes } from 'features/nodes/store/selectors';
import type { NodeExecutionStates } from 'features/nodes/store/types';
import type { NodeExecutionState } from 'features/nodes/types/invocation';
import { zNodeStatus } from 'features/nodes/types/invocation';
@@ -38,7 +38,7 @@ export const upsertExecutionState = (nodeId: string, updates?: Partial<NodeExecu
}
};
const selectNodeIds = createMemoizedSelector(selectNodesSlice, (nodesSlice) => nodesSlice.nodes.map((node) => node.id));
const selectNodeIds = createMemoizedSelector(selectNodes, (nodes) => nodes.map((node) => node.id));
export const useSyncExecutionState = () => {
const nodeIds = useAppSelector(selectNodeIds);

View File

@@ -1,10 +1,10 @@
import { some } from 'es-toolkit/compat';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
import { useNodeTemplateSafe } from './useNodeTemplateSafe';
export const useNodeHasImageOutput = (nodeId: string): boolean => {
const template = useNodeTemplateOrThrow(nodeId);
export const useNodeHasImageOutput = (): boolean => {
const template = useNodeTemplateSafe();
const hasImageOutput = useMemo(
() =>
some(

View File

@@ -1,17 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useNodeIsIntermediate = (nodeId: string): boolean => {
export const useNodeIsIntermediate = (): boolean => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
return selectNodeData(nodes, nodeId)?.isIntermediate ?? false;
createSelector(ctx.selectNodeDataSafe, (data) => {
return data?.isIntermediate ?? false;
}),
[nodeId]
[ctx]
);
const isIntermediate = useAppSelector(selector);
return isIntermediate;
return useAppSelector(selector);
};

View File

@@ -1,21 +1,9 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { getInvocationNodeErrors } from 'features/nodes/store/util/fieldValidators';
import { useMemo } from 'react';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { $nodeErrors } from 'features/nodes/store/util/fieldValidators';
export const useNodeIsInvalid = (nodeId: string) => {
const templates = useStore($templates);
const selectHasErrors = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const errors = getInvocationNodeErrors(nodeId, templates, nodes);
return errors.length > 0;
}),
[nodeId, templates]
);
const hasErrors = useAppSelector(selectHasErrors);
export const useNodeIsInvalid = () => {
const ctx = useInvocationNodeContext();
const hasErrors = useStore($nodeErrors, { keys: [ctx.nodeId] })[ctx.nodeId];
return hasErrors;
};

View File

@@ -4,10 +4,10 @@ import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useNodeNeedsUpdate = (nodeId: string) => {
const type = useNodeType(nodeId);
const version = useNodeVersion(nodeId);
const template = useNodeTemplateOrThrow(nodeId);
export const useNodeNeedsUpdate = () => {
const type = useNodeType();
const version = useNodeVersion();
const template = useNodeTemplateOrThrow();
const needsUpdate = useMemo(() => {
if (type !== template.type) {
return true;

View File

@@ -1,19 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useInvocationNodeNotes = (nodeId: string): string => {
export const useInvocationNodeNotes = (): string => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
const node = selectInvocationNode(nodes, nodeId);
return node.data.notes;
createSelector(ctx.selectNodeDataSafe, (data) => {
return data?.notes ?? '';
}),
[nodeId]
[ctx]
);
const notes = useAppSelector(selector);
return notes;
return useAppSelector(selector);
};

View File

@@ -1,17 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useNodePack = (nodeId: string): string | null => {
export const useNodePack = (): string | null => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
return selectNodeData(nodes, nodeId)?.nodePack ?? null;
createSelector(ctx.selectNodeDataSafe, (data) => {
return data?.nodePack ?? null;
}),
[nodeId]
[ctx]
);
const nodePack = useAppSelector(selector);
return nodePack;
return useAppSelector(selector);
};

View File

@@ -1,17 +1,8 @@
import { useStore } from '@nanostores/react';
import { useNodeType } from 'features/nodes/hooks/useNodeType';
import { $templates } from 'features/nodes/store/nodesSlice';
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useNodeTemplateOrThrow = (nodeId: string): InvocationTemplate => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => {
const t = templates[type];
assert(t, `Template for node type ${type} not found`);
return t;
}, [templates, type]);
return template;
export const useNodeTemplateOrThrow = (): InvocationTemplate => {
const ctx = useInvocationNodeContext();
return useAppSelector(ctx.selectNodeTemplateOrThrow);
};

View File

@@ -1,12 +1,8 @@
import { useStore } from '@nanostores/react';
import { useNodeType } from 'features/nodes/hooks/useNodeType';
import { $templates } from 'features/nodes/store/nodesSlice';
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { InvocationTemplate } from 'features/nodes/types/invocation';
import { useMemo } from 'react';
export const useNodeTemplateSafe = (nodeId: string): InvocationTemplate | null => {
const templates = useStore($templates);
const type = useNodeType(nodeId);
const template = useMemo(() => templates[type] ?? null, [templates, type]);
return template;
export const useNodeTemplateSafe = (): InvocationTemplate | null => {
const ctx = useInvocationNodeContext();
return useAppSelector(ctx.selectNodeTemplateSafe);
};

View File

@@ -1,25 +1,10 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useNodeTemplateTitleOrThrow = (nodeId: string): string => {
const templates = useStore($templates);
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
assert(isInvocationNode(node), 'Node not found');
const template = templates[node.data.type];
assert(template, 'Template not found');
return template.title;
}),
[nodeId, templates]
);
const title = useAppSelector(selector);
return title;
export const useNodeTemplateTitleOrThrow = (): string => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => createSelector(ctx.selectNodeTemplateOrThrow, (template) => template.title), [ctx]);
return useAppSelector(selector);
};

View File

@@ -1,25 +1,13 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useNodeTemplateTitleSafe = (nodeId: string): string | null => {
const templates = useStore($templates);
export const useNodeTemplateTitleSafe = (): string | null => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
if (!isInvocationNode(node)) {
return null;
}
const template = templates[node.data.type];
return template?.title ?? null;
}),
[nodeId, templates]
() => createSelector(ctx.selectNodeTemplateSafe, (template) => template?.title ?? ''),
[ctx]
);
const title = useAppSelector(selector);
return title;
return useAppSelector(selector);
};

View File

@@ -1,18 +1,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
import { useMemo } from 'react';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
export const useNodeType = (nodeId: string): string => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
return selectNodeData(nodes, nodeId).type;
}),
[nodeId]
);
const type = useAppSelector(selector);
return type;
export const useNodeType = (): string => {
const ctx = useInvocationNodeContext();
return useAppSelector(ctx.selectNodeTypeOrThrow);
};

View File

@@ -1,21 +1,10 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { isInvocationNode } from 'features/nodes/types/invocation';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
import { assert } from 'tsafe';
export const useNodeUserTitleOrThrow = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
assert(isInvocationNode(node), 'Node not found');
return node.data.label;
}),
[nodeId]
);
const title = useAppSelector(selector);
return title;
export const useNodeUserTitleOrThrow = () => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => createSelector(ctx.selectNodeDataOrThrow, (data) => data.label), [ctx]);
return useAppSelector(selector);
};

View File

@@ -1,18 +1,10 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useNodeUserTitleSafe = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = nodesSlice.nodes.find((node) => node.id === nodeId);
return node?.data.label ?? null;
}),
[nodeId]
);
const title = useAppSelector(selector);
return title;
export const useNodeUserTitleSafe = () => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => createSelector(ctx.selectNodeDataSafe, (data) => data?.label ?? ''), [ctx]);
return useAppSelector(selector);
};

View File

@@ -1,18 +1,10 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useNodeVersion = (nodeId: string) => {
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
return node.data.version;
}),
[nodeId]
);
const version = useAppSelector(selector);
return version;
export const useNodeVersion = () => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => createSelector(ctx.selectNodeDataOrThrow, (data) => data.version), [ctx]);
return useAppSelector(selector);
};

View File

@@ -1,26 +1,19 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNode, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useOutputFieldName = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
export const useOutputFieldName = (fieldName: string) => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNode(nodesSlice, nodeId);
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.outputs[fieldName];
createSelector([ctx.buildSelectOutputFieldTemplateSafe(fieldName)], (fieldTemplate) => {
const name = fieldTemplate?.title || fieldName;
return name;
}),
[fieldName, nodeId, templates]
[fieldName, ctx]
);
const name = useAppSelector(selector);
return name;
return useAppSelector(selector);
};

View File

@@ -1,11 +1,17 @@
import { map } from 'es-toolkit/compat';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { getSortedFilteredFieldNames } from 'features/nodes/util/node/getSortedFilteredFieldNames';
import { useMemo } from 'react';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useOutputFieldNames = (nodeId: string): string[] => {
const template = useNodeTemplateOrThrow(nodeId);
const fieldNames = useMemo(() => getSortedFilteredFieldNames(map(template.outputs)), [template.outputs]);
return fieldNames;
export const useOutputFieldNames = (): string[] => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector([ctx.selectNodeTemplateOrThrow], (template) =>
getSortedFilteredFieldNames(Object.values(template.outputs))
),
[ctx]
);
return useAppSelector(selector);
};

View File

@@ -1,15 +1,10 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import type { FieldOutputTemplate } from 'features/nodes/types/field';
import { useMemo } from 'react';
import { assert } from 'tsafe';
import { useNodeTemplateOrThrow } from './useNodeTemplateOrThrow';
export const useOutputFieldTemplate = (nodeId: string, fieldName: string): FieldOutputTemplate => {
const template = useNodeTemplateOrThrow(nodeId);
const fieldTemplate = useMemo(() => {
const _fieldTemplate = template.outputs[fieldName];
assert(_fieldTemplate, `Template for output field ${fieldName} not found`);
return _fieldTemplate;
}, [fieldName, template.outputs]);
return fieldTemplate;
export const useOutputFieldTemplate = (fieldName: string): FieldOutputTemplate => {
const ctx = useInvocationNodeContext();
const selector = useMemo(() => ctx.buildSelectOutputFieldTemplateOrThrow(fieldName), [ctx, fieldName]);
return useAppSelector(selector);
};

View File

@@ -1,28 +1,16 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectInvocationNodeSafe, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useOutputFieldTemplateExists = (nodeId: string, fieldName: string) => {
const templates = useStore($templates);
export const useOutputFieldTemplateExists = (fieldName: string) => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodesSlice) => {
const node = selectInvocationNodeSafe(nodesSlice, nodeId);
if (!node) {
return false;
}
const nodeTemplate = templates[node.data.type];
const fieldTemplate = nodeTemplate?.outputs[fieldName];
return Boolean(fieldTemplate);
createSelector(ctx.buildSelectOutputFieldTemplateSafe(fieldName), (fieldTemplate) => {
return !!fieldTemplate;
}),
[fieldName, nodeId, templates]
[ctx, fieldName]
);
const exists = useAppSelector(selector);
return exists;
return useAppSelector(selector);
};

View File

@@ -1,17 +1,16 @@
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectNodeData, selectNodesSlice } from 'features/nodes/store/selectors';
import { useInvocationNodeContext } from 'features/nodes/components/flow/nodes/Invocation/context';
import { useMemo } from 'react';
export const useUseCache = (nodeId: string) => {
export const useUseCache = () => {
const ctx = useInvocationNodeContext();
const selector = useMemo(
() =>
createSelector(selectNodesSlice, (nodes) => {
return selectNodeData(nodes, nodeId)?.useCache ?? false;
createSelector(ctx.selectNodeDataSafe, (data) => {
return data?.useCache ?? false;
}),
[nodeId]
[ctx]
);
const useCache = useAppSelector(selector);
return useCache;
return useAppSelector(selector);
};

View File

@@ -4,9 +4,9 @@ import { useMemo } from 'react';
import { useNodeHasImageOutput } from './useNodeHasImageOutput';
export const useWithFooter = (nodeId: string) => {
const hasImageOutput = useNodeHasImageOutput(nodeId);
const isExecutableNode = useIsExecutableNode(nodeId);
export const useWithFooter = () => {
const hasImageOutput = useNodeHasImageOutput();
const isExecutableNode = useIsExecutableNode();
const isCacheEnabled = useFeatureStatus('invocationCache');
const withFooter = useMemo(
() => isExecutableNode && (hasImageOutput || isCacheEnabled),

View File

@@ -223,63 +223,75 @@ export const nodesSlice = createSlice({
//
// But we don't have immer as an explicit dependency so we'll just cast.
state.nodes = applyNodeChanges(action.payload, state.nodes) as typeof state.nodes;
// Remove edges that are no longer valid, due to a removed or otherwise changed node
const edgeChanges: EdgeChange<AnyEdge>[] = [];
state.edges.forEach((e) => {
const sourceExists = state.nodes.some((n) => n.id === e.source);
const targetExists = state.nodes.some((n) => n.id === e.target);
if (!(sourceExists && targetExists)) {
edgeChanges.push({ type: 'remove', id: e.id });
}
});
state.edges = applyEdgeChanges<AnyEdge>(edgeChanges, state.edges);
// If a node was removed, we should remove any form fields that were associated with it. However, node changes
// may remove and then add the same node back. For example, when updating a workflow, we replace old nodes with
// updated nodes. In this case, we should not remove the form fields. To handle this, we find the last remove
// and add changes for each exposed field. If the remove change comes after the add change, we remove the exposed
// field.
for (const el of Object.values(state.form.elements)) {
if (!isNodeFieldElement(el)) {
continue;
// Remove edges that are no longer valid, due to a removed or otherwise changed node
const didNodesChange = action.payload.some(
(change) => change.type === 'add' || change.type === 'remove' || change.type === 'replace'
);
if (didNodesChange) {
const edgeChanges: EdgeChange<AnyEdge>[] = [];
for (const e of state.edges) {
const sourceExists = state.nodes.some((n) => n.id === e.source);
const targetExists = state.nodes.some((n) => n.id === e.target);
if (!(sourceExists && targetExists)) {
edgeChanges.push({ type: 'remove', id: e.id });
}
}
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 (edgeChanges.length > 0) {
state.edges = applyEdgeChanges<AnyEdge>(edgeChanges, state.edges);
}
}
const wereNodesRemoved = action.payload.some((change) => change.type === 'remove' || change.type === 'replace');
if (wereNodesRemoved) {
// If a node was removed, we should remove any form fields that were associated with it. However, node changes
// may remove and then add the same node back. For example, when updating a workflow, we replace old nodes with
// updated nodes. In this case, we should not remove the form fields. To handle this, we find the last remove
// and add changes for each exposed field. If the remove change comes after the add change, we remove the exposed
// 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 });
}
}
}
},
edgesChanged: (state, action: PayloadAction<EdgeChange<AnyEdge>[]>) => {
const changes: EdgeChange<AnyEdge>[] = [];
// We may need to massage the edge changes or otherwise handle them
action.payload.forEach((change) => {
for (const change of action.payload) {
if (change.type === 'remove' || change.type === 'select') {
const edge = state.edges.find((e) => e.id === change.id);
// If we deleted or selected a collapsed edge, we need to find its "hidden" edges and do the same to them
if (edge && edge.type === 'collapsed') {
const hiddenEdges = state.edges.filter((e) => e.source === edge.source && e.target === edge.target);
if (change.type === 'remove') {
hiddenEdges.forEach(({ id }) => {
for (const { id } of hiddenEdges) {
if (change.type === 'remove') {
changes.push({ type: 'remove', id });
});
}
if (change.type === 'select') {
hiddenEdges.forEach(({ id }) => {
}
if (change.type === 'select') {
changes.push({ type: 'select', id, selected: change.selected });
});
}
}
}
}
if (change.type === 'add') {
} else if (change.type === 'add') {
if (!change.item.type) {
// We must add the edge type!
change.item.type = 'default';
}
}
changes.push(change);
});
}
state.edges = applyEdgeChanges(changes, state.edges);
},
fieldLabelChanged: (

View File

@@ -1,3 +1,9 @@
import { useStore } from '@nanostores/react';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { debounce } from 'es-toolkit';
import { $templates } from 'features/nodes/store/nodesSlice';
import { selectNodesSlice } from 'features/nodes/store/selectors';
import type { NodesState, Templates } from 'features/nodes/store/types';
import type {
FieldInputInstance,
@@ -32,6 +38,8 @@ import {
} from 'features/nodes/types/field';
import { type InvocationNode, type InvocationTemplate, isInvocationNode } from 'features/nodes/types/invocation';
import { t } from 'i18next';
import { map } from 'nanostores';
import { useEffect } from 'react';
import { assert } from 'tsafe';
type FieldValidationFunc<TValue extends StatefulFieldValue, TTemplate extends FieldInputTemplate> = (
@@ -164,13 +172,13 @@ const validateNumberFieldValue: FieldValidationFunc<
return reasons;
};
type NodeError = {
export type NodeError = {
type: 'node-error';
nodeId: string;
issue: string;
};
type FieldError = {
export type FieldError = {
type: 'field-error';
nodeId: string;
fieldName: string;
@@ -223,7 +231,7 @@ export const getFieldErrors = (
nodeId,
fieldName,
prefix,
issue: t('parameters.invoke.missingInputForField'),
issue: 'parameters.invoke.missingInputForField',
});
} else if (isConnected) {
// Connected fields have no value to validate - they are OK
@@ -274,5 +282,57 @@ export const getInvocationNodeErrors = (
errors.push(...getFieldErrors(node, nodeTemplate, field, fieldTemplate, nodesState));
}
if (errors.length === 0) {
return EMPTY_ARRAY;
}
return errors;
};
export const $nodeErrors = map<Record<string, (NodeError | FieldError)[]>>({});
export const syncNodeErrors = (nodesState: NodesState, templates: Templates) => {
for (const node of nodesState.nodes) {
const errors: (NodeError | FieldError)[] = [];
if (!isInvocationNode(node)) {
continue;
}
const nodeTemplate = templates[node.data.type];
if (!nodeTemplate) {
errors.push({ type: 'node-error', nodeId: node.id, issue: t('parameters.invoke.missingNodeTemplate') });
$nodeErrors.setKey(node.id, errors);
continue;
}
for (const [fieldName, field] of Object.entries(node.data.inputs)) {
const fieldTemplate = nodeTemplate.inputs[fieldName];
if (!fieldTemplate) {
errors.push({ type: 'node-error', nodeId: node.id, issue: t('parameters.invoke.missingFieldTemplate') });
$nodeErrors.setKey(node.id, errors);
continue;
}
errors.push(...getFieldErrors(node, nodeTemplate, field, fieldTemplate, nodesState));
}
if (errors.length === 0) {
$nodeErrors.setKey(node.id, EMPTY_ARRAY);
continue;
}
$nodeErrors.setKey(node.id, errors);
}
};
const debouncedSyncNodeErrors = debounce(syncNodeErrors, 300);
export const useSyncNodeErrors = () => {
const nodesState = useAppSelector(selectNodesSlice);
const templates = useStore($templates);
useEffect(() => {
debouncedSyncNodeErrors(nodesState, templates);
}, [nodesState, templates]);
};

View File

@@ -1,4 +1,4 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice';
import { selectConfigSlice } from 'features/system/store/configSlice';
@@ -6,7 +6,7 @@ import { useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
const createIsTooLargeToUpscaleSelector = (imageDTO?: ImageDTO | null) =>
createMemoizedSelector(selectUpscaleSlice, selectConfigSlice, (upscale, config) => {
createSelector(selectUpscaleSlice, selectConfigSlice, (upscale, config) => {
const { upscaleModel, scale } = upscale;
const { maxUpscaleDimension } = config;

View File

@@ -2,8 +2,8 @@ import type { WorkflowV3 } from 'features/nodes/types/workflow';
import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow';
import { toast } from 'features/toast/toast';
import { useValidateAndLoadWorkflow } from 'features/workflowLibrary/hooks/useValidateAndLoadWorkflow';
import { t } from 'i18next';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useLazyGetImageWorkflowQuery } from 'services/api/endpoints/images';
import type { NonNullableGraph } from 'services/api/types';
import { assert } from 'tsafe';
@@ -15,7 +15,6 @@ import { assert } from 'tsafe';
* and handles the loading process.
*/
export const useLoadWorkflowFromImage = () => {
const { t } = useTranslation();
const [getWorkflowAndGraphFromImage] = useLazyGetImageWorkflowQuery();
const validateAndLoadWorkflow = useValidateAndLoadWorkflow();
const loadWorkflowFromImage = useCallback(
@@ -63,7 +62,7 @@ export const useLoadWorkflowFromImage = () => {
onCompleted?.();
}
},
[getWorkflowAndGraphFromImage, validateAndLoadWorkflow, t]
[getWorkflowAndGraphFromImage, validateAndLoadWorkflow]
);
return loadWorkflowFromImage;

View File

@@ -11,8 +11,8 @@ import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow'
import { toast } from 'features/toast/toast';
import { navigationApi } from 'features/ui/layouts/navigation-api';
import { VIEWER_PANEL_ID, WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared';
import { t } from 'i18next';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { serializeError } from 'serialize-error';
import { checkBoardAccess, checkImageAccess, checkModelAccess } from 'services/api/hooks/accessChecks';
import { z } from 'zod/v4';
@@ -36,7 +36,6 @@ const log = logger('workflows');
* ...each of which internally uses hook.
*/
export const useValidateAndLoadWorkflow = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const validateAndLoadWorkflow = useCallback(
/**
@@ -144,7 +143,7 @@ export const useValidateAndLoadWorkflow = () => {
return null;
}
},
[dispatch, t]
[dispatch]
);
return validateAndLoadWorkflow;

View File

@@ -98,7 +98,10 @@ const dynamicBaseQuery: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryE
return rawBaseQuery(args, api, extraOptions);
};
const createLruSelector = createSelectorCreator(lruMemoize);
const createLruSelector = createSelectorCreator({
memoize: lruMemoize,
argsMemoize: lruMemoize,
});
const customCreateApi = buildCreateApi(
coreModule({ createSelector: createLruSelector }),