feat: make step in editor configurable

This commit is contained in:
Ali BARIN
2022-01-13 21:25:18 +01:00
committed by Ömer Faruk Aydın
parent 4985fb422e
commit 95a63affe7
21 changed files with 1966 additions and 5016 deletions

View File

@@ -1,52 +1,54 @@
import { GraphQLString, GraphQLNonNull, GraphQLInt, GraphQLEnumType } from 'graphql';
import { GraphQLNonNull } from 'graphql';
import Step from '../../models/step';
import Flow from '../../models/flow';
import stepType from '../types/step';
import stepType, { stepInputType } from '../types/step';
import RequestWithCurrentUser from '../../types/express/request-with-current-user';
type Params = {
flowId: number,
key: string,
appKey: string,
type: string
connectionId: number
}
input: {
key: string;
appKey: string;
flow: {
id: number;
};
connection: {
id: number;
};
};
};
const createStepResolver = async (params: Params, req: RequestWithCurrentUser) => {
const flow = await Flow.query().findOne({
id: params.flowId,
user_id: req.currentUser.id
}).throwIfNotFound();
const createStepResolver = async (
params: Params,
req: RequestWithCurrentUser
) => {
const { input } = params;
const flow = await Flow.query()
.findOne({
id: input.flow.id,
user_id: req.currentUser.id,
})
.throwIfNotFound();
const step = await Step.query().insertAndFetch({
flowId: flow.id,
key: params.key,
appKey: params.appKey,
type: params.type,
connectionId: params.connectionId,
key: input.key,
appKey: input.appKey,
type: 'action',
connectionId: input.connection?.id,
position: 1,
});
return step;
}
};
const createStep = {
type: stepType,
args: {
flowId: { type: GraphQLNonNull(GraphQLInt) },
key: { type: GraphQLNonNull(GraphQLString) },
appKey: { type: GraphQLNonNull(GraphQLString) },
type: {
type: new GraphQLEnumType({
name: 'StepInputEnumType',
values: {
trigger: { value: 'trigger' },
action: { value: 'action' },
}
})
},
connectionId: { type: GraphQLNonNull(GraphQLInt) }
input: { type: new GraphQLNonNull(stepInputType) },
},
resolve: (_: any, params: Params, req: RequestWithCurrentUser) => createStepResolver(params, req)
resolve: (_: any, params: Params, req: RequestWithCurrentUser) =>
createStepResolver(params, req),
};
export default createStep;

View File

@@ -1,34 +1,41 @@
import { GraphQLInt, GraphQLString, GraphQLNonNull } from 'graphql';
import Flow from '../../models/flow';
import Step from '../../models/step';
import stepType from '../types/step';
import stepType, { stepInputType } from '../types/step';
import availableAppsEnumType from '../types/available-apps-enum-type';
import RequestWithCurrentUser from '../../types/express/request-with-current-user';
type Params = {
id: number,
flowId: number,
key: string,
appKey: string,
connectionId: number
input: {
id: number,
key: string,
appKey: string,
flow: {
id: number,
},
connection: {
id: number
},
}
}
const updateStepResolver = async (params: Params, req: RequestWithCurrentUser) => {
const { input } = params;
const flow = await Flow.query().findOne({
user_id: req.currentUser.id,
id: params.flowId
id: input.flow.id
}).throwIfNotFound();
let step = await Step.query().findOne({
flow_id: flow.id,
id: params.id
id: input.id
}).throwIfNotFound();
step = await step.$query().patchAndFetch({
...step,
key: params.key,
appKey: params.appKey,
connectionId: params.connectionId
})
step = await Step.query().patchAndFetchById(input.id, {
key: input.key,
appKey: input.appKey,
connectionId: input.connection.id,
});
return step;
}
@@ -36,10 +43,7 @@ const updateStepResolver = async (params: Params, req: RequestWithCurrentUser) =
const updateStep = {
type: stepType,
args: {
id: { type: GraphQLNonNull(GraphQLInt) },
flowId: { type: GraphQLNonNull(GraphQLInt) },
key: { type: GraphQLString },
appKey: { type: availableAppsEnumType },
input: { type: new GraphQLNonNull(stepInputType) }
},
resolve: (_: any, params: Params, req: RequestWithCurrentUser) => updateStepResolver(params, req)
};

View File

@@ -3,6 +3,7 @@ import {
GraphQLString,
GraphQLEnumType,
GraphQLInt,
GraphQLInputObjectType,
} from 'graphql';
import ConnectionType from './connection';
@@ -10,6 +11,7 @@ const stepType = new GraphQLObjectType({
name: 'Step',
fields: {
id: { type: GraphQLInt },
previousStepId: { type: GraphQLInt },
key: { type: GraphQLString },
appKey: { type: GraphQLString },
type: {
@@ -26,4 +28,30 @@ const stepType = new GraphQLObjectType({
},
});
export const stepInputType = new GraphQLInputObjectType({
name: 'StepInput',
fields: {
id: { type: GraphQLInt },
previousStepId: { type: GraphQLInt },
key: { type: GraphQLString },
appKey: { type: GraphQLString },
connection: {
type: new GraphQLInputObjectType({
name: 'StepConnectionInput',
fields: {
id: { type: GraphQLInt },
}
})
},
flow: {
type: new GraphQLInputObjectType({
name: 'StepFlowInput',
fields: {
id: { type: GraphQLInt },
}
})
}
}
})
export default stepType;

View File

@@ -26,7 +26,7 @@ class Step extends Base {
properties: {
id: { type: 'integer' },
flowId: { type: 'integer' },
key: { type: 'string', minLength: 1, maxLength: 255 },
key: { type: ['string', null] },
appKey: { type: 'string', minLength: 1, maxLength: 255 },
type: { type: 'string', enum: ['action', 'trigger'] },
connectionId: { type: 'integer' },

View File

@@ -28,7 +28,7 @@
"react-hook-form": "^7.17.2",
"react-intl": "^5.20.12",
"react-router-dom": "^6.0.2",
"react-scripts": "4.0.3",
"react-scripts": "5.0.0",
"typescript": "^4.1.2",
"web-vitals": "^1.0.1"
},

View File

@@ -3,8 +3,8 @@ import Avatar from '@mui/material/Avatar';
import type { AvatarProps } from '@mui/material/Avatar';
type AppIconProps = {
name: string;
url: string;
name?: string;
url?: string;
color?: string;
};

View File

@@ -1,17 +1,85 @@
import * as React from 'react';
import { useMutation } from '@apollo/client';
import { ApolloCache, FetchResult } from '@apollo/client';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
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 { Flow } from 'types/flow';
import type { Step } from 'types/step';
type EditorProps = {
flow: Flow;
}
function updateHandlerFactory(flowId: number, previousStepId: number) {
return function createStepUpdateHandler(
cache: any,
mutationResult: any,
) {
const { data } = mutationResult;
const { createStep: createdStep } = data;
const { getFlow: flow } = cache.readQuery({
query: GET_FLOW,
variables: { id: flowId },
});
const steps = flow.steps.reduce((steps: any[], currentStep: any) => {
if (currentStep.id === previousStepId) {
return [...steps, currentStep, createdStep];
}
return [...steps, currentStep];
}, []);
cache.writeQuery({
query: GET_FLOW,
variables: { id: flowId },
data: { getFlow: { ...flow, steps } },
});
}
}
export default function Editor(props: EditorProps): React.ReactElement {
const [currentStep, setCurrentStep] = React.useState<number | null>(null);
const [updateStep] = useMutation(UPDATE_STEP);
const [createStep] = useMutation(CREATE_STEP);
const [currentStep, setCurrentStep] = React.useState<number | null>(0);
const { flow } = props;
const onStepChange = React.useCallback((step: any) => {
const mutationInput = {
id: step.id,
key: step.key,
appKey: step.appKey,
previousStepId: step.previousStepId,
connection: {
id: step.connection?.id
},
flow: {
id: flow.id,
},
};
updateStep({ variables: { input: mutationInput } });
}, [updateStep, flow.id]);
const addStep = React.useCallback((previousStepId) => {
const mutationInput = {
previousStepId: previousStepId,
flow: {
id: flow.id,
},
};
createStep({
variables: { input: mutationInput },
update: updateHandlerFactory(flow.id, previousStepId),
});
}, [createStep, flow.id]);
return (
<Box
display="flex"
@@ -23,15 +91,22 @@ export default function Editor(props: EditorProps): React.ReactElement {
gap={2}
>
{flow?.steps?.map((step, index) => (
<FlowStep
key={step.id}
step={step}
index={index + 1}
collapsed={currentStep !== index}
onOpen={() => setCurrentStep(index)}
onClose={() => setCurrentStep(null)}
/>
<React.Fragment key={`${step}-${index}`}>
<FlowStep
key={step.id}
step={step}
index={index + 1}
collapsed={currentStep !== index}
onOpen={() => setCurrentStep(index)}
onClose={() => setCurrentStep(null)}
onChange={onStepChange}
/>
<IconButton onClick={() => addStep(step.id)} color="primary">
<AddIcon />
</IconButton>
</React.Fragment>
))}
</Box>
)
};
};

View File

@@ -38,9 +38,9 @@ export default function EditorLayout(): React.ReactElement {
<Box pr={1}>
<FormControlLabel
control={
<Switch checked={false} />
<Switch checked={flow?.active} />
}
label={formatMessage('flow.inactive')}
label={flow?.active ? formatMessage('flow.active') : formatMessage('flow.inactive')}
labelPlacement="start"
/>
</Box>

View File

@@ -17,7 +17,7 @@ export default function FlowRow(props: FlowRowProps): React.ReactElement {
const { flow } = props;
return (
<Link to={URLS.FLOW(flow.id)}>
<Link to={URLS.FLOW(flow.id.toString())}>
<Card sx={{ mb: 1 }}>
<CardActionArea>
<CardContent>

View File

@@ -3,13 +3,20 @@ import { useQuery } from '@apollo/client';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Collapse from '@mui/material/Collapse';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import TextField from '@mui/material/TextField';
import Autocomplete from '@mui/material/Autocomplete';
import AppIcon from 'components/AppIcon';
import { GET_APP } from 'graphql/queries/get-app';
import { GET_APPS } from 'graphql/queries/get-apps';
import useFormatMessage from 'hooks/useFormatMessage';
import type { App } from 'types/app';
import type { Step } from 'types/step';
import { StepType } from 'types/step';
import { Header, Wrapper } from './style';
import { Content, Header, Wrapper } from './style';
type FlowStepProps = {
collapsed?: boolean;
@@ -17,41 +24,99 @@ type FlowStepProps = {
index?: number;
onOpen?: () => void;
onClose?: () => void;
onChange?: (step: Step) => void;
}
export default function FlowStep(props: FlowStepProps): React.ReactElement | null {
const { collapsed, index, step } = props;
const formatMessage = useFormatMessage();
const { data } = useQuery(GET_APP, { variables: { key: step?.appKey }})
const app = data?.getApp;
const optionGenerator = (app: App): { label: string; value: string; } => ({
label: app.name,
value: app.key,
});
if (!app) return null;
const getOption = (options: Record<string, unknown>[], appKey: string) => options.find(app => app.value === appKey);
export default function FlowStep(props: FlowStepProps): React.ReactElement | null {
const { collapsed, index, onChange } = props;
const [step, setStep] = React.useState<Step>(props.step);
const formatMessage = useFormatMessage();
const [currentSubstep, setCurrentSubstep] = React.useState<number | null>(0);
const { data } = useQuery(GET_APPS)
const apps: App[] = data?.getApps;
// emit the step change to the parent component
React.useEffect(() => {
onChange?.(step);
}, [step, onChange]);
const appAndEventOptions = React.useMemo(() => apps?.map(optionGenerator), [apps]);
if (!apps) return null;
const app = apps.find((currentApp: App) => currentApp.key === step.appKey);
const onOpen = () => collapsed && props.onOpen?.();
const onClose = () => props.onClose?.();
const toggleSubstep = (substepIndex: number) => setCurrentSubstep((value) => value !== substepIndex ? substepIndex : null);
const onAppAndEventChange = (event: React.SyntheticEvent, selectedOption: unknown) => {
if (typeof selectedOption === 'object') {
const typedSelectedOption = selectedOption as { value: string; };
const option: { value: string } = typedSelectedOption;
const appKey = option.value as string;
const updatedStep = { ...step, appKey };
setStep(updatedStep);
}
}
return (
<Wrapper elevation={collapsed ? 1 : 4} onClick={onOpen}>
<Header borderBottom={!collapsed}>
<Header collapsed={collapsed}>
<Stack direction="row" alignItems="center" gap={2}>
<AppIcon url={app.iconUrl} name={app.name} />
<AppIcon url={app?.iconUrl} name={app?.name} />
<div>
<Typography variant="caption">
{step.type === StepType.Trigger ? formatMessage('flowStep.triggerType') : formatMessage('flowStep.actionType')}
{
step.type === StepType.Trigger ?
formatMessage('flowStep.triggerType') :
formatMessage('flowStep.actionType')
}
</Typography>
<Typography variant="body2">
{index}. {app.name}
{index}. {app?.name}
</Typography>
</div>
</Stack>
</Header>
{!collapsed && (
<Button onClick={onClose}>
Close
</Button>
{true && (
<Collapse in={!collapsed}>
<Content>
<List>
<ListItemButton onClick={() => toggleSubstep(0)}>
Choose app & event
</ListItemButton>
<Collapse in={currentSubstep === 0} timeout="auto" unmountOnExit>
<ListItem sx={{ pt: 2 }}>
<Autocomplete
disablePortal
id="combo-box-demo"
options={appAndEventOptions}
sx={{ width: 300 }}
renderInput={(params) => <TextField {...params} label="Choose app & event" />}
value={getOption(appAndEventOptions, step.appKey)}
onChange={onAppAndEventChange}
/>
</ListItem>
</Collapse>
</List>
</Content>
<Button onClick={onClose}>
Close
</Button>
</Collapse>
)}
</Wrapper>
)

View File

@@ -3,14 +3,21 @@ import Card from '@mui/material/Card';
export const Wrapper = styled(Card)`
width: 100%;
overflow: unset;
`;
type HeaderProps = {
borderBottom?: boolean;
collapsed?: boolean;
}
export const Header = styled('div', { shouldForwardProp: prop => prop !== 'borderBottom' })<HeaderProps>`
border-bottom: 1px solid ${({ theme, borderBottom }) => borderBottom ? alpha(theme.palette.divider, .8) : 'transparent'};
padding: ${({ theme }) => theme.spacing(2, 2)};
cursor: ${({ borderBottom }) => borderBottom ? 'unset' : 'pointer'};
`;
export const Header = styled('div', { shouldForwardProp: prop => prop !== 'collapsed' })<HeaderProps>`
padding: ${({ theme }) => theme.spacing(2)};
cursor: ${({ collapsed }) => collapsed ? 'pointer' : 'unset'};
`;
export const Content = styled('div')`
border: 1px solid ${({ theme }) => alpha(theme.palette.divider, .8)};
border-left: none;
border-right: none;
padding: ${({ theme }) => theme.spacing(2, 0)};
`;

View File

@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const CREATE_STEP = gql`
mutation CreateStep($input: StepInput!) {
createStep(input: $input) {
id
type
key
appKey
connection {
id
}
}
}
`;

View File

@@ -0,0 +1,15 @@
import { gql } from '@apollo/client';
export const UPDATE_STEP = gql`
mutation UpdateStep($input: StepInput!) {
updateStep(input: $input) {
id
type
key
appKey
connection {
id
}
}
}
`;

View File

@@ -55,6 +55,17 @@ export const GET_APP = gql`
name
key
description
subSteps {
name
}
}
actions {
name
key
description
subSteps {
name
}
}
}
}

View File

@@ -5,6 +5,7 @@ export const GET_FLOW = gql`
getFlow(id: $id) {
id
name
active
steps {
id
type

View File

@@ -30,8 +30,8 @@
"connection.deletedMessage": "The connection has been deleted.",
"connection.addedAt": "added {datetime}",
"createFlow.creating": "Creating a flow...",
"flow.active": "Enabled",
"flow.inactive": "Disabled",
"flow.active": "ON",
"flow.inactive": "OFF",
"flowStep.triggerType": "Trigger",
"flowStep.actionType": "Action",
"flows.create": "Create flow",

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useParams, } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import Box from '@mui/material/Box';
import Grid from '@mui/material/Grid';

View File

@@ -20,6 +20,8 @@ type App = {
fields: AppFields[];
authenticationSteps: any[];
reconnectionSteps: any[];
triggers: any[];
actions: any[];
};
export type { App, AppFields };

View File

@@ -1,7 +1,8 @@
import type { Step } from './step';
export type Flow = {
id: string;
id: number;
name: string;
steps: Step[];
active: boolean;
};

View File

@@ -9,5 +9,8 @@ export type Step = {
name: string;
appKey: string;
type: StepType;
connectionId: number;
previousStepId: number | null;
connection: {
id: number;
};
};

6565
yarn.lock

File diff suppressed because it is too large Load Diff