From d06f21c9279eb1dd7020dad04a91f79de5c21f3f Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Thu, 17 Feb 2022 20:15:08 +0100 Subject: [PATCH] feat: add PowerInput component --- .../src/apps/twitter/actions/create-tweet.ts | 4 +- packages/backend/src/apps/twitter/info.json | 4 +- .../src/graphql/mutations/create-step.ts | 1 - .../src/graphql/mutations/delete-step.ts | 1 - .../src/graphql/mutations/update-flow.ts | 5 +- .../queries/get-step-with-test-executions.ts | 2 +- packages/backend/src/graphql/types/action.ts | 4 +- packages/backend/src/models/step.ts | 2 +- packages/web/package.json | 3 + .../components/ChooseAccountSubstep/index.tsx | 2 +- packages/web/src/components/Editor/index.tsx | 2 +- .../web/src/components/EditorLayout/index.tsx | 2 +- .../web/src/components/FlowStep/index.tsx | 149 ++++++----- .../web/src/components/FlowSubstep/index.tsx | 64 +++-- packages/web/src/components/Form/index.tsx | 4 + .../web/src/components/InputCreator/index.tsx | 21 +- packages/web/src/components/Portal/index.tsx | 14 + .../src/components/PowerInput/Suggestions.tsx | 133 ++++++++++ .../web/src/components/PowerInput/data.ts | 44 ++++ .../web/src/components/PowerInput/index.tsx | 247 ++++++++++++++++++ .../web/src/components/PowerInput/style.ts | 28 ++ .../web/src/components/PowerInput/types.ts | 25 ++ .../web/src/components/PowerInput/utils.ts | 91 +++++++ packages/web/src/contexts/StepExecutions.tsx | 20 ++ packages/web/src/graphql/queries/get-apps.ts | 2 + .../queries/get-step-with-test-executions.ts | 11 + .../web/src/helpers/authenticationSteps.ts | 4 +- packages/web/src/types/app.ts | 7 +- packages/web/src/types/step.ts | 1 + yarn.lock | 66 ++++- 30 files changed, 848 insertions(+), 115 deletions(-) create mode 100644 packages/web/src/components/Portal/index.tsx create mode 100644 packages/web/src/components/PowerInput/Suggestions.tsx create mode 100644 packages/web/src/components/PowerInput/data.ts create mode 100644 packages/web/src/components/PowerInput/index.tsx create mode 100644 packages/web/src/components/PowerInput/style.ts create mode 100644 packages/web/src/components/PowerInput/types.ts create mode 100644 packages/web/src/components/PowerInput/utils.ts create mode 100644 packages/web/src/contexts/StepExecutions.tsx create mode 100644 packages/web/src/graphql/queries/get-step-with-test-executions.ts diff --git a/packages/backend/src/apps/twitter/actions/create-tweet.ts b/packages/backend/src/apps/twitter/actions/create-tweet.ts index 2cb60b76..5c58672a 100644 --- a/packages/backend/src/apps/twitter/actions/create-tweet.ts +++ b/packages/backend/src/apps/twitter/actions/create-tweet.ts @@ -12,9 +12,7 @@ export default class CreateTweet { accessSecret: connectionData.accessSecret, }); - if (parameters) { - this.parameters = JSON.parse(parameters); - } + this.parameters = parameters; } async run() { diff --git a/packages/backend/src/apps/twitter/info.json b/packages/backend/src/apps/twitter/info.json index bba524ae..a7d0eacd 100644 --- a/packages/backend/src/apps/twitter/info.json +++ b/packages/backend/src/apps/twitter/info.json @@ -277,7 +277,9 @@ "label": "Tweet body", "key": "tweet", "type": "string", - "required": true + "required": true, + "description": "The content of your new tweet.", + "variables": true } ] }, diff --git a/packages/backend/src/graphql/mutations/create-step.ts b/packages/backend/src/graphql/mutations/create-step.ts index 46021808..956734b8 100644 --- a/packages/backend/src/graphql/mutations/create-step.ts +++ b/packages/backend/src/graphql/mutations/create-step.ts @@ -54,7 +54,6 @@ const createStepResolver = async ( const nextStepQueries = nextSteps.map(async (nextStep, index) => { await nextStep.$query().patchAndFetch({ - ...nextStep, position: step.position + index + 1, }); }); diff --git a/packages/backend/src/graphql/mutations/delete-step.ts b/packages/backend/src/graphql/mutations/delete-step.ts index b3725982..35199535 100644 --- a/packages/backend/src/graphql/mutations/delete-step.ts +++ b/packages/backend/src/graphql/mutations/delete-step.ts @@ -26,7 +26,6 @@ const deleteStepResolver = async ( const nextStepQueries = nextSteps.map(async (nextStep) => { await nextStep.$query().patch({ - ...nextStep, position: nextStep.position - 1, }); }); diff --git a/packages/backend/src/graphql/mutations/update-flow.ts b/packages/backend/src/graphql/mutations/update-flow.ts index aa1698ae..a188c98e 100644 --- a/packages/backend/src/graphql/mutations/update-flow.ts +++ b/packages/backend/src/graphql/mutations/update-flow.ts @@ -19,10 +19,7 @@ const updateFlowResolver = async ( }) .throwIfNotFound(); - flow = await flow.$query().patchAndFetch({ - ...flow, - ...params, - }); + flow = await flow.$query().patchAndFetch(params); return flow; }; diff --git a/packages/backend/src/graphql/queries/get-step-with-test-executions.ts b/packages/backend/src/graphql/queries/get-step-with-test-executions.ts index 9a358f8d..2714d937 100644 --- a/packages/backend/src/graphql/queries/get-step-with-test-executions.ts +++ b/packages/backend/src/graphql/queries/get-step-with-test-executions.ts @@ -20,7 +20,7 @@ const getStepWithTestExecutionsResolver = async ( .withGraphJoined('executionSteps') .select('steps.*', 'executionSteps.data_out as output') .where('flow_id', '=', step.flowId) - .andWhere('position', '<=', step.position) + .andWhere('position', '<', step.position) .distinctOn('executionSteps.step_id') .orderBy([ 'executionSteps.step_id', diff --git a/packages/backend/src/graphql/types/action.ts b/packages/backend/src/graphql/types/action.ts index f772118a..350c6ba3 100644 --- a/packages/backend/src/graphql/types/action.ts +++ b/packages/backend/src/graphql/types/action.ts @@ -21,7 +21,9 @@ const actionType = new GraphQLObjectType({ label: { type: GraphQLString }, key: { type: GraphQLString }, type: { type: GraphQLString }, - required: { type: GraphQLBoolean } + description: { type: GraphQLString }, + required: { type: GraphQLBoolean }, + variables: { type: GraphQLBoolean } } }) ) diff --git a/packages/backend/src/models/step.ts b/packages/backend/src/models/step.ts index fc1d6c7c..1929be9c 100644 --- a/packages/backend/src/models/step.ts +++ b/packages/backend/src/models/step.ts @@ -10,7 +10,7 @@ class Step extends Base { key: string; appKey: string; type!: StepEnumType; - connectionId: string; + connectionId?: string; status: string; position: number; parameters: Record; diff --git a/packages/web/package.json b/packages/web/package.json index 74a4b1fa..93cde33e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -29,6 +29,9 @@ "react-intl": "^5.20.12", "react-router-dom": "^6.0.2", "react-scripts": "5.0.0", + "slate": "^0.72.8", + "slate-history": "^0.66.0", + "slate-react": "^0.72.9", "typescript": "^4.1.2", "web-vitals": "^1.0.1" }, diff --git a/packages/web/src/components/ChooseAccountSubstep/index.tsx b/packages/web/src/components/ChooseAccountSubstep/index.tsx index 689c5fc8..397257e2 100644 --- a/packages/web/src/components/ChooseAccountSubstep/index.tsx +++ b/packages/web/src/components/ChooseAccountSubstep/index.tsx @@ -23,7 +23,7 @@ type ChooseAccountSubstepProps = { }; const optionGenerator = (connection: AppConnection): { label: string; value: string; } => ({ - label: connection?.data?.screenName as string, + label: connection?.data?.screenName as string ?? 'Unnamed', value: connection?.id as string, }); diff --git a/packages/web/src/components/Editor/index.tsx b/packages/web/src/components/Editor/index.tsx index 3d3372dc..d0c31dfe 100644 --- a/packages/web/src/components/Editor/index.tsx +++ b/packages/web/src/components/Editor/index.tsx @@ -49,7 +49,7 @@ export default function Editor(props: EditorProps): React.ReactElement { const mutationInput: Record = { id: step.id, key: step.key, - parameters: JSON.stringify(step.parameters, null, 2), + parameters: step.parameters, connection: { id: step.connection?.id, }, diff --git a/packages/web/src/components/EditorLayout/index.tsx b/packages/web/src/components/EditorLayout/index.tsx index 7b3a9eaa..43d2f664 100644 --- a/packages/web/src/components/EditorLayout/index.tsx +++ b/packages/web/src/components/EditorLayout/index.tsx @@ -68,7 +68,7 @@ export default function EditorLayout(): React.ReactElement { + } label={flow?.active ? formatMessage('flow.active') : formatMessage('flow.inactive')} labelPlacement="start" diff --git a/packages/web/src/components/FlowStep/index.tsx b/packages/web/src/components/FlowStep/index.tsx index 38a98fe7..c4a7b856 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { useQuery } from '@apollo/client'; +import { useQuery, useLazyQuery } from '@apollo/client'; import Stack from '@mui/material/Stack'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; @@ -11,6 +11,7 @@ import IconButton from '@mui/material/IconButton'; import ErrorIcon from '@mui/icons-material/Error'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import { StepExecutionsProvider } from 'contexts/StepExecutions'; import TestSubstep from 'components/TestSubstep'; import FlowSubstep from 'components/FlowSubstep'; import ChooseAppAndEventSubstep from 'components/ChooseAppAndEventSubstep'; @@ -19,6 +20,7 @@ import Form from 'components/Form'; import FlowStepContextMenu from 'components/FlowStepContextMenu'; import AppIcon from 'components/AppIcon'; import { GET_APPS } from 'graphql/queries/get-apps'; +import { GET_STEP_WITH_TEST_EXECUTIONS } from 'graphql/queries/get-step-with-test-executions'; import useFormatMessage from 'hooks/useFormatMessage'; import type { App, AppFields } from 'types/app'; import type { Step } from 'types/step'; @@ -34,35 +36,40 @@ type FlowStepProps = { onChange: (step: Step) => void; } -const parseStep = (step: Step) => { - try { - // 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, - parameters: {}, - }; - } -}; - const validIcon = ; const errorIcon = ; export default function FlowStep(props: FlowStepProps): React.ReactElement | null { const { collapsed, index, onChange } = props; const contextButtonRef = React.useRef(null); - const step: Step = React.useMemo(() => parseStep(props.step), [props.step]); + const step: Step = props.step; const [anchorEl, setAnchorEl] = React.useState(null); const isTrigger = step.type === StepType.Trigger; const formatMessage = useFormatMessage(); - const [currentSubstep, setCurrentSubstep] = React.useState(0); + const [currentSubstep, setCurrentSubstep] = React.useState(2); const { data } = useQuery(GET_APPS, { variables: { onlyWithTriggers: isTrigger }}); + const [ + getStepWithTestExecutions, + { + data: stepWithTestExecutionsData, + called: stepWithTestExecutionsCalled, + loading: stepWithTestExecutionsLoading, + error: stepWithTestExecutionsError + }, + ] = useLazyQuery(GET_STEP_WITH_TEST_EXECUTIONS, { + fetchPolicy: 'network-only', + }); + + React.useEffect(() => { + if (!stepWithTestExecutionsCalled && !collapsed && !isTrigger) { + getStepWithTestExecutions({ + variables: { + stepId: step.id, + }, + }); + } + }, [collapsed, stepWithTestExecutionsCalled, getStepWithTestExecutions, step.id, isTrigger]); + const apps: App[] = data?.getApps; const app = apps?.find((currentApp: App) => currentApp.key === step.appKey); @@ -77,6 +84,10 @@ export default function FlowStep(props: FlowStepProps): React.ReactElement | nul setCurrentSubstep((currentSubstep) => (currentSubstep ?? 0) + 1); }, []); + const handleSubmit = (val: any) => { + handleChange({ step: val as Step }); + } + if (!apps) return null; const onContextMenuClose = (event: React.SyntheticEvent) => { @@ -132,57 +143,59 @@ export default function FlowStep(props: FlowStepProps): React.ReactElement | nul - toggleSubstep(0)} - onCollapse={() => toggleSubstep(0)} - onSubmit={expandNextStep} - onChange={handleChange} - step={step} - /> + +
+ toggleSubstep(0)} + onCollapse={() => toggleSubstep(0)} + onSubmit={expandNextStep} + onChange={handleChange} + step={step} + /> - - {substeps?.length > 0 && substeps.map((substep: { name: string, key: string, arguments: AppFields[] }, index: number) => ( - - {substep.key === 'chooseAccount' && ( - toggleSubstep((index + 1))} - onCollapse={() => toggleSubstep((index + 1))} - onSubmit={expandNextStep} - onChange={handleChange} - step={step} - /> - )} + {substeps?.length > 0 && substeps.map((substep: { name: string, key: string, arguments: AppFields[] }, index: number) => ( + + {substep.key === 'chooseAccount' && ( + toggleSubstep((index + 1))} + onCollapse={() => toggleSubstep((index + 1))} + onSubmit={expandNextStep} + onChange={handleChange} + step={step} + /> + )} - {substep.key === 'testStep' && ( - toggleSubstep((index + 1))} - onCollapse={() => toggleSubstep((index + 1))} - onSubmit={expandNextStep} - onChange={handleChange} - step={step} - /> - )} + {substep.key === 'testStep' && ( + toggleSubstep((index + 1))} + onCollapse={() => toggleSubstep((index + 1))} + onSubmit={expandNextStep} + onChange={handleChange} + step={step} + /> + )} - {['chooseAccount', 'testStep'].includes(substep.key) === false && ( - toggleSubstep((index + 1))} - onCollapse={() => toggleSubstep((index + 1))} - onSubmit={expandNextStep} - onChange={handleChange} - step={step} - /> - )} - - ))} - + {['chooseAccount', 'testStep'].includes(substep.key) === false && ( + 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 index 5790a350..adde2da2 100644 --- a/packages/web/src/components/FlowSubstep/index.tsx +++ b/packages/web/src/components/FlowSubstep/index.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; +import { useFormContext } from 'react-hook-form'; import Collapse from '@mui/material/Collapse'; import ListItem from '@mui/material/ListItem'; import Button from '@mui/material/Button'; @@ -26,9 +27,9 @@ const validateSubstep = (substep: Substep, step: Step) => { return args.every(arg => { if (arg.required === false) { return true; } - const argValue = step.parameters[arg.key]; + const argValue = step.parameters?.[arg.key]; - return argValue !== null && argValue !== undefined; + return Boolean(argValue); }); }; @@ -48,25 +49,39 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement { arguments: args, } = substep; - const handleChangeOnBlur = React.useCallback((event: React.SyntheticEvent) => { - const { name, value: newValue } = event.target as HTMLInputElement; - const currentValue = step.parameters?.[name]; + const formContext = useFormContext(); + const [validationStatus, setValidationStatus] = React.useState(validateSubstep(substep, formContext.getValues() as Step)); - if (currentValue !== newValue) { - onChange({ - step: { - ...step, - parameters: { - ...step.parameters, - [name]: newValue, - } - }, - }); + + const handleChangeOnBlur = React.useCallback((key: string) => { + return (value: string) => { + const currentValue = step.parameters?.[key]; + + if (currentValue !== value) { + onChange({ + step: { + ...step, + parameters: { + ...step.parameters, + [key]: value, + } + }, + }); + } } }, [step, onChange]); + React.useEffect(() => { + function validate (step: unknown) { + const validationResult = validateSubstep(substep, step as Step); + setValidationStatus(validationResult); + }; + const subscription = formContext.watch(validate); + + return () => subscription.unsubscribe(); + }, [substep, formContext.watch]); + const onToggle = expanded ? onCollapse : onExpand; - const valid = validateSubstep(substep, step); return ( @@ -74,22 +89,25 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement { expanded={expanded} onClick={onToggle} title={name} - valid={valid} + valid={validationStatus} /> - - {args?.map((argument) => ( - - ))} - + {args?.map((argument) => ( + + ))} diff --git a/packages/web/src/components/Form/index.tsx b/packages/web/src/components/Form/index.tsx index 7444a7e7..6c4c79ef 100644 --- a/packages/web/src/components/Form/index.tsx +++ b/packages/web/src/components/Form/index.tsx @@ -16,6 +16,10 @@ export default function Form(props: FormProps): React.ReactElement { defaultValues, }); + React.useEffect(() => { + methods.reset(defaultValues); + }, [defaultValues]); + return (
diff --git a/packages/web/src/components/InputCreator/index.tsx b/packages/web/src/components/InputCreator/index.tsx index ffd372d4..defaebc6 100644 --- a/packages/web/src/components/InputCreator/index.tsx +++ b/packages/web/src/components/InputCreator/index.tsx @@ -2,12 +2,14 @@ import * as React from 'react'; import { useFormContext } from 'react-hook-form'; import type { AppFields } from 'types/app'; +import PowerInput from 'components/PowerInput'; import TextField from 'components/TextField'; type InputCreatorProps = { onChange?: React.ChangeEventHandler; onBlur?: React.FocusEventHandler; schema: AppFields; + namePrefix?: string; }; export default function InputCreator(props: InputCreatorProps): React.ReactElement { @@ -15,6 +17,7 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme onChange, onBlur, schema, + namePrefix, } = props; const { control } = useFormContext(); @@ -27,8 +30,24 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme value, description, clickToCopy, + variables, } = schema; + const computedName = namePrefix ? `${namePrefix}.${name}` : name; + + if (variables) { + return ( + + ); + } + return ( { + return typeof document === 'object' + ? ReactDOM.createPortal(children, document.body) + : null +} + +export default Portal; diff --git a/packages/web/src/components/PowerInput/Suggestions.tsx b/packages/web/src/components/PowerInput/Suggestions.tsx new file mode 100644 index 00000000..028dd826 --- /dev/null +++ b/packages/web/src/components/PowerInput/Suggestions.tsx @@ -0,0 +1,133 @@ +import * as React from 'react'; + +import { styled } from '@mui/material/styles'; +import Button from '@mui/material/Button'; +import List from '@mui/material/List'; +import ListItemButton from '@mui/material/ListItemButton'; +import MuiListItemText from '@mui/material/ListItemText'; +import Paper from '@mui/material/Paper'; +import Collapse from '@mui/material/Collapse'; +import Typography from '@mui/material/Typography'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; +import { Step } from 'types/step'; + +const ListItemText = styled(MuiListItemText)``; + +type SuggestionsProps = { + query?: string | null; + index: number; + data: any; + onSuggestionClick: (variable: any) => void; +}; + +const SHORT_LIST_LENGTH = 4; +const LIST_HEIGHT = 256; + +const getPartialFilteredArray = (array: any[], query = '', length = array.length) => { + return array + .filter((suboption: any) => suboption.name.includes(query)) + .slice(0, length); +} + +const Suggestions = (props: SuggestionsProps) => { + const { + query = '', + index: focusIndex, + data, + onSuggestionClick = () => null, + } = props; + const [current, setCurrent] = React.useState(0); + const [listLength, setListLength] = React.useState(SHORT_LIST_LENGTH); + + const expandList = () => { + setListLength(undefined); + }; + + const collapseList = () => { + setListLength(SHORT_LIST_LENGTH); + } + + React.useEffect(() => { + setListLength(SHORT_LIST_LENGTH); + }, [current]) + + return ( + + Variables + + {data.map((option: Step, index: number) => ( + <> + setCurrent((currentIndex) => currentIndex === index ? null : index)} + sx={{ py: 0.5, }} + > + + + {option.output?.length && ( + current === index ? : + )} + + + + + {getPartialFilteredArray(option.output as any || [], query as string, listLength) + .map((suboption: any, index: number) => ( + onSuggestionClick(suboption)} + selected={focusIndex === index}> + + + )) + } + + + {listLength && ( + + )} + + {listLength === undefined && ( + + )} + + + ))} + + + ); +} + +export default Suggestions; diff --git a/packages/web/src/components/PowerInput/data.ts b/packages/web/src/components/PowerInput/data.ts new file mode 100644 index 00000000..883c3cc0 --- /dev/null +++ b/packages/web/src/components/PowerInput/data.ts @@ -0,0 +1,44 @@ +import { Step } from 'types/step'; + +const joinBy = (delimiter = '.', ...args: string[]) => args.filter(Boolean).join(delimiter); + +const process = (data: any, parentKey?: any, index?: number): any[] => { + if (typeof data !== 'object') { + return [ + { + name: `${parentKey}.${index}`, + value: data, + } + ] + } + + const entries = Object.entries(data); + + return entries.flatMap(([name, value]) => { + const fullName = joinBy('.', parentKey, (index as number)?.toString(), name); + + if (Array.isArray(value)) { + return value.flatMap((item, index) => process(item, fullName, index)); + } + + if (typeof value === 'object' && value !== null) { + return process(value, fullName); + } + + return [{ + name: fullName, + value, + }]; + }); +}; + +export const processStepWithExecutions = (steps: Step[]): any[] => { + if (!steps) return []; + + return steps.map((step: Step, index: number) => ({ + id: step.id, + // TODO: replace with step.name once introduced + name: `${index + 1}. ${step.appKey}`, + output: process(step.output, `step.${step.id}`), + })); +}; diff --git a/packages/web/src/components/PowerInput/index.tsx b/packages/web/src/components/PowerInput/index.tsx new file mode 100644 index 00000000..d2e3196e --- /dev/null +++ b/packages/web/src/components/PowerInput/index.tsx @@ -0,0 +1,247 @@ +import * as React from 'react'; +import ClickAwayListener from '@mui/base/ClickAwayListener'; +import Chip from '@mui/material/Chip'; +import Popper from '@mui/material/Popper'; +import TextField from '@mui/material/TextField'; +import InputLabel from '@mui/material/InputLabel'; +import FormHelperText from '@mui/material/FormHelperText'; +import { Controller, Control, FieldValues } from 'react-hook-form'; +import { Editor, Transforms, Range, createEditor } from 'slate'; +import { + Slate, + Editable, + useSelected, + useFocused, +} from 'slate-react'; + +import { + serialize, + deserialize, + insertVariable, + customizeEditor, +} from './utils'; +import Suggestions from './Suggestions'; +import { StepExecutionsContext } from 'contexts/StepExecutions'; + +import { FakeInput, InputLabelWrapper } from './style'; +import { VariableElement } from './types'; +import { processStepWithExecutions } from './data'; + +type PowerInputProps = { + control?: Control; + onChange?: (value: string) => void; + onBlur?: (value: string) => void; + defaultValue?: string; + name: string; + label?: string; + type?: string; + required?: boolean; + readOnly?: boolean; + description?: string; + docUrl?: string; + clickToCopy?: boolean; +} + +const PowerInput = (props: PowerInputProps) => { + const { + control, + defaultValue = '', + onBlur, + name, + label, + required, + description, + } = props; + const priorStepsWithExecutions = React.useContext(StepExecutionsContext); + const editorRef = React.useRef(null); + const [target, setTarget] = React.useState(null); + const [index, setIndex] = React.useState(0); + const [search, setSearch] = React.useState(null); + const renderElement = React.useCallback(props => , []); + const [editor] = React.useState(() => customizeEditor(createEditor())); + + const stepsWithVariables = React.useMemo(() => { + return processStepWithExecutions(priorStepsWithExecutions); + }, [priorStepsWithExecutions]) + + const handleBlur = React.useCallback((value) => { + onBlur?.(value); + }, [onBlur]); + + const handleVariableSuggestionClick = React.useCallback( + (variable: Pick) => { + if (target) { + Transforms.select(editor, target); + insertVariable(editor, variable); + setTarget(null); + } + }, + [index, target] + ); + + const onKeyDown = React.useCallback( + event => { + if (target) { + switch (event.key) { + case 'ArrowDown': { + event.preventDefault(); + setIndex((currentIndex) => currentIndex + 1); + break + } + case 'ArrowUp': { + event.preventDefault(); + setIndex((currentIndex) => currentIndex - 1 < 0 ? 0 : currentIndex - 1); + break + } + case 'Tab': + case 'Enter': { + event.preventDefault(); + Transforms.select(editor, target); + insertVariable(editor, stepsWithVariables[0].output[index]); + setTarget(null); + break + } + case 'Escape': { + event.preventDefault(); + setTarget(null); + break + } + } + } + }, + [index, search, target, stepsWithVariables] + ); + + return ( + ( + { + controllerOnChange(serialize(value)); + const { selection } = editor + + if (selection && Range.isCollapsed(selection)) { + const [start] = Range.edges(selection); + const lineBefore = Editor.before(editor, start, { unit: 'line' }); + const before = lineBefore && Editor.before(editor, lineBefore); + const beforeRange = (before || lineBefore) && Editor.range(editor, before || lineBefore, start); + const beforeText = beforeRange && Editor.string(editor, beforeRange); + const variableMatch = beforeText && beforeText.match(/@([\w.]*?)$/); + + if (variableMatch) { + const beginningOfVariable = Editor.before( + editor, + start, + { + unit: 'offset', + distance: (variableMatch[1].length || 0) + 1 + } + ); + if (beginningOfVariable) { + const newTarget = Editor.range(editor, beginningOfVariable, start); + if (newTarget) { + setTarget(newTarget); + } + } + setIndex(0); + setSearch(variableMatch[1]); + + return; + } + } + + setSearch(null); + }} + > + setSearch(null)}> + {/* ref-able single child for ClickAwayListener */} +
+ + + + {label} + + + + { controllerOnBlur(); handleBlur(value); }} + /> + + {/* ghost placer for the variables popover */} +
+ + + {description} + + + + + +
+ + + )} + /> + ) +} + +const Element = (props: any) => { + const { attributes, children, element } = props; + switch (element.type) { + case 'variable': + return ; + default: + return

{children}

; + } +} + +const Variable = ({ attributes, children, element }: any) => { + const selected = useSelected(); + const focused = useFocused(); + const label = ( + <> + {element.name} + {children} + + ); + return ( + + ) +} + +export default PowerInput; diff --git a/packages/web/src/components/PowerInput/style.ts b/packages/web/src/components/PowerInput/style.ts new file mode 100644 index 00000000..5af48c77 --- /dev/null +++ b/packages/web/src/components/PowerInput/style.ts @@ -0,0 +1,28 @@ +import { styled } from '@mui/material/styles'; + +export const InputLabelWrapper = styled('div')` + position: absolute; + left: ${({ theme }) => theme.spacing(1.75)}; + inset: 0; + left: -6px; +`; + +export const FakeInput = styled('div')` + border: 1px solid #eee; + min-height: 52px; + width: 100%; + display: block; + padding: ${({ theme }) => theme.spacing(0, 1.75)}; + border-radius: ${({ theme }) => theme.spacing(.5)}; + border-color: rgba(0, 0, 0, 0.23); + position: relative; + + &:hover { + border-color: ${({ theme }) => theme.palette.text.primary}; + } + + &:focus-within { + border-color: ${({ theme }) => theme.palette.primary.main}; + border-width: 2px; + } +`; diff --git a/packages/web/src/components/PowerInput/types.ts b/packages/web/src/components/PowerInput/types.ts new file mode 100644 index 00000000..3fb2b1e4 --- /dev/null +++ b/packages/web/src/components/PowerInput/types.ts @@ -0,0 +1,25 @@ +import type { BaseEditor, Text, Descendant } from 'slate'; +import type { ReactEditor } from 'slate-react'; + +export type VariableElement = { + type: 'variable'; + value?: unknown; + name?: string; + children: Text[]; +} + +export type ParagraphElement = { + type: 'paragraph'; + children: Descendant[]; +}; + +export type CustomEditor = BaseEditor & ReactEditor; + +export type CustomElement = VariableElement | ParagraphElement; + +declare module 'slate' { + interface CustomTypes { + Editor: CustomEditor; + Element: CustomElement; + } +} diff --git a/packages/web/src/components/PowerInput/utils.ts b/packages/web/src/components/PowerInput/utils.ts new file mode 100644 index 00000000..43c92058 --- /dev/null +++ b/packages/web/src/components/PowerInput/utils.ts @@ -0,0 +1,91 @@ +import { Text, Descendant, Transforms } from 'slate'; +import { withHistory } from 'slate-history'; +import { withReact } from 'slate-react'; + +import type { + CustomEditor, + CustomElement, + VariableElement, +} from './types'; + +export const deserialize = (value: string): Descendant[] => { + const variableRegExp = /({{.*?}})/g; + + if (!value) return [{ + type: 'paragraph', + children: [{ text: '', }], + }]; + + return value.split('\n').map(line => { + const nodes = line.split(variableRegExp); + + if (nodes.length > 1) { + return { + type: 'paragraph', + children: nodes.map(node => { + if (node.match(variableRegExp)) { + return { + type: 'variable', + name: node.replace(/{{|}}/g, ''), + children: [{ text: '' }], + }; + } + + return { + text: node, + }; + }) + }; + } + + return { + type: 'paragraph', + children: [{ text: line }], + } + }) +}; + +export const serialize = (value: Descendant[]): string => { + return value.map(node => serializeNode(node)).join('\n'); +}; + +const serializeNode = (node: CustomElement | Descendant): string => { + if (Text.isText(node)) { + return node.text; + } + + if (node.type === 'variable') { + return `{{${node.name}}}`; + } + + return node.children.map(n => serializeNode(n)).join(''); +}; + +export const withVariables = (editor: CustomEditor) => { + const { isInline, isVoid } = editor; + + editor.isInline = (element: CustomElement) => { + return element.type === 'variable' ? true : isInline(element); + } + + editor.isVoid = (element: CustomElement) => { + return element.type === 'variable' ? true : isVoid(element); + } + + return editor; +} + +export const insertVariable = (editor: CustomEditor, variableData: Pick) => { + const variable: VariableElement = { + type: 'variable', + name: variableData.name, + value: variableData.value, + children: [{ text: '' }], + }; + Transforms.insertNodes(editor, variable); + Transforms.move(editor); +} + +export const customizeEditor = (editor: CustomEditor): CustomEditor => { + return withVariables(withReact(withHistory(editor))); +}; diff --git a/packages/web/src/contexts/StepExecutions.tsx b/packages/web/src/contexts/StepExecutions.tsx new file mode 100644 index 00000000..587c074d --- /dev/null +++ b/packages/web/src/contexts/StepExecutions.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Step } from 'types/step'; + +export const StepExecutionsContext = React.createContext([]); + +type StepExecutionsProviderProps = { + children: React.ReactNode; + value: Step[]; +} + +export const StepExecutionsProvider = (props: StepExecutionsProviderProps): React.ReactElement => { + const { children, value } = props; + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/packages/web/src/graphql/queries/get-apps.ts b/packages/web/src/graphql/queries/get-apps.ts index cc7b7c91..6e872e07 100644 --- a/packages/web/src/graphql/queries/get-apps.ts +++ b/packages/web/src/graphql/queries/get-apps.ts @@ -76,6 +76,8 @@ export const GET_APPS = gql` key type required + description + variables } } } diff --git a/packages/web/src/graphql/queries/get-step-with-test-executions.ts b/packages/web/src/graphql/queries/get-step-with-test-executions.ts new file mode 100644 index 00000000..813d764d --- /dev/null +++ b/packages/web/src/graphql/queries/get-step-with-test-executions.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const GET_STEP_WITH_TEST_EXECUTIONS = gql` + query GetStepWithTestExecutions($stepId: String!) { + getStepWithTestExecutions(stepId: $stepId) { + id + appKey + output + } + } +`; \ No newline at end of file diff --git a/packages/web/src/helpers/authenticationSteps.ts b/packages/web/src/helpers/authenticationSteps.ts index 0b5f25c6..28d0b762 100644 --- a/packages/web/src/helpers/authenticationSteps.ts +++ b/packages/web/src/helpers/authenticationSteps.ts @@ -40,10 +40,10 @@ function getObjectOfEntries(iterator: any) { const processOpenWithPopup = (step: Step, variables: Record) => { return new Promise((resolve) => { - const windowFeatures = 'toolbar=no, menubar=no, width=600, height=700, top=100, left=100'; + const windowFeatures = 'toolbar=no, titlebar=no, menubar=no, width=500, height=700, top=100, left=100'; const url = variables.url; - const popup: any = window.open(url, '_blank', windowFeatures); + const popup = window.open(url, '_blank', windowFeatures) as WindowProxy; popup?.focus(); const messageHandler = async (event: any) => { diff --git a/packages/web/src/types/app.ts b/packages/web/src/types/app.ts index b674839e..98a3a88f 100644 --- a/packages/web/src/types/app.ts +++ b/packages/web/src/types/app.ts @@ -3,12 +3,13 @@ type AppFields = { name: string; label: string; type: string; - required: boolean, - readOnly: boolean, + required: boolean; + readOnly: boolean; value: string; description: string; docUrl: string; - clickToCopy: boolean, + clickToCopy: boolean; + variables?: boolean; }; type AppConnection = { diff --git a/packages/web/src/types/step.ts b/packages/web/src/types/step.ts index 4cb133f0..a0921326 100644 --- a/packages/web/src/types/step.ts +++ b/packages/web/src/types/step.ts @@ -16,6 +16,7 @@ export type Step = { parameters: Record; connection: Pick; status: 'completed' | 'incomplete'; + output: Record; }; export type Substep = { diff --git a/yarn.lock b/yarn.lock index d0238195..47724030 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4116,6 +4116,11 @@ dependencies: "@types/node" "*" +"@types/is-hotkey@^0.1.1": + version "0.1.7" + resolved "https://registry.yarnpkg.com/@types/is-hotkey/-/is-hotkey-0.1.7.tgz#30ec6d4234895230b576728ef77e70a52962f3b3" + integrity sha512-yB5C7zcOM7idwYZZ1wKQ3pTfjA9BbvFqRWvKB46GFddxnJtHwi/b9y84ykQtxQPg5qhdpg4Q/kWU3EGoCTmLzQ== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -4168,7 +4173,7 @@ dependencies: "@types/lodash" "*" -"@types/lodash@*": +"@types/lodash@*", "@types/lodash@^4.14.149": version "4.14.178" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== @@ -6510,6 +6515,11 @@ compression@^1.7.4: safe-buffer "5.1.2" vary "~1.1.2" +compute-scroll-into-view@^1.0.17: + version "1.0.17" + resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab" + integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -7437,6 +7447,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +direction@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/direction/-/direction-1.0.4.tgz#2b86fb686967e987088caf8b89059370d4837442" + integrity sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ== + discord-api-types@^0.22.0: version "0.22.0" resolved "https://registry.yarnpkg.com/discord-api-types/-/discord-api-types-0.22.0.tgz#34dc57fe8e016e5eaac5e393646cd42a7e1ccc2a" @@ -10292,6 +10307,11 @@ is-hexadecimal@^1.0.0: resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== +is-hotkey@^0.1.6: + version "0.1.8" + resolved "https://registry.yarnpkg.com/is-hotkey/-/is-hotkey-0.1.8.tgz#6b1f4b2d0e5639934e20c05ed24d623a21d36d25" + integrity sha512-qs3NZ1INIS+H+yeo7cD9pDfwYV/jqRh1JG9S9zYrNudkoUQg7OL7ziXqRKu+InFjUIDoP2o6HIkLYMh1pcWgyQ== + is-installed-globally@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" @@ -11723,7 +11743,7 @@ lodash.uniq@4.5.0, lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.7.0: +lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -15641,6 +15661,13 @@ scoped-regex@^2.0.0: resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-2.1.0.tgz#7b9be845d81fd9d21d1ec97c61a0b7cf86d2015f" integrity sha512-g3WxHrqSWCZHGHlSrF51VXFdjImhwvH8ZO/pryFH56Qi0cDsZfylQa/t0jCzVQFNbNvM00HfHjkDPEuarKDSWQ== +scroll-into-view-if-needed@^2.2.20: + version "2.2.29" + resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz#551791a84b7e2287706511f8c68161e4990ab885" + integrity sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg== + dependencies: + compute-scroll-into-view "^1.0.17" + section-matter@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/section-matter/-/section-matter-1.0.0.tgz#e9041953506780ec01d59f292a19c7b850b84167" @@ -15941,6 +15968,36 @@ slash@^4.0.0: resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== +slate-history@^0.66.0: + version "0.66.0" + resolved "https://registry.yarnpkg.com/slate-history/-/slate-history-0.66.0.tgz#ac63fddb903098ceb4c944433e3f75fe63acf940" + integrity sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng== + dependencies: + is-plain-object "^5.0.0" + +slate-react@^0.72.9: + version "0.72.9" + resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.72.9.tgz#b05dd533bd29dd2d4796b614a8d8e01f214bb714" + integrity sha512-FEsqB+D1R/h+w1eCtHH367Krw2X7vju2GjMRL/d0bUiCRXlV50J9I9TJizvi7aaZyqBY8BypCuIiq9nNmsulCA== + dependencies: + "@types/is-hotkey" "^0.1.1" + "@types/lodash" "^4.14.149" + direction "^1.0.3" + is-hotkey "^0.1.6" + is-plain-object "^5.0.0" + lodash "^4.17.4" + scroll-into-view-if-needed "^2.2.20" + tiny-invariant "1.0.6" + +slate@^0.72.8: + version "0.72.8" + resolved "https://registry.yarnpkg.com/slate/-/slate-0.72.8.tgz#5a018edf24e45448655293a68bfbcf563aa5ba81" + integrity sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw== + dependencies: + immer "^9.0.6" + is-plain-object "^5.0.0" + tiny-warning "^1.0.3" + slice-ansi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" @@ -16858,6 +16915,11 @@ timsort@^0.3.0: resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4" integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q= +tiny-invariant@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73" + integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA== + tiny-invariant@^1.0.2: version "1.2.0" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9"