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",
"prevPage": "Previous Page",
"nextPage": "Next Page",
"unknownError": "Unknown Error"
"unknownError": "Unknown Error",
"unsaved": "Unsaved"
},
"controlnet": {
"controlAdapter_one": "Control Adapter",

View File

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

View File

@ -1,18 +1,19 @@
import { RootState } from 'app/store/store';
import { useAppSelector } from 'app/store/storeHooks';
import { buildWorkflow } from 'features/nodes/util/workflow/buildWorkflow';
import { omit } from 'lodash-es';
import { useMemo } from 'react';
import { useDebounce } from 'use-debounce';
export const useWorkflow = () => {
const nodes_ = useAppSelector((state: RootState) => state.nodes.nodes);
const edges_ = useAppSelector((state: RootState) => state.nodes.edges);
const workflow_ = useAppSelector((state: RootState) => state.workflow);
const nodes_ = useAppSelector((state) => state.nodes.nodes);
const edges_ = useAppSelector((state) => state.nodes.edges);
const workflow_ = useAppSelector((state) => state.workflow);
const [nodes] = useDebounce(nodes_, 300);
const [edges] = useDebounce(edges_, 300);
const [workflow] = useDebounce(workflow_, 300);
const builtWorkflow = useMemo(
() => buildWorkflow({ nodes, edges, workflow }),
() =>
buildWorkflow({ nodes, edges, workflow: omit(workflow, 'isTouched') }),
[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 { SHARED_NODE_PROPERTIES } from 'features/nodes/types/constants';
import {
@ -936,4 +936,42 @@ export const {
edgeAdded,
} = 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;

View File

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

View File

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

View File

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