feat(ui): track & indicate workflow saved status

This commit is contained in:
psychedelicious 2023-12-06 23:44:00 +11:00
parent 4627a7c75f
commit 6d176601cc
8 changed files with 101 additions and 23 deletions

View File

@ -171,7 +171,8 @@
"created": "Created", "created": "Created",
"prevPage": "Previous Page", "prevPage": "Previous Page",
"nextPage": "Next Page", "nextPage": "Next Page",
"unknownError": "Unknown Error" "unknownError": "Unknown Error",
"unsaved": "Unsaved"
}, },
"controlnet": { "controlnet": {
"controlAdapter_one": "Control Adapter", "controlAdapter_one": "Control Adapter",

View File

@ -1,11 +1,14 @@
import { Text } from '@chakra-ui/layout'; import { Text } from '@chakra-ui/layout';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { memo } from 'react'; import { memo } from 'react';
import { useTranslation } from 'react-i18next';
const TopCenterPanel = () => { const TopCenterPanel = () => {
const { t } = useTranslation();
const name = useAppSelector( const name = useAppSelector(
(state) => state.workflow.name || 'Untitled Workflow' (state) => state.workflow.name || 'Untitled Workflow'
); );
const isTouched = useAppSelector((state) => state.workflow.isTouched);
return ( return (
<Text <Text
@ -18,6 +21,7 @@ const TopCenterPanel = () => {
opacity={0.8} opacity={0.8}
> >
{name} {name}
{isTouched ? ` (${t('common.unsaved')})` : ''}
</Text> </Text>
); );
}; };

View File

@ -1,18 +1,19 @@
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks'; import { useAppSelector } from 'app/store/storeHooks';
import { buildWorkflow } from 'features/nodes/util/workflow/buildWorkflow'; import { buildWorkflow } from 'features/nodes/util/workflow/buildWorkflow';
import { omit } from 'lodash-es';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
export const useWorkflow = () => { export const useWorkflow = () => {
const nodes_ = useAppSelector((state: RootState) => state.nodes.nodes); const nodes_ = useAppSelector((state) => state.nodes.nodes);
const edges_ = useAppSelector((state: RootState) => state.nodes.edges); const edges_ = useAppSelector((state) => state.nodes.edges);
const workflow_ = useAppSelector((state: RootState) => state.workflow); const workflow_ = useAppSelector((state) => state.workflow);
const [nodes] = useDebounce(nodes_, 300); const [nodes] = useDebounce(nodes_, 300);
const [edges] = useDebounce(edges_, 300); const [edges] = useDebounce(edges_, 300);
const [workflow] = useDebounce(workflow_, 300); const [workflow] = useDebounce(workflow_, 300);
const builtWorkflow = useMemo( const builtWorkflow = useMemo(
() => buildWorkflow({ nodes, edges, workflow }), () =>
buildWorkflow({ nodes, edges, workflow: omit(workflow, 'isTouched') }),
[nodes, edges, workflow] [nodes, edges, workflow]
); );

View File

@ -1,4 +1,4 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { createSlice, isAnyOf, PayloadAction } from '@reduxjs/toolkit';
import { workflowLoaded } from 'features/nodes/store/actions'; import { workflowLoaded } from 'features/nodes/store/actions';
import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants'; import { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants';
import { import {
@ -936,4 +936,42 @@ export const {
edgeAdded, edgeAdded,
} = nodesSlice.actions; } = nodesSlice.actions;
// This is used for tracking `state.workflow.isTouched`
export const isAnyNodeOrEdgeMutation = isAnyOf(
connectionEnded,
connectionMade,
edgeDeleted,
edgesChanged,
edgesDeleted,
edgeUpdated,
fieldBoardValueChanged,
fieldBooleanValueChanged,
fieldColorValueChanged,
fieldControlNetModelValueChanged,
fieldEnumModelValueChanged,
fieldImageValueChanged,
fieldIPAdapterModelValueChanged,
fieldT2IAdapterModelValueChanged,
fieldLabelChanged,
fieldLoRAModelValueChanged,
fieldMainModelValueChanged,
fieldNumberValueChanged,
fieldRefinerModelValueChanged,
fieldSchedulerValueChanged,
fieldStringValueChanged,
fieldVaeModelValueChanged,
nodeAdded,
nodeReplaced,
nodeIsIntermediateChanged,
nodeIsOpenChanged,
nodeLabelChanged,
nodeNotesChanged,
nodesChanged,
nodesDeleted,
nodeUseCacheChanged,
notesNodeValueChanged,
selectionPasted,
edgeAdded
);
export default nodesSlice.reducer; export default nodesSlice.reducer;

View File

@ -41,4 +41,6 @@ export type NodesState = {
selectionMode: SelectionMode; selectionMode: SelectionMode;
}; };
export type WorkflowsState = Omit<WorkflowV2, 'nodes' | 'edges'>; export type WorkflowsState = Omit<WorkflowV2, 'nodes' | 'edges'> & {
isTouched: boolean;
};

View File

@ -1,6 +1,10 @@
import { PayloadAction, createSlice } from '@reduxjs/toolkit'; import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { workflowLoaded } from 'features/nodes/store/actions'; import { workflowLoaded } from 'features/nodes/store/actions';
import { nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice'; import {
nodeEditorReset,
nodesDeleted,
isAnyNodeOrEdgeMutation,
} from 'features/nodes/store/nodesSlice';
import { WorkflowsState as WorkflowState } from 'features/nodes/store/types'; import { WorkflowsState as WorkflowState } from 'features/nodes/store/types';
import { FieldIdentifier } from 'features/nodes/types/field'; import { FieldIdentifier } from 'features/nodes/types/field';
import { cloneDeep, isEqual, uniqBy } from 'lodash-es'; import { cloneDeep, isEqual, uniqBy } from 'lodash-es';
@ -15,6 +19,7 @@ export const initialWorkflowState: WorkflowState = {
notes: '', notes: '',
exposedFields: [], exposedFields: [],
meta: { version: '2.0.0', category: 'user' }, meta: { version: '2.0.0', category: 'user' },
isTouched: true,
}; };
const workflowSlice = createSlice({ const workflowSlice = createSlice({
@ -29,6 +34,7 @@ const workflowSlice = createSlice({
state.exposedFields.concat(action.payload), state.exposedFields.concat(action.payload),
(field) => `${field.nodeId}-${field.fieldName}` (field) => `${field.nodeId}-${field.fieldName}`
); );
state.isTouched = true;
}, },
workflowExposedFieldRemoved: ( workflowExposedFieldRemoved: (
state, state,
@ -37,37 +43,48 @@ const workflowSlice = createSlice({
state.exposedFields = state.exposedFields.filter( state.exposedFields = state.exposedFields.filter(
(field) => !isEqual(field, action.payload) (field) => !isEqual(field, action.payload)
); );
state.isTouched = true;
}, },
workflowNameChanged: (state, action: PayloadAction<string>) => { workflowNameChanged: (state, action: PayloadAction<string>) => {
state.name = action.payload; state.name = action.payload;
state.isTouched = true;
}, },
workflowDescriptionChanged: (state, action: PayloadAction<string>) => { workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.description = action.payload; state.description = action.payload;
state.isTouched = true;
}, },
workflowTagsChanged: (state, action: PayloadAction<string>) => { workflowTagsChanged: (state, action: PayloadAction<string>) => {
state.tags = action.payload; state.tags = action.payload;
state.isTouched = true;
}, },
workflowAuthorChanged: (state, action: PayloadAction<string>) => { workflowAuthorChanged: (state, action: PayloadAction<string>) => {
state.author = action.payload; state.author = action.payload;
state.isTouched = true;
}, },
workflowNotesChanged: (state, action: PayloadAction<string>) => { workflowNotesChanged: (state, action: PayloadAction<string>) => {
state.notes = action.payload; state.notes = action.payload;
state.isTouched = true;
}, },
workflowVersionChanged: (state, action: PayloadAction<string>) => { workflowVersionChanged: (state, action: PayloadAction<string>) => {
state.version = action.payload; state.version = action.payload;
state.isTouched = true;
}, },
workflowContactChanged: (state, action: PayloadAction<string>) => { workflowContactChanged: (state, action: PayloadAction<string>) => {
state.contact = action.payload; state.contact = action.payload;
state.isTouched = true;
}, },
workflowIDChanged: (state, action: PayloadAction<string>) => { workflowIDChanged: (state, action: PayloadAction<string>) => {
state.id = action.payload; state.id = action.payload;
}, },
workflowReset: () => cloneDeep(initialWorkflowState), workflowReset: () => cloneDeep(initialWorkflowState),
workflowSaved: (state) => {
state.isTouched = false;
},
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(workflowLoaded, (state, action) => { builder.addCase(workflowLoaded, (state, action) => {
const { nodes: _nodes, edges: _edges, ...workflow } = action.payload; const { nodes: _nodes, edges: _edges, ...workflowExtra } = action.payload;
return cloneDeep(workflow); return { ...cloneDeep(workflowExtra), isTouched: true };
}); });
builder.addCase(nodesDeleted, (state, action) => { builder.addCase(nodesDeleted, (state, action) => {
@ -79,6 +96,10 @@ const workflowSlice = createSlice({
}); });
builder.addCase(nodeEditorReset, () => cloneDeep(initialWorkflowState)); builder.addCase(nodeEditorReset, () => cloneDeep(initialWorkflowState));
builder.addMatcher(isAnyNodeOrEdgeMutation, (state) => {
state.isTouched = true;
});
}, },
}); });
@ -94,6 +115,7 @@ export const {
workflowContactChanged, workflowContactChanged,
workflowIDChanged, workflowIDChanged,
workflowReset, workflowReset,
workflowSaved,
} = workflowSlice.actions; } = workflowSlice.actions;
export default workflowSlice.reducer; export default workflowSlice.reducer;

View File

@ -1,14 +1,18 @@
import { ToastId, useToast } from '@chakra-ui/react'; import { ToastId, useToast } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { useWorkflow } from 'features/nodes/hooks/useWorkflow'; import { useWorkflow } from 'features/nodes/hooks/useWorkflow';
import { workflowLoaded } from 'features/nodes/store/actions'; import {
import { zWorkflowV2 } from 'features/nodes/types/workflow'; workflowIDChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
import { WorkflowV2 } from 'features/nodes/types/workflow';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
useCreateWorkflowMutation, useCreateWorkflowMutation,
useUpdateWorkflowMutation, useUpdateWorkflowMutation,
} from 'services/api/endpoints/workflows'; } from 'services/api/endpoints/workflows';
import { O } from 'ts-toolbelt';
type UseSaveLibraryWorkflowReturn = { type UseSaveLibraryWorkflowReturn = {
saveWorkflow: () => Promise<void>; saveWorkflow: () => Promise<void>;
@ -18,6 +22,10 @@ type UseSaveLibraryWorkflowReturn = {
type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn; type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn;
const isWorkflowWithID = (
workflow: WorkflowV2
): workflow is O.Required<WorkflowV2, 'id'> => Boolean(workflow.id);
export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
@ -34,15 +42,13 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
isClosable: false, isClosable: false,
}); });
try { try {
if (workflow.id) { if (isWorkflowWithID(workflow)) {
const data = await updateWorkflow(workflow).unwrap(); await updateWorkflow(workflow).unwrap();
const updatedWorkflow = zWorkflowV2.parse(data.workflow);
dispatch(workflowLoaded(updatedWorkflow));
} else { } else {
const data = await createWorkflow(workflow).unwrap(); const data = await createWorkflow(workflow).unwrap();
const createdWorkflow = zWorkflowV2.parse(data.workflow); dispatch(workflowIDChanged(data.workflow.id));
dispatch(workflowLoaded(createdWorkflow));
} }
dispatch(workflowSaved());
toast.update(toastRef.current, { toast.update(toastRef.current, {
title: t('workflows.workflowSaved'), title: t('workflows.workflowSaved'),
status: 'success', status: 'success',

View File

@ -1,8 +1,11 @@
import { ToastId, useToast } from '@chakra-ui/react'; import { ToastId, useToast } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks'; import { useAppDispatch } from 'app/store/storeHooks';
import { useWorkflow } from 'features/nodes/hooks/useWorkflow'; import { useWorkflow } from 'features/nodes/hooks/useWorkflow';
import { workflowLoaded } from 'features/nodes/store/actions'; import {
import { zWorkflowV2 } from 'features/nodes/types/workflow'; workflowIDChanged,
workflowNameChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
import { useCallback, useRef } from 'react'; import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCreateWorkflowMutation } from 'services/api/endpoints/workflows'; import { useCreateWorkflowMutation } from 'services/api/endpoints/workflows';
@ -40,8 +43,9 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
workflow.id = undefined; workflow.id = undefined;
workflow.name = newName; workflow.name = newName;
const data = await createWorkflow(workflow).unwrap(); const data = await createWorkflow(workflow).unwrap();
const createdWorkflow = zWorkflowV2.parse(data.workflow); dispatch(workflowIDChanged(data.workflow.id));
dispatch(workflowLoaded(createdWorkflow)); dispatch(workflowNameChanged(data.workflow.name));
dispatch(workflowSaved());
onSuccess && onSuccess(); onSuccess && onSuccess();
toast.update(toastRef.current, { toast.update(toastRef.current, {
title: t('workflows.workflowSaved'), title: t('workflows.workflowSaved'),