Merge pull request #456 from automatisch/issue-455

feat: make flow editor read only when published
This commit is contained in:
Ömer Faruk Aydın
2022-08-24 22:24:44 +03:00
committed by GitHub
13 changed files with 87 additions and 25 deletions

View File

@@ -9,7 +9,7 @@ class Flow extends Base {
id!: string; id!: string;
name!: string; name!: string;
userId!: string; userId!: string;
active = false; active: boolean;
steps?: [Step]; steps?: [Step];
published_at: string; published_at: string;

View File

@@ -8,6 +8,7 @@ import ListItem from '@mui/material/ListItem';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import Autocomplete from '@mui/material/Autocomplete'; import Autocomplete from '@mui/material/Autocomplete';
import { EditorContext } from 'contexts/Editor';
import { GET_APPS } from 'graphql/queries/get-apps'; import { GET_APPS } from 'graphql/queries/get-apps';
import FlowSubstepTitle from 'components/FlowSubstepTitle'; import FlowSubstepTitle from 'components/FlowSubstepTitle';
import type { IApp, IStep, ISubstep } from '@automatisch/types'; import type { IApp, IStep, ISubstep } from '@automatisch/types';
@@ -40,6 +41,8 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
onChange, onChange,
} = props; } = props;
const editorContext = React.useContext(EditorContext);
const isTrigger = step.type === 'trigger'; const isTrigger = step.type === 'trigger';
const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }}); const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }});
@@ -111,6 +114,7 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
fullWidth fullWidth
disablePortal disablePortal
disableClearable disableClearable
disabled={editorContext.readOnly}
options={appOptions} options={appOptions}
renderInput={(params) => <TextField {...params} label="Choose an app" />} renderInput={(params) => <TextField {...params} label="Choose an app" />}
value={getOption(appOptions, step.appKey)} value={getOption(appOptions, step.appKey)}
@@ -127,6 +131,7 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
fullWidth fullWidth
disablePortal disablePortal
disableClearable disableClearable
disabled={editorContext.readOnly}
options={actionOptions} options={actionOptions}
renderInput={(params) => <TextField {...params} label="Choose an event" />} renderInput={(params) => <TextField {...params} label="Choose an event" />}
value={getOption(actionOptions, step.key)} value={getOption(actionOptions, step.key)}
@@ -140,7 +145,7 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
variant="contained" variant="contained"
onClick={onSubmit} onClick={onSubmit}
sx={{ mt: 2 }} sx={{ mt: 2 }}
disabled={!valid} disabled={!valid || editorContext.readOnly}
> >
Continue Continue
</Button> </Button>

View File

@@ -6,6 +6,7 @@ import Collapse from '@mui/material/Collapse';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import Autocomplete from '@mui/material/Autocomplete'; import Autocomplete from '@mui/material/Autocomplete';
import { EditorContext } from 'contexts/Editor';
import FlowSubstepTitle from 'components/FlowSubstepTitle'; import FlowSubstepTitle from 'components/FlowSubstepTitle';
import type { IApp, IConnection, IStep, ISubstep } from '@automatisch/types'; import type { IApp, IConnection, IStep, ISubstep } from '@automatisch/types';
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections'; import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
@@ -42,6 +43,7 @@ function ChooseConnectionSubstep(props: ChooseConnectionSubstepProps): React.Rea
connection, connection,
appKey, appKey,
} = step; } = step;
const editorContext = React.useContext(EditorContext);
const { data, loading } = useQuery(GET_APP_CONNECTIONS, { variables: { key: appKey }}); const { data, loading } = useQuery(GET_APP_CONNECTIONS, { variables: { key: appKey }});
// TODO: show detailed error when connection test/verification fails // TODO: show detailed error when connection test/verification fails
const [ const [
@@ -118,6 +120,7 @@ function ChooseConnectionSubstep(props: ChooseConnectionSubstepProps): React.Rea
fullWidth fullWidth
disablePortal disablePortal
disableClearable disableClearable
disabled={editorContext.readOnly}
options={connectionOptions} options={connectionOptions}
renderInput={(params) => <TextField {...params} label="Choose connection" />} renderInput={(params) => <TextField {...params} label="Choose connection" />}
value={getOption(connectionOptions, connection?.id)} value={getOption(connectionOptions, connection?.id)}
@@ -130,7 +133,7 @@ function ChooseConnectionSubstep(props: ChooseConnectionSubstepProps): React.Rea
variant="contained" variant="contained"
onClick={onSubmit} onClick={onSubmit}
sx={{ mt: 2 }} sx={{ mt: 2 }}
disabled={testResultLoading || !connection?.verified} disabled={testResultLoading || !connection?.verified || editorContext.readOnly}
> >
Continue Continue
</Button> </Button>

View File

@@ -3,12 +3,12 @@ import { useMutation } from '@apollo/client';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import type { IFlow } from '@automatisch/types';
import { GET_FLOW } from 'graphql/queries/get-flow'; import { GET_FLOW } from 'graphql/queries/get-flow';
import { CREATE_STEP } from 'graphql/mutations/create-step'; import { CREATE_STEP } from 'graphql/mutations/create-step';
import { UPDATE_STEP } from 'graphql/mutations/update-step'; import { UPDATE_STEP } from 'graphql/mutations/update-step';
import FlowStep from 'components/FlowStep'; import FlowStep from 'components/FlowStep';
import type { IFlow } from '@automatisch/types';
type EditorProps = { type EditorProps = {
flow: IFlow; flow: IFlow;

View File

@@ -3,12 +3,13 @@ import { Link, useParams } from 'react-router-dom';
import { useMutation, useQuery } from '@apollo/client'; import { useMutation, useQuery } from '@apollo/client';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel'; import Button from '@mui/material/Button';
import Switch from '@mui/material/Switch';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import Snackbar from '@mui/material/Snackbar';
import { EditorProvider } from 'contexts/Editor';
import EditableTypography from 'components/EditableTypography'; import EditableTypography from 'components/EditableTypography';
import Container from 'components/Container'; import Container from 'components/Container';
import Editor from 'components/Editor'; import Editor from 'components/Editor';
@@ -45,9 +46,7 @@ export default function EditorLayout(): React.ReactElement {
}); });
}, [flow?.id]); }, [flow?.id]);
const onFlowStatusUpdate = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => { const onFlowStatusUpdate = React.useCallback(async (active: boolean) => {
const active = event.target.checked;
await updateFlowStatus({ await updateFlowStatus({
variables: { variables: {
input: { input: {
@@ -93,22 +92,32 @@ export default function EditorLayout(): React.ReactElement {
</Box> </Box>
<Box pr={1}> <Box pr={1}>
<FormControlLabel <Button variant="contained" size="small" onClick={() => onFlowStatusUpdate(!flow.active)}>
control={ {flow?.active ? formatMessage('flowEditor.unpublish') : formatMessage('flowEditor.publish')}
<Switch checked={flow?.active ?? false} onChange={onFlowStatusUpdate} /> </Button>
}
label={flow?.active ? formatMessage('flow.active') : formatMessage('flow.inactive')}
labelPlacement="start"
/>
</Box> </Box>
</Stack> </Stack>
<Container maxWidth="md"> <Container maxWidth="md">
{!flow && !loading && 'not found'} <EditorProvider value={{ readOnly: !!flow?.active }}>
{!flow && !loading && 'not found'}
{flow && <Editor flow={flow} />} {flow && <Editor flow={flow} />}
</EditorProvider>
</Container> </Container>
</Stack> </Stack>
<Snackbar
open={!!flow?.active}
message={formatMessage('flowEditor.publishFlowCannotBeUpdated')}
anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
ContentProps={{ sx: { fontWeight: 300 }}}
action={(
<Button variant="contained" size="small" onClick={() => onFlowStatusUpdate(!flow.active)}>
{flow?.active ? formatMessage('flowEditor.unpublish') : formatMessage('flowEditor.publish')}
</Button>
)}
/>
</> </>
) )
} }

View File

@@ -15,6 +15,7 @@ import * as yup from 'yup';
import type { BaseSchema } from 'yup'; import type { BaseSchema } from 'yup';
import type { IApp, IField, IStep, ISubstep } from '@automatisch/types'; import type { IApp, IField, IStep, ISubstep } from '@automatisch/types';
import { EditorContext } from 'contexts/Editor';
import { StepExecutionsProvider } from 'contexts/StepExecutions'; import { StepExecutionsProvider } from 'contexts/StepExecutions';
import TestSubstep from 'components/TestSubstep'; import TestSubstep from 'components/TestSubstep';
import FlowSubstep from 'components/FlowSubstep'; import FlowSubstep from 'components/FlowSubstep';
@@ -99,6 +100,7 @@ export default function FlowStep(
props: FlowStepProps props: FlowStepProps
): React.ReactElement | null { ): React.ReactElement | null {
const { collapsed, onChange } = props; const { collapsed, onChange } = props;
const editorContext = React.useContext(EditorContext);
const contextButtonRef = React.useRef<HTMLButtonElement | null>(null); const contextButtonRef = React.useRef<HTMLButtonElement | null>(null);
const step: IStep = props.step; const step: IStep = props.step;
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>( const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
@@ -204,7 +206,7 @@ export default function FlowStep(
<Box display="flex" flex={1} justifyContent="end"> <Box display="flex" flex={1} justifyContent="end">
{/* as there are no other actions besides "delete step", we hide the context menu. */} {/* as there are no other actions besides "delete step", we hide the context menu. */}
{!isTrigger && ( {!isTrigger && !editorContext.readOnly && (
<IconButton <IconButton
color="primary" color="primary"
onClick={onContextMenuClick} onClick={onContextMenuClick}

View File

@@ -5,6 +5,7 @@ import ListItem from '@mui/material/ListItem';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import { EditorContext } from 'contexts/Editor';
import FlowSubstepTitle from 'components/FlowSubstepTitle'; import FlowSubstepTitle from 'components/FlowSubstepTitle';
import InputCreator from 'components/InputCreator'; import InputCreator from 'components/InputCreator';
import type { IField, IStep, ISubstep } from '@automatisch/types'; import type { IField, IStep, ISubstep } from '@automatisch/types';
@@ -52,6 +53,7 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
arguments: args, arguments: args,
} = substep; } = substep;
const editorContext = React.useContext(EditorContext);
const formContext = useFormContext(); const formContext = useFormContext();
const [validationStatus, setValidationStatus] = React.useState<boolean | null>(validateSubstep(substep, formContext.getValues() as IStep)); const [validationStatus, setValidationStatus] = React.useState<boolean | null>(validateSubstep(substep, formContext.getValues() as IStep));
@@ -105,6 +107,7 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
schema={argument} schema={argument}
namePrefix="parameters" namePrefix="parameters"
stepId={step.id} stepId={step.id}
disabled={editorContext.readOnly}
/> />
))} ))}
</Stack> </Stack>
@@ -114,7 +117,7 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
variant="contained" variant="contained"
onClick={onSubmit} onClick={onSubmit}
sx={{ mt: 2 }} sx={{ mt: 2 }}
disabled={!validationStatus} disabled={!validationStatus || editorContext.readOnly}
type="submit" type="submit"
> >
Continue Continue

View File

@@ -15,6 +15,7 @@ type InputCreatorProps = {
schema: IField; schema: IField;
namePrefix?: string; namePrefix?: string;
stepId?: string; stepId?: string;
disabled?: boolean;
}; };
type RawOption = { type RawOption = {
@@ -33,6 +34,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
schema, schema,
namePrefix, namePrefix,
stepId, stepId,
disabled,
} = props; } = props;
const { const {
@@ -65,6 +67,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
onChange={console.log} onChange={console.log}
description={description} description={description}
loading={loading} loading={loading}
disabled={disabled}
/> />
); );
} }
@@ -77,6 +80,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
description={description} description={description}
name={computedName} name={computedName}
required={required} required={required}
disabled={disabled}
/> />
); );
} }
@@ -86,8 +90,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
defaultValue={value} defaultValue={value}
required={required} required={required}
placeholder="" placeholder=""
disabled={readOnly} readOnly={readOnly || disabled}
readOnly={readOnly}
onChange={onChange} onChange={onChange}
onBlur={onBlur} onBlur={onBlur}
name={computedName} name={computedName}

View File

@@ -38,6 +38,7 @@ type PowerInputProps = {
description?: string; description?: string;
docUrl?: string; docUrl?: string;
clickToCopy?: boolean; clickToCopy?: boolean;
disabled?: boolean;
} }
const PowerInput = (props: PowerInputProps) => { const PowerInput = (props: PowerInputProps) => {
@@ -49,6 +50,7 @@ const PowerInput = (props: PowerInputProps) => {
label, label,
required, required,
description, description,
disabled,
} = props; } = props;
const priorStepsWithExecutions = React.useContext(StepExecutionsContext); const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
const editorRef = React.useRef<HTMLDivElement | null>(null); const editorRef = React.useRef<HTMLDivElement | null>(null);
@@ -161,11 +163,11 @@ const PowerInput = (props: PowerInputProps) => {
<ClickAwayListener onClickAway={() => setSearch(null)}> <ClickAwayListener onClickAway={() => setSearch(null)}>
{/* ref-able single child for ClickAwayListener */} {/* ref-able single child for ClickAwayListener */}
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
<FakeInput> <FakeInput disabled={disabled}>
<InputLabelWrapper> <InputLabelWrapper>
<InputLabel <InputLabel
shrink={true} shrink={true}
// focused disabled={disabled}
variant="outlined" variant="outlined"
sx={{ bgcolor: 'white', display: 'inline-block', px: .75 }} sx={{ bgcolor: 'white', display: 'inline-block', px: .75 }}
> >
@@ -174,6 +176,7 @@ const PowerInput = (props: PowerInputProps) => {
</InputLabelWrapper> </InputLabelWrapper>
<Editable <Editable
readOnly={disabled}
style={{ width: '100%' }} style={{ width: '100%' }}
renderElement={renderElement} renderElement={renderElement}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}

View File

@@ -7,7 +7,7 @@ export const InputLabelWrapper = styled('div')`
left: -6px; left: -6px;
`; `;
export const FakeInput = styled('div')` export const FakeInput = styled('div', { shouldForwardProp: prop => prop !== 'disabled'})<{ disabled?: boolean }>`
border: 1px solid #eee; border: 1px solid #eee;
min-height: 52px; min-height: 52px;
width: 100%; width: 100%;
@@ -17,6 +17,11 @@ export const FakeInput = styled('div')`
border-color: rgba(0, 0, 0, 0.23); border-color: rgba(0, 0, 0, 0.23);
position: relative; position: relative;
${({ disabled, theme }) => !!disabled && `
color: ${theme.palette.action.disabled},
border-color: ${theme.palette.action.disabled},
`}
&:hover { &:hover {
border-color: ${({ theme }) => theme.palette.text.primary}; border-color: ${({ theme }) => theme.palette.text.primary};
} }

View File

@@ -6,6 +6,7 @@ import ListItem from '@mui/material/ListItem';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import { EditorContext } from 'contexts/Editor';
import JSONViewer from 'components/JSONViewer'; import JSONViewer from 'components/JSONViewer';
import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow'; import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow';
import FlowSubstepTitle from 'components/FlowSubstepTitle'; import FlowSubstepTitle from 'components/FlowSubstepTitle';
@@ -31,6 +32,7 @@ function TestSubstep(props: TestSubstepProps): React.ReactElement {
step, step,
} = props; } = props;
const editorContext = React.useContext(EditorContext);
const [executeFlow, { data, error, loading }] = useMutation(EXECUTE_FLOW, { context: { autoSnackbar: false }}); const [executeFlow, { data, error, loading }] = useMutation(EXECUTE_FLOW, { context: { autoSnackbar: false }});
const response = data?.executeFlow?.data; const response = data?.executeFlow?.data;
@@ -74,6 +76,7 @@ function TestSubstep(props: TestSubstepProps): React.ReactElement {
onClick={handleSubmit} onClick={handleSubmit}
sx={{ mt: 2 }} sx={{ mt: 2 }}
loading={loading} loading={loading}
disabled={editorContext.readOnly}
color="primary" color="primary"
> >
Test & Continue Test & Continue

View File

@@ -0,0 +1,23 @@
import * as React from 'react';
interface IEditorContext {
readOnly: boolean;
}
export const EditorContext = React.createContext<IEditorContext>({ readOnly: false });
type EditorProviderProps = {
children: React.ReactNode;
value: IEditorContext;
}
export const EditorProvider = (props: EditorProviderProps): React.ReactElement => {
const { children, value } = props;
return (
<EditorContext.Provider
value={value}
>
{children}
</EditorContext.Provider>
);
};

View File

@@ -40,6 +40,9 @@
"createFlow.creating": "Creating a flow...", "createFlow.creating": "Creating a flow...",
"flow.active": "ON", "flow.active": "ON",
"flow.inactive": "OFF", "flow.inactive": "OFF",
"flowEditor.publish": "PUBLISH",
"flowEditor.unpublish": "UNPUBLISH",
"flowEditor.publishFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.",
"flow.createdAt": "created {datetime}", "flow.createdAt": "created {datetime}",
"flow.updatedAt": "updated {datetime}", "flow.updatedAt": "updated {datetime}",
"flow.view": "View", "flow.view": "View",