feat: allow modify workflow metadata from list and editor

This commit is contained in:
linonetwo 2023-07-15 22:25:18 +08:00 committed by lin onetwo
parent fbae83117a
commit 5275be539f
10 changed files with 239 additions and 30 deletions

View file

@ -464,7 +464,18 @@
"AddTagsDescription": "in-wiki tags, press Enter to add more",
"DeleteWorkflow": "Delete Workflow",
"DeleteWorkflowDescription": "The workflow will be completely deleted from the Wiki workspace it belongs to, should it really be deleted?",
"OpenInWorkspaceTiddler": "Open {{title}} in {{workspace}}"
"OpenInWorkspaceTiddler": "Open {{title}} in {{workspace}}",
"RunWorkflow": "Run Workflow",
"BackToHome": "Back To List Page",
"ToggleReadonly": "Toggle On Readonly",
"ZoomToFit": "Zoom To Fit",
"ChangeWorkflowMetadata": "Change this Workflow's Metadata",
"ChangeSelectedItemInfo": "Change Selected Item's Info",
"ToggleOffReadonly": "Toggle Off Readonly",
"ChangeMetadata": "Change Metadata",
"ChangeWorkflowMetadataDescription": "Create this automated workflow and save it to the selected workspace wiki to backup.",
"ChangeWorkflowMetadataDoneMessage": "Change Done",
"ToggleOnReadonly": "Toggle On Readonly"
},
"Description": "Description",
"Tags": "Tags",

View file

@ -465,10 +465,20 @@
"AddNewWorkflow": "添加新的工作流",
"AddNewWorkflowDescription": "创建新的自动化工作流并保存到所选的工作区Wiki里备份。",
"AddNewWorkflowDoneMessage": "添加成功",
"ChangeWorkflowMetadataDoneMessage": "修改成功",
"DeleteWorkflow": "删除工作流",
"DeleteWorkflowDescription": "将从所属的Wiki工作区里彻底删除该工作流是否真的要删除",
"BelongsToWorkspace": "所属工作区",
"AddTagsDescription": "Wiki内的标签回车可以添加更多",
"OpenInWorkspaceTiddler": "在 {{workspace}} 中打开 {{title}}"
"OpenInWorkspaceTiddler": "在 {{workspace}} 中打开 {{title}}",
"RunWorkflow": "运行工作流",
"BackToHome": "返回列表页",
"ToggleOnReadonly": "打开只读状态",
"ToggleOffReadonly": "关闭只读状态",
"ZoomToFit": "缩放到合适大小",
"ChangeWorkflowMetadata": "修改本流程图的元信息",
"ChangeMetadata": "修改元信息",
"ChangeWorkflowMetadataDescription": "修改所选的自动化工作流并保存到所选的工作区Wiki里备份。",
"ChangeSelectedItemInfo": "修改选中的物体的信息"
}
}

View file

@ -5,13 +5,14 @@ import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import type { IFBPLibrary, INoFloUIComponent } from 'the-graph';
import { NoFloIcon } from './NoFloIcon';
import { searchBarWidth } from './styleConstant';
const SearchBarWrapper = styled.div`
position: absolute;
left: ${sidebarWidth}px;
top: 1em;
z-index: 2;
width: 300px;
width: ${searchBarWidth}px;
opacity: 0.3;
&:hover {

View file

@ -0,0 +1,124 @@
import { sidebarWidth } from '@/constants/style';
import EditIcon from '@mui/icons-material/Edit';
import HomeIcon from '@mui/icons-material/Home';
import InfoIcon from '@mui/icons-material/Info';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import VisibilityOnIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import ZoomOutMapIcon from '@mui/icons-material/ZoomOutMap';
import { IconButton, Toolbar, Tooltip } from '@mui/material';
import { PageType } from '@services/pages/interface';
import { WindowNames } from '@services/windows/WindowProperties';
import { useWorkspacesListObservable } from '@services/workspaces/hooks';
import React, { Dispatch, MutableRefObject, SetStateAction, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { ITheGraphEditor } from 'the-graph';
import { useLocation } from 'wouter';
import { IWorkflowContext } from '../../useContext';
import { AddItemDialog } from '../../WorkflowManage/AddItemDialog';
import { addWorkflowToWiki, useAvailableFilterTags } from '../../WorkflowManage/useWorkflowDataSource';
import { IWorkflowListItem } from '../../WorkflowManage/WorkflowList';
import { searchBarWidth } from './styleConstant';
const ToolbarContainer = styled(Toolbar)`
position: absolute;
top: 1em;
left: ${sidebarWidth + searchBarWidth}px;
min-height: unset;
/** same as the search bar */
height: 56px;
`;
interface IGraphTopToolbarProps {
editorReference: MutableRefObject<ITheGraphEditor | undefined>;
readonly: boolean;
setReadonly: Dispatch<SetStateAction<boolean>>;
workflowContext: IWorkflowContext;
}
export const GraphTopToolbar = (props: IGraphTopToolbarProps) => {
const { editorReference, readonly, setReadonly, workflowContext } = props;
const { t } = useTranslation();
const [, setLocation] = useLocation();
const runWorkflow = useCallback(() => {
console.log('Running workflow...');
}, []);
const backToHome = useCallback(() => {
// don't need to save here, because we already debouncedSave after all operations
setLocation(`/${WindowNames.main}/${PageType.workflow}/`);
}, [setLocation]);
const toggleReadonly = useCallback(() => {
setReadonly((readonly: boolean) => !readonly);
}, [setReadonly]);
const zoomToFit = useCallback(() => {
editorReference?.current?.triggerFit();
}, [editorReference]);
const [changeGraphInfoDialogOpen, setChangeGraphInfoDialogOpen] = useState(false);
const workspacesList = useWorkspacesListObservable();
const [availableFilterTags] = useAvailableFilterTags(workspacesList);
const changeGraphInfo = useCallback(() => {
setChangeGraphInfoDialogOpen(true);
}, []);
const closeChangeGraphInfoDialog = useCallback(() => {
setChangeGraphInfoDialogOpen(false);
}, []);
const handleDialogAddWorkflow = useCallback(async (newItem: IWorkflowListItem, oldItem?: IWorkflowListItem) => {
await addWorkflowToWiki(newItem, oldItem);
workflowContext.setOpenedWorkflowItem(newItem);
closeChangeGraphInfoDialog();
}, [closeChangeGraphInfoDialog, workflowContext]);
const changeSelectedItemInfo = useCallback(() => {
console.log('Changing selected item info...');
}, []);
return (
<>
<ToolbarContainer>
<Tooltip title={t('Workflow.RunWorkflow')}>
<IconButton onClick={runWorkflow}>
<PlayArrowIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('Workflow.BackToHome')}>
<IconButton onClick={backToHome}>
<HomeIcon />
</IconButton>
</Tooltip>
<Tooltip title={readonly ? t('Workflow.ToggleOnReadonly') : t('Workflow.ToggleOffReadonly')}>
<IconButton onClick={toggleReadonly}>
{readonly ? <VisibilityOnIcon /> : <VisibilityOffIcon />}
</IconButton>
</Tooltip>
<Tooltip title={t('Workflow.ZoomToFit')}>
<IconButton onClick={zoomToFit}>
<ZoomOutMapIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('Workflow.ChangeWorkflowMetadata')}>
<IconButton onClick={changeGraphInfo}>
<InfoIcon />
</IconButton>
</Tooltip>
<Tooltip title={t('Workflow.ChangeSelectedItemInfo')}>
<IconButton onClick={changeSelectedItemInfo}>
<EditIcon />
</IconButton>
</Tooltip>
</ToolbarContainer>
<AddItemDialog
open={changeGraphInfoDialogOpen}
onClose={closeChangeGraphInfoDialog}
onAdd={handleDialogAddWorkflow}
availableFilterTags={availableFilterTags}
workspacesList={workspacesList}
item={workflowContext.openedWorkflowItem}
/>
</>
);
};

View file

@ -0,0 +1 @@
export const searchBarWidth = 300;

View file

@ -1,6 +1,6 @@
import { sidebarWidth } from '@/constants/style';
import { useThemeObservable } from '@services/theme/hooks';
import { useRef } from 'react';
import { useContext, useRef, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
@ -13,7 +13,9 @@ import '@fortawesome/fontawesome-free/js/all.js';
import '@fortawesome/fontawesome-free/css/all.css';
import '@fortawesome/fontawesome-free/css/v4-font-face.css';
import { WorkflowContext } from '../useContext';
import { SearchComponents } from './components/SearchComponents';
import { GraphTopToolbar } from './components/Toolbar';
import { useLibrary } from './library';
import { useMenu } from './menu';
import { useMouseEvents } from './mouseEvents';
@ -52,7 +54,9 @@ export function GraphEditor() {
const theme = useThemeObservable();
const [graph, setGraph] = useSaveLoadGraph();
const workflowContext = useContext(WorkflowContext);
const library = useLibrary();
const [readonly, setReadonly] = useState(false);
// methods
// const { subscribeGraph, unsubscribeGraph } = useSubscribeGraph({ readonly });
@ -79,7 +83,6 @@ export function GraphEditor() {
<TheGraph.App
graph={graph}
library={library}
readonly={false}
height={window.innerHeight}
width={window.innerWidth}
offsetX={sidebarWidth}
@ -88,6 +91,7 @@ export function GraphEditor() {
onNodeSelection={onNodeSelection}
onEdgeSelection={onEdgeSelection}
getEditorRef={editorReference}
readonly={readonly}
/>
</TheGraphContainer>
<ThumbnailContainer>
@ -106,6 +110,7 @@ export function GraphEditor() {
/>
</ThumbnailContainer>
<SearchComponents library={library} addNode={addNode} />
<GraphTopToolbar editorReference={editorReference} readonly={readonly} setReadonly={setReadonly} workflowContext={workflowContext} />
</ErrorBoundary>
);
}

View file

@ -8,7 +8,8 @@ import type { IWorkflowListItem } from './WorkflowList';
interface AddItemDialogProps {
availableFilterTags: string[];
onAdd: (newItem: IWorkflowListItem) => Promise<void>;
item?: IWorkflowListItem;
onAdd: (newItem: IWorkflowListItem, oldItem?: IWorkflowListItem) => Promise<void>;
onClose: () => void;
open: boolean;
workspacesList: IWorkspaceWithMetadata[] | undefined;
@ -20,23 +21,37 @@ export const AddItemDialog: React.FC<AddItemDialogProps> = ({
onAdd,
availableFilterTags,
workspacesList,
item,
}) => {
const { t } = useTranslation();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const isModifyMode = !!item;
const [title, setTitle] = useState(item?.title ?? '');
const [description, setDescription] = useState(item?.description ?? '');
const [hasError, setHasError] = useState(false);
const [doneMessageSnackBarOpen, setDoneMessageSnackBarOpen] = useState(false);
const [workspaceToSaveTo, setWorkspaceToSaveTo] = useState<IWorkspaceWithMetadata | undefined | null>(workspacesList?.[0]);
const [workspaceToSaveTo, setWorkspaceToSaveTo] = useState<IWorkspaceWithMetadata | undefined | null>(
item?.workspaceID ? workspacesList?.find(workspace => workspace.id === item.workspaceID) : workspacesList?.[0],
);
useEffect(() => {
// when list was undefined and change to have value, auto set default value once.
if (workspaceToSaveTo === undefined && workspacesList?.[0]) {
setWorkspaceToSaveTo(workspacesList?.[0]);
}
}, [workspaceToSaveTo, workspacesList]);
const [tags, setTags] = useState<string[]>([]);
const [tags, setTags] = useState<string[]>(item?.tags ?? []);
useEffect(() => {
if (item) {
setTitle(item.title ?? '');
setDescription(item.description ?? '');
setTags(item.tags ?? []);
setWorkspaceToSaveTo(workspacesList?.find(workspace => workspace.id === item.workspaceID));
}
}, [item, workspacesList]);
const closeAndCleanup = useCallback(() => {
setTitle('');
setDescription('');
setTags([]);
// no need to reset workspace dropdown, because later workflow might save to same workspace
onClose();
}, [onClose]);
const workspaceIDs = useMemo(() => workspacesList?.map(workspace => workspace.id) ?? [], [workspacesList]);
@ -52,16 +67,18 @@ export const AddItemDialog: React.FC<AddItemDialogProps> = ({
return;
}
const newItem: IWorkflowListItem = {
...item,
id: `${workspaceID}:${title}`,
title,
tags,
workspaceID,
graphJSONString: '{}',
description,
};
await onAdd(newItem);
await onAdd(newItem, item);
setDoneMessageSnackBarOpen(true);
closeAndCleanup();
}, [workspaceToSaveTo?.id, workspacesList, workspaceIDs, title, tags, onAdd, closeAndCleanup]);
}, [workspaceToSaveTo?.id, workspacesList, workspaceIDs, title, tags, description, item, onAdd, closeAndCleanup]);
return (
<>
@ -71,13 +88,13 @@ export const AddItemDialog: React.FC<AddItemDialogProps> = ({
setDoneMessageSnackBarOpen(false);
}}
autoHideDuration={3000}
message={t('Workflow.AddNewWorkflowDoneMessage')}
message={isModifyMode ? t('Workflow.ChangeWorkflowMetadataDoneMessage') : t('Workflow.AddNewWorkflowDoneMessage')}
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
/>
<Dialog open={open} onClose={closeAndCleanup}>
<DialogTitle>{t('Workflow.AddNewWorkflow')}</DialogTitle>
<DialogTitle>{isModifyMode ? t('Workflow.ChangeWorkflowMetadata') : t('Workflow.AddNewWorkflow')}</DialogTitle>
<DialogContent>
<DialogContentText>{t('Workflow.AddNewWorkflowDescription')}</DialogContentText>
<DialogContentText>{isModifyMode ? t('Workflow.ChangeWorkflowMetadataDescription') : t('Workflow.AddNewWorkflowDescription')}</DialogContentText>
<FormControl fullWidth>
<TextField
required

View file

@ -38,12 +38,13 @@ const ItemMenuCardActions = styled(CardActions)`
`;
interface IWorkflowListItemProps {
handleOpenChangeMetadataDialog: (item: IWorkflowListItem) => void;
item: IWorkflowListItem;
onDeleteWorkflow: (item: IWorkflowListItem) => void;
}
export function WorkflowListItem(props: IWorkflowListItemProps) {
const { t } = useTranslation();
const { onDeleteWorkflow, item } = props;
const { onDeleteWorkflow, item, handleOpenChangeMetadataDialog: handleOpenChangeMetadataDialogRaw } = props;
const [anchorElement, setAnchorElement] = useState<null | HTMLElement>(null);
const handleOpenItemMenu = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
@ -57,6 +58,10 @@ export function WorkflowListItem(props: IWorkflowListItemProps) {
setAnchorElement(null);
onDeleteWorkflow(item);
}, [item, onDeleteWorkflow]);
const handleOpenChangeMetadataDialog = useCallback(() => {
setAnchorElement(null);
handleOpenChangeMetadataDialogRaw(item);
}, [item, handleOpenChangeMetadataDialogRaw]);
const [, setLocation] = useLocation();
const handleOpenInWiki = useCallback(async () => {
@ -110,6 +115,11 @@ export function WorkflowListItem(props: IWorkflowListItemProps) {
TransitionComponent={Fade}
>
<MenuItem onClick={handleDelete}>{t('Delete')}</MenuItem>
<MenuItem
onClick={handleOpenChangeMetadataDialog}
>
{t('Workflow.ChangeMetadata')}
</MenuItem>
<MenuItem onClick={handleOpenInWiki}>
{t('Workflow.OpenInWorkspaceTiddler', { title: item.title, workspace: item.metadata?.workspace?.name ?? t('AddWorkspace.MainWorkspace') })}
</MenuItem>
@ -136,11 +146,12 @@ export interface IWorkflowListItem {
}
interface IWorkflowListProps {
handleOpenChangeMetadataDialog: (item: IWorkflowListItem) => void;
onDeleteWorkflow: (item: IWorkflowListItem) => void;
workflows: IWorkflowListItem[];
}
export const WorkflowList: React.FC<IWorkflowListProps> = ({ workflows, onDeleteWorkflow }) => {
export const WorkflowList: React.FC<IWorkflowListProps> = ({ workflows, onDeleteWorkflow, handleOpenChangeMetadataDialog }) => {
const [itemToDelete, setDeleteItem] = useState<IWorkflowListItem | undefined>();
const handleDeleteConfirmed = useCallback(() => {
if (itemToDelete) {
@ -161,7 +172,7 @@ export const WorkflowList: React.FC<IWorkflowListProps> = ({ workflows, onDelete
<Grid container spacing={{ xs: 2, md: 3 }} columns={{ xs: 4, sm: 8, md: 12 }}>
{workflows.map((workflow) => (
<Grid item xs={2} sm={4} md={4} key={workflow.id}>
<WorkflowListItem item={workflow} onDeleteWorkflow={handleDeleteWithConfirmation} />
<WorkflowListItem item={workflow} onDeleteWorkflow={handleDeleteWithConfirmation} handleOpenChangeMetadataDialog={handleOpenChangeMetadataDialog} />
</Grid>
))}
</Grid>

View file

@ -22,6 +22,7 @@ const SearchRegionContainer = styled(Box)`
display: flex;
flex-direction: column;
margin-bottom: 1em;
padding-right: 1em;
width: 100%;
`;
const SearchBar = styled(TextField)`
@ -42,15 +43,18 @@ export const WorkflowManage: React.FC = () => {
const [availableFilterTags, setTagsByWorkspace] = useAvailableFilterTags(workspacesList);
const [workflows, onAddWorkflow, onDeleteWorkflow] = useWorkflows(workspacesList, setTagsByWorkspace);
const handleOpenDialog = useCallback(() => {
const [itemSelectedForDialog, setItemSelectedForDialog] = useState<IWorkflowListItem | undefined>();
const handleOpenDialog = useCallback((item?: IWorkflowListItem) => {
setItemSelectedForDialog(item);
setDialogOpen(true);
}, []);
const handleCloseDialog = useCallback(() => {
setDialogOpen(false);
// eslint-disable-next-line unicorn/no-useless-undefined
setItemSelectedForDialog(undefined);
}, []);
const handleDialogAddWorkflow = useCallback(async (newItem: IWorkflowListItem) => {
await onAddWorkflow(newItem);
const handleDialogAddWorkflow = useCallback(async (newItem: IWorkflowListItem, oldItem?: IWorkflowListItem) => {
await onAddWorkflow(newItem, oldItem);
handleCloseDialog();
}, [handleCloseDialog, onAddWorkflow]);
@ -87,9 +91,15 @@ export const WorkflowManage: React.FC = () => {
))}
</Stack>
</SearchRegionContainer>
<WorkflowList workflows={filteredWorkflows} onDeleteWorkflow={onDeleteWorkflow} />
<WorkflowList workflows={filteredWorkflows} onDeleteWorkflow={onDeleteWorkflow} handleOpenChangeMetadataDialog={handleOpenDialog} />
</SimpleBar>
<AddNewItemFloatingButton color='primary' aria-label='add' onClick={handleOpenDialog}>
<AddNewItemFloatingButton
color='primary'
aria-label='add'
onClick={() => {
handleOpenDialog();
}}
>
<AddIcon />
</AddNewItemFloatingButton>
<AddItemDialog
@ -98,6 +108,7 @@ export const WorkflowManage: React.FC = () => {
onAdd={handleDialogAddWorkflow}
availableFilterTags={availableFilterTags}
workspacesList={workspacesList}
item={itemSelectedForDialog}
/>
</WorkflowManageContainer>
);

View file

@ -112,12 +112,12 @@ export function useWorkflows(workspacesList: IWorkspaceWithMetadata[] | undefine
const initialWorkflows = useWorkflowFromWiki(workspacesList);
// loading workflows using filter expression is expensive, so we only do this on initial load. Later just update&use local state value
useEffect(() => {
setWorkflows(initialWorkflows);
setWorkflows(initialWorkflows.sort(sortWorkflow));
}, [initialWorkflows]);
const onAddWorkflow = useCallback(async (newItem: IWorkflowListItem) => {
await addWorkflowToWiki(newItem);
const onAddWorkflow = useCallback(async (newItem: IWorkflowListItem, oldItem?: IWorkflowListItem) => {
await addWorkflowToWiki(newItem, oldItem);
// can overwrite a old workflow with same title
setWorkflows((workflows) => [...workflows.filter(item => item.title !== newItem.title), newItem]);
setWorkflows((workflows) => [...workflows.filter(item => item.title !== newItem.title), newItem].sort(sortWorkflow));
// update tag list in the search region tags filter
setTagsByWorkspace((previousTagsByWorkspace) => {
// add newly appeared tags to local state
@ -138,13 +138,14 @@ export function useWorkflows(workspacesList: IWorkspaceWithMetadata[] | undefine
item.title,
);
// delete workflow from local state
setWorkflows((workflows) => workflows.filter(workflow => workflow.id !== item.id));
setWorkflows((workflows) => workflows.filter(workflow => workflow.id !== item.id).sort(sortWorkflow));
}, [setWorkflows]);
return [workflows, onAddWorkflow, onDeleteWorkflow] as const;
}
export async function addWorkflowToWiki(newItem: IWorkflowListItem) {
export async function addWorkflowToWiki(newItem: IWorkflowListItem, oldItem?: IWorkflowListItem) {
// FIXME: this won't resolve if user haven't click on wiki once, the browser view might not initialized
await window.service.wiki.wikiOperation(
WikiChannel.addTiddler,
newItem.workspaceID,
@ -159,4 +160,21 @@ export async function addWorkflowToWiki(newItem: IWorkflowListItem) {
} satisfies Omit<IWorkflowTiddler, 'text' | 'title'>,
{ withDate: true },
);
// we sort workflows using workflow.metadata.tiddler.modified, so we need to update it (side effect)
if (newItem.metadata?.tiddler) {
// @ts-expect-error Cannot assign to 'modified' because it is a read-only property.ts(2540)
newItem.metadata.tiddler.modified = new Date();
}
// when change title, wiki requires delete old tiddler manually
if (oldItem !== undefined && oldItem.title !== newItem.title) {
window.service.wiki.wikiOperation(
WikiChannel.deleteTiddler,
oldItem.workspaceID,
oldItem.title,
);
}
}
export function sortWorkflow(workflow1: IWorkflowListItem, workflow2: IWorkflowListItem) {
return (workflow2.metadata?.tiddler?.modified ?? new Date()).getTime() - (workflow1.metadata?.tiddler?.modified ?? new Date()).getTime();
}