Merge pull request #456 from automatisch/issue-455
feat: make flow editor read only when published
This commit is contained in:
@@ -9,7 +9,7 @@ class Flow extends Base {
|
||||
id!: string;
|
||||
name!: string;
|
||||
userId!: string;
|
||||
active = false;
|
||||
active: boolean;
|
||||
steps?: [Step];
|
||||
published_at: string;
|
||||
|
||||
|
@@ -8,6 +8,7 @@ import ListItem from '@mui/material/ListItem';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import { GET_APPS } from 'graphql/queries/get-apps';
|
||||
import FlowSubstepTitle from 'components/FlowSubstepTitle';
|
||||
import type { IApp, IStep, ISubstep } from '@automatisch/types';
|
||||
@@ -40,6 +41,8 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
|
||||
onChange,
|
||||
} = props;
|
||||
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
|
||||
const isTrigger = step.type === 'trigger';
|
||||
|
||||
const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }});
|
||||
@@ -111,6 +114,7 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable
|
||||
disabled={editorContext.readOnly}
|
||||
options={appOptions}
|
||||
renderInput={(params) => <TextField {...params} label="Choose an app" />}
|
||||
value={getOption(appOptions, step.appKey)}
|
||||
@@ -127,6 +131,7 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable
|
||||
disabled={editorContext.readOnly}
|
||||
options={actionOptions}
|
||||
renderInput={(params) => <TextField {...params} label="Choose an event" />}
|
||||
value={getOption(actionOptions, step.key)}
|
||||
@@ -140,7 +145,7 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
sx={{ mt: 2 }}
|
||||
disabled={!valid}
|
||||
disabled={!valid || editorContext.readOnly}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
|
@@ -6,6 +6,7 @@ import Collapse from '@mui/material/Collapse';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import FlowSubstepTitle from 'components/FlowSubstepTitle';
|
||||
import type { IApp, IConnection, IStep, ISubstep } from '@automatisch/types';
|
||||
import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections';
|
||||
@@ -42,6 +43,7 @@ function ChooseConnectionSubstep(props: ChooseConnectionSubstepProps): React.Rea
|
||||
connection,
|
||||
appKey,
|
||||
} = step;
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const { data, loading } = useQuery(GET_APP_CONNECTIONS, { variables: { key: appKey }});
|
||||
// TODO: show detailed error when connection test/verification fails
|
||||
const [
|
||||
@@ -118,6 +120,7 @@ function ChooseConnectionSubstep(props: ChooseConnectionSubstepProps): React.Rea
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable
|
||||
disabled={editorContext.readOnly}
|
||||
options={connectionOptions}
|
||||
renderInput={(params) => <TextField {...params} label="Choose connection" />}
|
||||
value={getOption(connectionOptions, connection?.id)}
|
||||
@@ -130,7 +133,7 @@ function ChooseConnectionSubstep(props: ChooseConnectionSubstepProps): React.Rea
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
sx={{ mt: 2 }}
|
||||
disabled={testResultLoading || !connection?.verified}
|
||||
disabled={testResultLoading || !connection?.verified || editorContext.readOnly}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
|
@@ -3,12 +3,12 @@ import { useMutation } from '@apollo/client';
|
||||
import Box from '@mui/material/Box';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import type { IFlow } from '@automatisch/types';
|
||||
|
||||
import { GET_FLOW } from 'graphql/queries/get-flow';
|
||||
import { CREATE_STEP } from 'graphql/mutations/create-step';
|
||||
import { UPDATE_STEP } from 'graphql/mutations/update-step';
|
||||
import FlowStep from 'components/FlowStep';
|
||||
import type { IFlow } from '@automatisch/types';
|
||||
|
||||
type EditorProps = {
|
||||
flow: IFlow;
|
||||
|
@@ -3,12 +3,13 @@ import { Link, useParams } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@apollo/client';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Box from '@mui/material/Box';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
|
||||
import { EditorProvider } from 'contexts/Editor';
|
||||
import EditableTypography from 'components/EditableTypography';
|
||||
import Container from 'components/Container';
|
||||
import Editor from 'components/Editor';
|
||||
@@ -45,9 +46,7 @@ export default function EditorLayout(): React.ReactElement {
|
||||
});
|
||||
}, [flow?.id]);
|
||||
|
||||
const onFlowStatusUpdate = React.useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const active = event.target.checked;
|
||||
|
||||
const onFlowStatusUpdate = React.useCallback(async (active: boolean) => {
|
||||
await updateFlowStatus({
|
||||
variables: {
|
||||
input: {
|
||||
@@ -93,22 +92,32 @@ export default function EditorLayout(): React.ReactElement {
|
||||
</Box>
|
||||
|
||||
<Box pr={1}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={flow?.active ?? false} onChange={onFlowStatusUpdate} />
|
||||
}
|
||||
label={flow?.active ? formatMessage('flow.active') : formatMessage('flow.inactive')}
|
||||
labelPlacement="start"
|
||||
/>
|
||||
<Button variant="contained" size="small" onClick={() => onFlowStatusUpdate(!flow.active)}>
|
||||
{flow?.active ? formatMessage('flowEditor.unpublish') : formatMessage('flowEditor.publish')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@@ -15,6 +15,7 @@ import * as yup from 'yup';
|
||||
import type { BaseSchema } from 'yup';
|
||||
import type { IApp, IField, IStep, ISubstep } from '@automatisch/types';
|
||||
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import { StepExecutionsProvider } from 'contexts/StepExecutions';
|
||||
import TestSubstep from 'components/TestSubstep';
|
||||
import FlowSubstep from 'components/FlowSubstep';
|
||||
@@ -99,6 +100,7 @@ export default function FlowStep(
|
||||
props: FlowStepProps
|
||||
): React.ReactElement | null {
|
||||
const { collapsed, onChange } = props;
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const contextButtonRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const step: IStep = props.step;
|
||||
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(
|
||||
@@ -204,7 +206,7 @@ export default function FlowStep(
|
||||
|
||||
<Box display="flex" flex={1} justifyContent="end">
|
||||
{/* as there are no other actions besides "delete step", we hide the context menu. */}
|
||||
{!isTrigger && (
|
||||
{!isTrigger && !editorContext.readOnly && (
|
||||
<IconButton
|
||||
color="primary"
|
||||
onClick={onContextMenuClick}
|
||||
|
@@ -5,6 +5,7 @@ import ListItem from '@mui/material/ListItem';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import FlowSubstepTitle from 'components/FlowSubstepTitle';
|
||||
import InputCreator from 'components/InputCreator';
|
||||
import type { IField, IStep, ISubstep } from '@automatisch/types';
|
||||
@@ -52,6 +53,7 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
|
||||
arguments: args,
|
||||
} = substep;
|
||||
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const formContext = useFormContext();
|
||||
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}
|
||||
namePrefix="parameters"
|
||||
stepId={step.id}
|
||||
disabled={editorContext.readOnly}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -114,7 +117,7 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement {
|
||||
variant="contained"
|
||||
onClick={onSubmit}
|
||||
sx={{ mt: 2 }}
|
||||
disabled={!validationStatus}
|
||||
disabled={!validationStatus || editorContext.readOnly}
|
||||
type="submit"
|
||||
>
|
||||
Continue
|
||||
|
@@ -15,6 +15,7 @@ type InputCreatorProps = {
|
||||
schema: IField;
|
||||
namePrefix?: string;
|
||||
stepId?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
type RawOption = {
|
||||
@@ -33,6 +34,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
||||
schema,
|
||||
namePrefix,
|
||||
stepId,
|
||||
disabled,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
@@ -65,6 +67,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
||||
onChange={console.log}
|
||||
description={description}
|
||||
loading={loading}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +80,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
||||
description={description}
|
||||
name={computedName}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -86,8 +90,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme
|
||||
defaultValue={value}
|
||||
required={required}
|
||||
placeholder=""
|
||||
disabled={readOnly}
|
||||
readOnly={readOnly}
|
||||
readOnly={readOnly || disabled}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
name={computedName}
|
||||
|
@@ -38,6 +38,7 @@ type PowerInputProps = {
|
||||
description?: string;
|
||||
docUrl?: string;
|
||||
clickToCopy?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const PowerInput = (props: PowerInputProps) => {
|
||||
@@ -49,6 +50,7 @@ const PowerInput = (props: PowerInputProps) => {
|
||||
label,
|
||||
required,
|
||||
description,
|
||||
disabled,
|
||||
} = props;
|
||||
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
|
||||
const editorRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -161,11 +163,11 @@ const PowerInput = (props: PowerInputProps) => {
|
||||
<ClickAwayListener onClickAway={() => setSearch(null)}>
|
||||
{/* ref-able single child for ClickAwayListener */}
|
||||
<div style={{ width: '100%' }}>
|
||||
<FakeInput>
|
||||
<FakeInput disabled={disabled}>
|
||||
<InputLabelWrapper>
|
||||
<InputLabel
|
||||
shrink={true}
|
||||
// focused
|
||||
disabled={disabled}
|
||||
variant="outlined"
|
||||
sx={{ bgcolor: 'white', display: 'inline-block', px: .75 }}
|
||||
>
|
||||
@@ -174,6 +176,7 @@ const PowerInput = (props: PowerInputProps) => {
|
||||
</InputLabelWrapper>
|
||||
|
||||
<Editable
|
||||
readOnly={disabled}
|
||||
style={{ width: '100%' }}
|
||||
renderElement={renderElement}
|
||||
onKeyDown={onKeyDown}
|
||||
|
@@ -7,7 +7,7 @@ export const InputLabelWrapper = styled('div')`
|
||||
left: -6px;
|
||||
`;
|
||||
|
||||
export const FakeInput = styled('div')`
|
||||
export const FakeInput = styled('div', { shouldForwardProp: prop => prop !== 'disabled'})<{ disabled?: boolean }>`
|
||||
border: 1px solid #eee;
|
||||
min-height: 52px;
|
||||
width: 100%;
|
||||
@@ -17,6 +17,11 @@ export const FakeInput = styled('div')`
|
||||
border-color: rgba(0, 0, 0, 0.23);
|
||||
position: relative;
|
||||
|
||||
${({ disabled, theme }) => !!disabled && `
|
||||
color: ${theme.palette.action.disabled},
|
||||
border-color: ${theme.palette.action.disabled},
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
border-color: ${({ theme }) => theme.palette.text.primary};
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ import ListItem from '@mui/material/ListItem';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
|
||||
import { EditorContext } from 'contexts/Editor';
|
||||
import JSONViewer from 'components/JSONViewer';
|
||||
import { EXECUTE_FLOW } from 'graphql/mutations/execute-flow';
|
||||
import FlowSubstepTitle from 'components/FlowSubstepTitle';
|
||||
@@ -31,6 +32,7 @@ function TestSubstep(props: TestSubstepProps): React.ReactElement {
|
||||
step,
|
||||
} = props;
|
||||
|
||||
const editorContext = React.useContext(EditorContext);
|
||||
const [executeFlow, { data, error, loading }] = useMutation(EXECUTE_FLOW, { context: { autoSnackbar: false }});
|
||||
const response = data?.executeFlow?.data;
|
||||
|
||||
@@ -74,6 +76,7 @@ function TestSubstep(props: TestSubstepProps): React.ReactElement {
|
||||
onClick={handleSubmit}
|
||||
sx={{ mt: 2 }}
|
||||
loading={loading}
|
||||
disabled={editorContext.readOnly}
|
||||
color="primary"
|
||||
>
|
||||
Test & Continue
|
||||
|
23
packages/web/src/contexts/Editor.tsx
Normal file
23
packages/web/src/contexts/Editor.tsx
Normal 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>
|
||||
);
|
||||
};
|
@@ -40,6 +40,9 @@
|
||||
"createFlow.creating": "Creating a flow...",
|
||||
"flow.active": "ON",
|
||||
"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.updatedAt": "updated {datetime}",
|
||||
"flow.view": "View",
|
||||
|
Reference in New Issue
Block a user