From 50ef6be69c0dcbfe79e010b3358d378218c100c6 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Sun, 6 Feb 2022 22:45:43 +0100 Subject: [PATCH] refactor: separate substeps in FlowStep --- .../components/ChooseAccountSubstep/index.tsx | 122 ++++++++-- .../ChooseAppAndEventSubstep/index.tsx | 154 +++++++++++++ .../web/src/components/FlowStep/index.tsx | 210 ++++-------------- .../web/src/components/FlowSubstep/index.tsx | 104 +++++++++ packages/web/src/graphql/queries/get-flow.ts | 2 + packages/web/src/types/connection.ts | 2 +- packages/web/src/types/step.ts | 16 +- 7 files changed, 420 insertions(+), 190 deletions(-) create mode 100644 packages/web/src/components/ChooseAppAndEventSubstep/index.tsx create mode 100644 packages/web/src/components/FlowSubstep/index.tsx diff --git a/packages/web/src/components/ChooseAccountSubstep/index.tsx b/packages/web/src/components/ChooseAccountSubstep/index.tsx index 92486600..689c5fc8 100644 --- a/packages/web/src/components/ChooseAccountSubstep/index.tsx +++ b/packages/web/src/components/ChooseAccountSubstep/index.tsx @@ -1,15 +1,25 @@ import * as React from 'react'; -import { useQuery } from '@apollo/client'; -import Autocomplete from '@mui/material/Autocomplete'; +import { useQuery, useLazyQuery } from '@apollo/client'; import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import ListItem from '@mui/material/ListItem'; +import Autocomplete from '@mui/material/Autocomplete'; +import FlowSubstepTitle from 'components/FlowSubstepTitle'; import type { App, AppConnection } from 'types/app'; +import type { Step, Substep } from 'types/step'; import { GET_APP_CONNECTIONS } from 'graphql/queries/get-app-connections'; +import { TEST_CONNECTION } from 'graphql/queries/test-connection'; type ChooseAccountSubstepProps = { - appKey: string; - connectionId: string; - onChange: (connectionId: string) => void; + substep: Substep, + expanded?: boolean; + onExpand: () => void; + onCollapse: () => void; + onChange: ({ step }: { step: Step}) => void; + onSubmit: () => void; + step: Step; }; const optionGenerator = (connection: AppConnection): { label: string; value: string; } => ({ @@ -20,31 +30,105 @@ const optionGenerator = (connection: AppConnection): { label: string; value: str const getOption = (options: Record[], connectionId: string) => options.find(connection => connection.value === connectionId) || null; function ChooseAccountSubstep(props: ChooseAccountSubstepProps): React.ReactElement { - const { appKey, connectionId, onChange } = props; + const { + substep, + expanded = false, + onExpand, + onCollapse, + step, + onSubmit, + onChange, + } = props; + const { + connection, + appKey, + } = step; const { data, loading } = useQuery(GET_APP_CONNECTIONS, { variables: { key: appKey }}); + // TODO: show detailed error when connection test/verification fails + const [ + testConnection, + { + loading: testResultLoading, + refetch: retestConnection + } + ] = useLazyQuery (TEST_CONNECTION, { variables: { id: connection?.id, }}); + + React.useEffect(() => { + if (connection?.id) { + testConnection({ + variables: { id: connection?.id }, + }); + } + // intentionally no dependencies for initial test + }, []); const connectionOptions = React.useMemo(() => (data?.getApp as App)?.connections?.map((connection) => optionGenerator(connection)) || [], [data]); + const { name } = substep; + const handleChange = React.useCallback((event: React.SyntheticEvent, selectedOption: unknown) => { if (typeof selectedOption === 'object') { + // TODO: try to simplify type casting below. const typedSelectedOption = selectedOption as { value: string }; - const value = typedSelectedOption.value; + const option: { value: string } = typedSelectedOption; + const connectionId = option?.value as string; - onChange(value); + if (connectionId !== step.connection?.id) { + onChange({ + step: { + ...step, + connection: { + id: connectionId, + }, + }, + }); + } } - }, [onChange]); + }, [step, onChange]); + + React.useEffect(() => { + if (step.connection?.id) { + retestConnection({ + id: step.connection.id, + }); + } + }, [step.connection?.id, retestConnection]) + + const onToggle = expanded ? onCollapse : onExpand; return ( - } - value={getOption(connectionOptions, connectionId)} - onChange={handleChange} - loading={loading} - /> + + + + + } + value={getOption(connectionOptions, connection?.id)} + onChange={handleChange} + loading={loading} + /> + + + + + ); } diff --git a/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx b/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx new file mode 100644 index 00000000..0654e69e --- /dev/null +++ b/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import { useQuery } from '@apollo/client'; +import Typography from '@mui/material/Typography'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import ListItem from '@mui/material/ListItem'; +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; + +import { GET_APPS } from 'graphql/queries/get-apps'; +import FlowSubstepTitle from 'components/FlowSubstepTitle'; +import type { App } from 'types/app'; +import type { Step, Substep } from 'types/step'; +import { StepType } from 'types/step'; + +type ChooseAppAndEventSubstepProps = { + substep: Substep, + expanded?: boolean; + onExpand: () => void; + onCollapse: () => void; + onChange: ({ step }: { step: Step}) => void; + onSubmit: () => void; + step: Step; +}; + +const optionGenerator = (app: Record): { label: string; value: string; } => ({ + label: app.name as string, + value: app.key as string, +}); + +const getOption = (options: Record[], appKey: unknown) => options.find(app => app.value === appKey as string) || null; + +function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.ReactElement { + const { + substep, + expanded = false, + onExpand, + onCollapse, + step, + onSubmit, + onChange, + } = props; + + const isTrigger = step.type === StepType.Trigger; + + const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }}); + const apps: App[] = data?.getApps; + const app = apps?.find((currentApp: App) => currentApp.key === step.appKey); + + const appOptions = React.useMemo(() => apps?.map((app) => optionGenerator(app)), [apps]); + const actionsOrTriggers = isTrigger ? app?.triggers : app?.actions; + const actionOptions = React.useMemo(() => actionsOrTriggers?.map((trigger) => optionGenerator(trigger)) ?? [], [app?.key]); + + const { + name, + } = substep; + + const valid: boolean = !!step.key && !!step.appKey; + + // placeholders + const onEventChange = React.useCallback((event: React.SyntheticEvent, selectedOption: unknown) => { + if (typeof selectedOption === 'object') { + // TODO: try to simplify type casting below. + const typedSelectedOption = selectedOption as { value: string }; + const option: { value: string } = typedSelectedOption; + const eventKey = option?.value as string; + + if (step.key !== eventKey) { + onChange({ + step: { + ...step, + key: eventKey, + }, + }); + } + } + }, [step, onChange]); + + const onAppChange = React.useCallback((event: React.SyntheticEvent, selectedOption: unknown) => { + if (typeof selectedOption === 'object') { + // TODO: try to simplify type casting below. + const typedSelectedOption = selectedOption as { value: string }; + const option: { value: string } = typedSelectedOption; + const appKey = option?.value as string; + + if (step.appKey !== appKey) { + onChange({ + step: { + ...step, + key: null, + appKey, + }, + }); + } + } + }, [step, onChange]);; + + const onToggle = expanded ? onCollapse : onExpand; + + return ( + + + + + } + value={getOption(appOptions, step.appKey)} + onChange={onAppChange} + /> + + {step.appKey && ( + + + Action event + + + } + value={getOption(actionOptions, step.key)} + onChange={onEventChange} + /> + + )} + + + + + + ); +} + +export default ChooseAppAndEventSubstep; diff --git a/packages/web/src/components/FlowStep/index.tsx b/packages/web/src/components/FlowStep/index.tsx index d9ea2d73..f5682b12 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -6,19 +6,13 @@ import Box from '@mui/material/Box'; 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 MoreHorizIcon from '@mui/icons-material/MoreHoriz'; -import ExpandLessIcon from '@mui/icons-material/ExpandLess'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import IconButton from '@mui/material/IconButton'; -import FlowSubstepTitle from 'components/FlowSubstepTitle'; +import FlowSubstep from 'components/FlowSubstep'; +import ChooseAppAndEventSubstep from 'components/ChooseAppAndEventSubstep'; import ChooseAccountSubstep from 'components/ChooseAccountSubstep'; import Form from 'components/Form'; -import InputCreator from 'components/InputCreator'; import FlowStepContextMenu from 'components/FlowStepContextMenu'; import AppIcon from 'components/AppIcon'; import { GET_APPS } from 'graphql/queries/get-apps'; @@ -34,127 +28,47 @@ type FlowStepProps = { index?: number; onOpen?: () => void; onClose?: () => void; - onChange?: (step: Step) => void; + onChange: (step: Step) => void; } -const optionGenerator = (app: Record): { label: string; value: string; } => ({ - label: app.name as string, - value: app.key as string, -}); - -const getOption = (options: Record[], appKey: unknown) => options.find(app => app.value === appKey as string) || null; - -const parseStep = (step: any) => { +const parseStep = (step: Step) => { try { - const parameters = JSON.parse(step.parameters); + // stringify stringified JSON first to overcome type casting + const parameters = JSON.parse(step.parameters?.toString()); return { ...step, parameters, } } catch (err) { // highly likely that step does not have any parameters and thus, the error is thrown - return step; + return { + ...step, + parameters: {}, + }; } }; -const validateSubstep = (substep: any, step: Step, substepIndex: number, substepCount: number) => { - if (!substep) return true; - - if (substepCount < substepIndex + 1) { - return null; - } - - if (substep.name === 'Choose account') { - return Boolean(step.connection?.id); - } - - const args: AppFields[] = substep.arguments || []; - - return args.every(arg => { - if (arg.required === false) { return true; } - - const argValue = step.parameters[arg.key]; - - return argValue !== null && argValue !== undefined; - }); -}; - export default function FlowStep(props: FlowStepProps): React.ReactElement | null { const { collapsed, index, onChange } = props; const contextButtonRef = React.useRef(null); - const [step, setStep] = React.useState(() => parseStep(props.step)); + const step: Step = React.useMemo(() => parseStep(props.step), [props.step]); const [anchorEl, setAnchorEl] = React.useState(null); const isTrigger = step.type === StepType.Trigger; - const isAction = step.type === StepType.Action; - const initialRender = React.useRef(true); const formatMessage = useFormatMessage(); const [currentSubstep, setCurrentSubstep] = React.useState(0); const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }}); const apps: App[] = data?.getApps; const app = apps?.find((currentApp: App) => currentApp.key === step.appKey); - // emit the step change to the parent component - React.useEffect(() => { - if (!initialRender.current) { - onChange?.(step); - } else { - initialRender.current = false; - } - }, [step, onChange]); - - const appOptions = React.useMemo(() => apps?.map((app) => optionGenerator(app)), [apps]); const actionsOrTriggers = isTrigger ? app?.triggers : app?.actions; - const actionOptions = React.useMemo(() => actionsOrTriggers?.map((trigger) => optionGenerator(trigger)) ?? [], [app?.key]); const substeps = React.useMemo(() => actionsOrTriggers?.find(({ key }) => key === step.key)?.subSteps || [], [actionsOrTriggers, step?.key]); - const substepCount = substeps.length + 1; - const expandNextStep = React.useCallback(() => { setCurrentSubstep((currentSubstep) => (currentSubstep ?? 0) + 1); }, []); + const handleChange = React.useCallback(({ step }: { step: Step }) => { + onChange(step); + }, []) - const onAppChange = React.useCallback((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; - setStep((step) => ({ ...step, appKey, parameters: {} })); - } - }, []); - - const onEventChange = React.useCallback((event: React.SyntheticEvent, selectedOption: unknown) => { - if (typeof selectedOption === 'object') { - const typedSelectedOption = selectedOption as { value: string; }; - const option: { value: string } = typedSelectedOption; - const eventKey = option?.value as string; - setStep((step) => ({ - ...step, - key: eventKey, - parameters: {}, - })); - - expandNextStep(); - } - }, []); - - const onAccountChange = React.useCallback((connectionId: string) => { - setStep((step) => ({ - ...step, - connection: { - id: connectionId, - }, - })); - - expandNextStep(); - }, []); - - const onParameterChange = React.useCallback((event: React.SyntheticEvent) => { - const { name, value } = event.target as HTMLInputElement; - - setStep((step) => ({ - ...step, - parameters: { - ...step.parameters, - [name]: value - } - })); + const expandNextStep = React.useCallback(() => { + setCurrentSubstep((currentSubstep) => (currentSubstep ?? 0) + 1); }, []); if (!apps) return null; @@ -201,78 +115,44 @@ export default function FlowStep(props: FlowStepProps): React.ReactElement | nul - + - toggleSubstep(0)} - title="Choose app & event" - valid={substepCount === 1 ? null : !!step.appKey && !!step.key} + substep={{ name: 'Choose app & event', arguments: [] }} + onExpand={() => toggleSubstep(0)} + onCollapse={() => toggleSubstep(0)} + onSubmit={expandNextStep} + onChange={handleChange} + step={step} /> - - - } - value={getOption(appOptions, step.appKey)} - onChange={onAppChange} - /> - - {step.appKey && ( - - - Action event - - - } - value={getOption(actionOptions, step.key)} - onChange={onEventChange} - /> - - )} - -
{substeps?.length > 0 && substeps.map((substep: { name: string, arguments: AppFields[] }, index: number) => ( - {validateSubstep(substeps[index - 1], step, index, substepCount) && ( - - toggleSubstep(index + 1)} - title={substep.name} - valid={validateSubstep(substep, step, index + 1, substepCount)} - /> - - - {substep.name === 'Choose account' && ( - - )} + {substep.name === 'Choose account' && ( + toggleSubstep((index + 1))} + onCollapse={() => toggleSubstep((index + 1))} + onSubmit={expandNextStep} + onChange={handleChange} + step={step} + /> + )} - {substep.name !== 'Choose account' && ( - - {substep?.arguments?.map((argument: AppFields) => ( - - ))} - - )} - - - + {substep.name !== 'Choose account' && ( + toggleSubstep((index + 1))} + onCollapse={() => toggleSubstep((index + 1))} + onSubmit={expandNextStep} + onChange={handleChange} + step={step} + /> )} ))} diff --git a/packages/web/src/components/FlowSubstep/index.tsx b/packages/web/src/components/FlowSubstep/index.tsx new file mode 100644 index 00000000..7a45591b --- /dev/null +++ b/packages/web/src/components/FlowSubstep/index.tsx @@ -0,0 +1,104 @@ +import * as React from 'react'; +import Collapse from '@mui/material/Collapse'; +import ListItem from '@mui/material/ListItem'; +import Button from '@mui/material/Button'; + +import FlowSubstepTitle from 'components/FlowSubstepTitle'; +import InputCreator from 'components/InputCreator'; +import type { Step, Substep } from 'types/step'; +import type { AppFields } from 'types/app'; + +type FlowSubstepProps = { + substep: Substep, + expanded?: boolean; + onExpand: () => void; + onCollapse: () => void; + onChange: ({ step }: { step: Step }) => void; + onSubmit: () => void; + step: Step; +}; + +const validateSubstep = (substep: Substep, step: Step) => { + if (!substep) return true; + + const args: AppFields[] = substep.arguments || []; + + return args.every(arg => { + if (arg.required === false) { return true; } + + const argValue = step.parameters[arg.key]; + + return argValue !== null && argValue !== undefined; + }); +}; + +function FlowSubstep(props: FlowSubstepProps): React.ReactElement { + const { + substep, + expanded = false, + onExpand, + onCollapse, + onChange, + onSubmit, + step, + } = props; + + const { + name, + arguments: args, + } = substep; + + const handleChangeOnBlur = React.useCallback((event: React.SyntheticEvent) => { + const { name, value: newValue } = event.target as HTMLInputElement; + const currentValue = step.parameters?.[name]; + + if (currentValue !== newValue) { + onChange({ + step: { + ...step, + parameters: { + ...step.parameters, + [name]: newValue, + } + }, + }); + } + }, [step, onChange]); + + const onToggle = expanded ? onCollapse : onExpand; + const valid = validateSubstep(substep, step); + + return ( + + + + + {substep.name !== 'Choose account' && ( + + {args?.map((argument) => ( + + ))} + + )} + + + + + + ); +}; + +export default FlowSubstep; diff --git a/packages/web/src/graphql/queries/get-flow.ts b/packages/web/src/graphql/queries/get-flow.ts index 267c9163..2e11d172 100644 --- a/packages/web/src/graphql/queries/get-flow.ts +++ b/packages/web/src/graphql/queries/get-flow.ts @@ -13,6 +13,8 @@ export const GET_FLOW = gql` appKey connection { id + verified + createdAt } parameters } diff --git a/packages/web/src/types/connection.ts b/packages/web/src/types/connection.ts index c903a17d..4114f58b 100644 --- a/packages/web/src/types/connection.ts +++ b/packages/web/src/types/connection.ts @@ -6,7 +6,7 @@ type Connection = { id: string; key: string; data: ConnectionData; - verified: boolean; + verified?: boolean; createdAt: string; }; diff --git a/packages/web/src/types/step.ts b/packages/web/src/types/step.ts index d96d7d99..bf29954d 100644 --- a/packages/web/src/types/step.ts +++ b/packages/web/src/types/step.ts @@ -1,3 +1,6 @@ +import type { AppFields } from './app'; +import type { Connection } from './connection'; + export enum StepType { Trigger = 'trigger', Action = 'action', @@ -5,13 +8,16 @@ export enum StepType { export type Step = { id: string; - key: string; + key: string | null; name: string; - appKey: string; + appKey: string | null; type: StepType; previousStepId: string | null; parameters: Record; - connection: { - id: string; - }; + connection: Pick; +}; + +export type Substep = { + name: string; + arguments: AppFields[]; };