diff --git a/packages/backend/src/apps/deepl/actions/translate-text/index.ts b/packages/backend/src/apps/deepl/actions/translate-text/index.ts index 689c921d..18ee70eb 100644 --- a/packages/backend/src/apps/deepl/actions/translate-text/index.ts +++ b/packages/backend/src/apps/deepl/actions/translate-text/index.ts @@ -20,7 +20,7 @@ export default defineAction({ type: 'dropdown' as const, required: true, description: 'Language to translate the text to.', - variables: false, + variables: true, value: '', options: [ { label: 'Bulgarian', value: 'BG' }, diff --git a/packages/backend/src/apps/delay/actions/delay-for/index.ts b/packages/backend/src/apps/delay/actions/delay-for/index.ts index e11c7c58..d3d15032 100644 --- a/packages/backend/src/apps/delay/actions/delay-for/index.ts +++ b/packages/backend/src/apps/delay/actions/delay-for/index.ts @@ -13,7 +13,7 @@ export default defineAction({ required: true, value: null, description: 'Delay for unit, e.g. minutes, hours, days, weeks.', - variables: false, + variables: true, options: [ { label: 'Minutes', diff --git a/packages/backend/src/apps/discord/actions/send-message-to-channel/index.ts b/packages/backend/src/apps/discord/actions/send-message-to-channel/index.ts index afebe42a..fa72ea83 100644 --- a/packages/backend/src/apps/discord/actions/send-message-to-channel/index.ts +++ b/packages/backend/src/apps/discord/actions/send-message-to-channel/index.ts @@ -11,7 +11,7 @@ export default defineAction({ type: 'dropdown' as const, required: true, description: 'Pick a channel to send the message to.', - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', diff --git a/packages/backend/src/apps/github/actions/create-issue/index.ts b/packages/backend/src/apps/github/actions/create-issue/index.ts index 923679a1..af4fe9eb 100644 --- a/packages/backend/src/apps/github/actions/create-issue/index.ts +++ b/packages/backend/src/apps/github/actions/create-issue/index.ts @@ -11,7 +11,7 @@ export default defineAction({ key: 'repo', type: 'dropdown' as const, required: false, - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', diff --git a/packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.ts b/packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.ts index ecd19f9c..645d4bbd 100644 --- a/packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.ts +++ b/packages/backend/src/apps/google-sheets/actions/create-spreadsheet-row/index.ts @@ -21,7 +21,7 @@ export default defineAction({ required: false, description: 'The Google Drive where your spreadsheet resides. If nothing is selected, then your personal Google Drive will be used.', - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', @@ -40,7 +40,7 @@ export default defineAction({ required: true, dependsOn: ['parameters.driveId'], description: 'The spreadsheets in your Google Drive.', - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', @@ -63,7 +63,7 @@ export default defineAction({ required: true, dependsOn: ['parameters.spreadsheetId'], description: 'The worksheets in your selected spreadsheet.', - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', diff --git a/packages/backend/src/apps/http-request/actions/custom-request/index.ts b/packages/backend/src/apps/http-request/actions/custom-request/index.ts index 569b5853..25c01d68 100644 --- a/packages/backend/src/apps/http-request/actions/custom-request/index.ts +++ b/packages/backend/src/apps/http-request/actions/custom-request/index.ts @@ -84,7 +84,7 @@ export default defineAction({ type: 'string' as const, required: true, description: 'Header key', - variables: false, + variables: true, }, { label: 'Value', @@ -132,7 +132,7 @@ export default defineAction({ throwIfFileSizeExceedsLimit(metadataResponse.headers['content-length']); // eslint-disable-next-line no-empty - } catch {} + } catch { } const requestData: AxiosRequestConfig = { url, diff --git a/packages/backend/src/apps/openai/actions/send-chat-prompt/index.ts b/packages/backend/src/apps/openai/actions/send-chat-prompt/index.ts index e15ee972..0e5a7dec 100644 --- a/packages/backend/src/apps/openai/actions/send-chat-prompt/index.ts +++ b/packages/backend/src/apps/openai/actions/send-chat-prompt/index.ts @@ -19,7 +19,7 @@ export default defineAction({ key: 'model', type: 'dropdown' as const, required: true, - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', @@ -35,7 +35,7 @@ export default defineAction({ label: 'Messages', key: 'messages', type: 'dynamic' as const, - required: false, + required: true, description: 'Add or remove messages as needed', value: [{ role: 'system', body: '' }], fields: [ diff --git a/packages/backend/src/apps/openai/actions/send-prompt/index.ts b/packages/backend/src/apps/openai/actions/send-prompt/index.ts index 76f8d439..7eef1b62 100644 --- a/packages/backend/src/apps/openai/actions/send-prompt/index.ts +++ b/packages/backend/src/apps/openai/actions/send-prompt/index.ts @@ -14,7 +14,7 @@ export default defineAction({ key: 'model', type: 'dropdown' as const, required: true, - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', diff --git a/packages/backend/src/apps/postgresql/actions/delete/index.ts b/packages/backend/src/apps/postgresql/actions/delete/index.ts index 23c6cf63..518017d0 100644 --- a/packages/backend/src/apps/postgresql/actions/delete/index.ts +++ b/packages/backend/src/apps/postgresql/actions/delete/index.ts @@ -18,14 +18,14 @@ export default defineAction({ type: 'string' as const, value: 'public', required: true, - variables: false, + variables: true, }, { label: 'Table name', key: 'table', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Where clause entries', @@ -38,14 +38,14 @@ export default defineAction({ key: 'columnName', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Operator', key: 'operator', type: 'dropdown' as const, required: true, - variables: false, + variables: true, options: whereClauseOperators }, { @@ -69,7 +69,7 @@ export default defineAction({ key: 'parameter', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Value', diff --git a/packages/backend/src/apps/postgresql/actions/insert/index.ts b/packages/backend/src/apps/postgresql/actions/insert/index.ts index 56e46fab..5c0e8545 100644 --- a/packages/backend/src/apps/postgresql/actions/insert/index.ts +++ b/packages/backend/src/apps/postgresql/actions/insert/index.ts @@ -16,14 +16,14 @@ export default defineAction({ type: 'string' as const, value: 'public', required: true, - variables: false, + variables: true, }, { label: 'Table name', key: 'table', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Column - value entries', @@ -37,7 +37,7 @@ export default defineAction({ key: 'columnName', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Value', @@ -52,7 +52,7 @@ export default defineAction({ label: 'Run-time parameters', key: 'params', type: 'dynamic' as const, - required: false, + required: true, description: 'Change run-time configuration parameters with SET command', fields: [ { @@ -60,7 +60,7 @@ export default defineAction({ key: 'parameter', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Value', diff --git a/packages/backend/src/apps/postgresql/actions/sql-query/index.ts b/packages/backend/src/apps/postgresql/actions/sql-query/index.ts index 81eea625..e5145cc7 100644 --- a/packages/backend/src/apps/postgresql/actions/sql-query/index.ts +++ b/packages/backend/src/apps/postgresql/actions/sql-query/index.ts @@ -27,7 +27,7 @@ export default defineAction({ key: 'parameter', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Value', diff --git a/packages/backend/src/apps/postgresql/actions/update/index.ts b/packages/backend/src/apps/postgresql/actions/update/index.ts index df6d7c23..9a43506e 100644 --- a/packages/backend/src/apps/postgresql/actions/update/index.ts +++ b/packages/backend/src/apps/postgresql/actions/update/index.ts @@ -19,14 +19,14 @@ export default defineAction({ type: 'string' as const, value: 'public', required: true, - variables: false, + variables: true, }, { label: 'Table name', key: 'table', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Where clause entries', @@ -39,14 +39,14 @@ export default defineAction({ key: 'columnName', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Operator', key: 'operator', type: 'dropdown' as const, required: true, - variables: false, + variables: true, options: whereClauseOperators }, { @@ -70,7 +70,7 @@ export default defineAction({ key: 'columnName', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Value', @@ -93,7 +93,7 @@ export default defineAction({ key: 'parameter', type: 'string' as const, required: true, - variables: false, + variables: true, }, { label: 'Value', diff --git a/packages/backend/src/apps/salesforce/actions/find-record/index.ts b/packages/backend/src/apps/salesforce/actions/find-record/index.ts index 6daa21a9..a1105352 100644 --- a/packages/backend/src/apps/salesforce/actions/find-record/index.ts +++ b/packages/backend/src/apps/salesforce/actions/find-record/index.ts @@ -10,6 +10,7 @@ export default defineAction({ key: 'object', type: 'dropdown' as const, required: true, + variables: true, description: 'Pick which type of object you want to search for.', source: { type: 'query', @@ -28,7 +29,7 @@ export default defineAction({ type: 'dropdown' as const, description: 'Pick which field to search by', required: true, - variables: false, + variables: true, dependsOn: ['parameters.object'], source: { type: 'query', diff --git a/packages/backend/src/apps/slack/actions/find-message/index.ts b/packages/backend/src/apps/slack/actions/find-message/index.ts index ff8df853..48007c2d 100644 --- a/packages/backend/src/apps/slack/actions/find-message/index.ts +++ b/packages/backend/src/apps/slack/actions/find-message/index.ts @@ -23,7 +23,7 @@ export default defineAction({ 'Sort messages by their match strength or by their date. Default is score.', required: true, value: 'score', - variables: false, + variables: true, options: [ { label: 'Match strength', @@ -43,7 +43,7 @@ export default defineAction({ 'Sort matching messages in ascending or descending order. Default is descending.', required: true, value: 'desc', - variables: false, + variables: true, options: [ { label: 'Descending (newest or best match first)', diff --git a/packages/backend/src/apps/slack/actions/send-a-direct-message/index.ts b/packages/backend/src/apps/slack/actions/send-a-direct-message/index.ts index 38fbbbbb..481150bf 100644 --- a/packages/backend/src/apps/slack/actions/send-a-direct-message/index.ts +++ b/packages/backend/src/apps/slack/actions/send-a-direct-message/index.ts @@ -12,7 +12,7 @@ export default defineAction({ type: 'dropdown' as const, required: true, description: 'Pick a user to send the message to.', - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', @@ -40,7 +40,7 @@ export default defineAction({ value: false, description: 'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.', - variables: false, + variables: true, options: [ { label: 'Yes', diff --git a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts index 70315501..6b42cc6c 100644 --- a/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts +++ b/packages/backend/src/apps/slack/actions/send-a-message-to-channel/index.ts @@ -12,7 +12,7 @@ export default defineAction({ type: 'dropdown' as const, required: true, description: 'Pick a channel to send the message to.', - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', @@ -40,7 +40,7 @@ export default defineAction({ value: false, description: 'If you choose no, this message will appear to come from you. Direct messages are always sent by bots.', - variables: false, + variables: true, options: [ { label: 'Yes', diff --git a/packages/backend/src/apps/telegram-bot/actions/send-message/index.ts b/packages/backend/src/apps/telegram-bot/actions/send-message/index.ts index 5d884cb7..6399d9bc 100644 --- a/packages/backend/src/apps/telegram-bot/actions/send-message/index.ts +++ b/packages/backend/src/apps/telegram-bot/actions/send-message/index.ts @@ -29,7 +29,7 @@ export default defineAction({ required: false, value: false, description: 'Sends the message silently. Users will receive a notification with no sound.', - variables: false, + variables: true, options: [ { label: 'Yes', diff --git a/packages/backend/src/apps/todoist/actions/create-task/index.ts b/packages/backend/src/apps/todoist/actions/create-task/index.ts index 785c7ebe..a3924486 100644 --- a/packages/backend/src/apps/todoist/actions/create-task/index.ts +++ b/packages/backend/src/apps/todoist/actions/create-task/index.ts @@ -10,7 +10,7 @@ export default defineAction({ key: 'projectId', type: 'dropdown' as const, required: false, - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', @@ -27,7 +27,7 @@ export default defineAction({ key: 'sectionId', type: 'dropdown' as const, required: false, - variables: false, + variables: true, dependsOn: ['parameters.projectId'], source: { type: 'query', diff --git a/packages/backend/src/apps/twilio/actions/send-sms/index.ts b/packages/backend/src/apps/twilio/actions/send-sms/index.ts index 136880fc..f983c5bd 100644 --- a/packages/backend/src/apps/twilio/actions/send-sms/index.ts +++ b/packages/backend/src/apps/twilio/actions/send-sms/index.ts @@ -13,7 +13,7 @@ export default defineAction({ required: true, description: 'The number to send the SMS from. Include country code. Example: 15551234567', - variables: false, + variables: true, source: { type: 'query', name: 'getDynamicData', diff --git a/packages/backend/src/graphql/queries/get-dynamic-data.ts b/packages/backend/src/graphql/queries/get-dynamic-data.ts index 1ea74a91..5b713049 100644 --- a/packages/backend/src/graphql/queries/get-dynamic-data.ts +++ b/packages/backend/src/graphql/queries/get-dynamic-data.ts @@ -1,7 +1,9 @@ import { IDynamicData, IJSONObject } from '@automatisch/types'; import Context from '../../types/express/context'; import App from '../../models/app'; +import ExecutionStep from '../../models/execution-step'; import globalVariable from '../../helpers/global-variable'; +import computeParameters from '../../helpers/compute-parameters'; type Params = { stepId: string; @@ -28,18 +30,29 @@ const getDynamicData = async ( if (!connection || !step.appKey) return null; + const flow = step.flow; const app = await App.findOneByKey(step.appKey); - const $ = await globalVariable({ connection, app, flow: step.flow, step }); + const $ = await globalVariable({ connection, app, flow, step }); const command = app.dynamicData.find( (data: IDynamicData) => data.key === params.key ); + // apply run-time parameters that're not persisted yet for (const parameterKey in params.parameters) { const parameterValue = params.parameters[parameterKey]; $.step.parameters[parameterKey] = parameterValue; } + const priorExecutionSteps = await ExecutionStep.query().where({ + execution_id: (await flow.$relatedQuery('lastExecution')).id, + }); + + // compute variables in parameters + const computedParameters = computeParameters($.step.parameters, priorExecutionSteps); + + $.step.parameters = computedParameters; + const fetchedData = await command.run($); if (fetchedData.error) { diff --git a/packages/backend/src/models/flow.ts b/packages/backend/src/models/flow.ts index 5ae48d6c..e00beccd 100644 --- a/packages/backend/src/models/flow.ts +++ b/packages/backend/src/models/flow.ts @@ -21,6 +21,7 @@ class Flow extends Base { published_at: string; remoteWebhookId: string; executions?: Execution[]; + lastExecution?: Execution; user?: User; static tableName = 'flows'; @@ -58,6 +59,17 @@ class Flow extends Base { to: 'executions.flow_id', }, }, + lastExecution: { + relation: Base.HasOneRelation, + modelClass: Execution, + join: { + from: 'flows.id', + to: 'executions.flow_id', + }, + filter(builder: ExtendedQueryBuilder) { + builder.orderBy('created_at', 'desc').limit(1).first(); + }, + }, user: { relation: Base.HasOneRelation, modelClass: User, @@ -89,10 +101,7 @@ class Flow extends Base { } async lastInternalId() { - const lastExecution = await this.$relatedQuery('executions') - .orderBy('created_at', 'desc') - .limit(1) - .first(); + const lastExecution = await this.$relatedQuery('lastExecution'); return lastExecution ? (lastExecution as Execution).internalId : null; } diff --git a/packages/web/package.json b/packages/web/package.json index d689ef6b..636e6088 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -21,6 +21,7 @@ "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", + "@types/react-window": "^1.8.5", "@types/uuid": "^9.0.0", "clipboard-copy": "^4.0.1", "compare-versions": "^4.1.3", @@ -35,9 +36,10 @@ "react-json-tree": "^0.16.2", "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", + "react-window": "^1.8.9", + "slate": "^0.94.1", + "slate-history": "^0.93.0", + "slate-react": "^0.94.2", "typescript": "^4.6.3", "uuid": "^9.0.0", "web-vitals": "^1.0.1", diff --git a/packages/web/src/components/ControlledCustomAutocomplete/Controller.tsx b/packages/web/src/components/ControlledCustomAutocomplete/Controller.tsx new file mode 100644 index 00000000..7f48310e --- /dev/null +++ b/packages/web/src/components/ControlledCustomAutocomplete/Controller.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; +import { Controller as RHFController, useFormContext } from 'react-hook-form'; + +interface ControllerProps { + defaultValue?: string; + name: string; + required?: boolean; + shouldUnregister?: boolean; + children: React.ReactElement; +} + +function Controller( + props: ControllerProps +): React.ReactElement { + const { control } = useFormContext(); + const { + defaultValue = '', + name, + required, + shouldUnregister, + children, + } = props; + + return ( + React.cloneElement(children, { field })} + /> + ); +} + +export default Controller; diff --git a/packages/web/src/components/ControlledCustomAutocomplete/CustomOptions.tsx b/packages/web/src/components/ControlledCustomAutocomplete/CustomOptions.tsx new file mode 100644 index 00000000..4f76e2ae --- /dev/null +++ b/packages/web/src/components/ControlledCustomAutocomplete/CustomOptions.tsx @@ -0,0 +1,96 @@ +import Paper from '@mui/material/Paper'; +import Popper from '@mui/material/Popper'; +import Tab from '@mui/material/Tab'; +import * as React from 'react'; + +import type { IFieldDropdownOption } from '@automatisch/types'; +import Suggestions from 'components/PowerInput/Suggestions'; +import TabPanel from 'components/TabPanel'; + +import Options from './Options'; +import { Tabs } from './style'; + +interface CustomOptionsProps { + open: boolean; + anchorEl: any; + data: any; + options: readonly IFieldDropdownOption[]; + onSuggestionClick: any; + onOptionClick: (event: React.MouseEvent, option: any) => void; + onTabChange: (tabIndex: 0 | 1) => void; + label?: string; + initialTabIndex?: 0 | 1; +}; + +const CustomOptions = (props: CustomOptionsProps) => { + const { + open, + anchorEl, + data, + options = [], + onSuggestionClick, + onOptionClick, + onTabChange, + label, + initialTabIndex, + } = props; + const [activeTabIndex, setActiveTabIndex] = React.useState(undefined); + + React.useEffect(function applyInitialActiveTabIndex() { + setActiveTabIndex((currentActiveTabIndex) => { + if (currentActiveTabIndex === undefined) { + return initialTabIndex; + } + + return currentActiveTabIndex; + }); + }, [initialTabIndex]); + + return ( + + + { + onTabChange(tabIndex); + setActiveTabIndex(tabIndex); + }} + > + + + + + + + + + + + + + + ); +}; + + +export default CustomOptions; diff --git a/packages/web/src/components/ControlledCustomAutocomplete/Options.tsx b/packages/web/src/components/ControlledCustomAutocomplete/Options.tsx new file mode 100644 index 00000000..1084c548 --- /dev/null +++ b/packages/web/src/components/ControlledCustomAutocomplete/Options.tsx @@ -0,0 +1,126 @@ +import type { IFieldDropdownOption } from '@automatisch/types'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import throttle from 'lodash/throttle'; +import * as React from 'react'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; + +import { Typography } from '@mui/material'; +import SearchInput from 'components/SearchInput'; +import useFormatMessage from 'hooks/useFormatMessage'; +import { SearchInputWrapper } from './style'; + +interface OptionsProps { + data: readonly IFieldDropdownOption[]; + onOptionClick: (event: React.MouseEvent, option: any) => void; +}; + +const SHORT_LIST_LENGTH = 4; +const LIST_ITEM_HEIGHT = 64; + +const computeListHeight = (currentLength: number) => { + const numberOfRenderedItems = Math.min(SHORT_LIST_LENGTH, currentLength); + return LIST_ITEM_HEIGHT * numberOfRenderedItems; +} + +const renderItemFactory = ({ onOptionClick }: Pick) => (props: ListChildComponentProps) => { + const { index, style, data } = props; + + const suboption = data[index]; + + return ( + onOptionClick(event, suboption)} + data-test="power-input-suggestion-item" + key={index} + style={style} + > + + + ); +}; + +const Options = (props: OptionsProps) => { + const formatMessage = useFormatMessage(); + const { + data, + onOptionClick + } = props; + const [filteredData, setFilteredData] = React.useState( + data + ); + + React.useEffect(function syncOptions() { + setFilteredData((filteredData) => { + if (filteredData.length === 0 && filteredData.length !== data.length) { + return data; + } + + return filteredData; + }) + }, [data]); + + const renderItem = React.useMemo(() => renderItemFactory({ + onOptionClick + }), [onOptionClick]); + + const onSearchChange = React.useMemo( + () => + throttle((event: React.ChangeEvent) => { + const search = (event.target as HTMLInputElement).value.toLowerCase(); + + if (!search) { + setFilteredData(data); + return; + } + + const newFilteredData = data.filter(option => `${option.label}\n${option.value}`.toLowerCase().includes(search.toLowerCase())); + + setFilteredData(newFilteredData); + }, 400), + [data] +); + + return ( + <> + + + + + + {renderItem} + + + {filteredData.length === 0 && ( + theme.spacing(0, 0, 2, 2) }}> + {formatMessage('customAutocomplete.noOptions')} + + )} + + ); +}; + + +export default Options; diff --git a/packages/web/src/components/ControlledCustomAutocomplete/index.tsx b/packages/web/src/components/ControlledCustomAutocomplete/index.tsx new file mode 100644 index 00000000..6a5a75c8 --- /dev/null +++ b/packages/web/src/components/ControlledCustomAutocomplete/index.tsx @@ -0,0 +1,280 @@ +import * as React from 'react'; +import { useController, useFormContext } from 'react-hook-form'; +import FormHelperText from '@mui/material/FormHelperText'; +import { AutocompleteProps } from '@mui/material/Autocomplete'; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; +import type { IFieldDropdownOption } from '@automatisch/types'; +import { FakeDropdownButton } from './style'; + +import ClickAwayListener from '@mui/base/ClickAwayListener'; +import InputLabel from '@mui/material/InputLabel'; +import { createEditor } from 'slate'; +import { Editable, ReactEditor,} from 'slate-react'; + +import Slate from 'components/Slate'; +import Element from 'components/Slate/Element'; + +import { + serialize, + deserialize, + insertVariable, + customizeEditor, + resetEditor, + overrideEditorValue, + focusEditor, +} from 'components/Slate/utils'; +import { FakeInput, InputLabelWrapper, ChildrenWrapper, } from 'components/PowerInput/style'; +import { VariableElement } from 'components/Slate/types'; +import CustomOptions from './CustomOptions'; +import { processStepWithExecutions } from 'components/PowerInput/data'; +import { StepExecutionsContext } from 'contexts/StepExecutions'; + +interface ControlledCustomAutocompleteProps + extends AutocompleteProps { + showOptionValue?: boolean; + dependsOn?: string[]; + + defaultValue?: string; + name: string; + label?: string; + type?: string; + required?: boolean; + readOnly?: boolean; + description?: string; + docUrl?: string; + clickToCopy?: boolean; + disabled?: boolean; + shouldUnregister?: boolean; +} + +function ControlledCustomAutocomplete( + props: ControlledCustomAutocompleteProps +): React.ReactElement { + const { + defaultValue = '', + name, + label, + required, + options = [], + dependsOn = [], + description, + loading, + disabled, + shouldUnregister, + } = props; + const { control, watch } = useFormContext(); + const { field, fieldState } = useController({ + control, + name, + defaultValue, + rules: { required }, + shouldUnregister, + }); + const { + value, + onChange: controllerOnChange, + onBlur: controllerOnBlur, + } = field; + const [, forceUpdate] = React.useReducer(x => x + 1, 0); + const [isInitialValueSet, setInitialValue] = React.useState(false); + const [isSingleChoice, setSingleChoice] = React.useState(undefined); + const priorStepsWithExecutions = React.useContext(StepExecutionsContext); + const editorRef = React.useRef(null); + const renderElement = React.useCallback( + (props) => , + [disabled] + ); + const [editor] = React.useState(() => customizeEditor(createEditor())); + const [showVariableSuggestions, setShowVariableSuggestions] = + React.useState(false); + + let dependsOnValues: unknown[] = []; + if (dependsOn?.length) { + dependsOnValues = watch(dependsOn); + } + + React.useEffect(() => { + const ref = ReactEditor.toDOMNode(editor, editor); + + resizeObserver.observe(ref); + + return () => resizeObserver.unobserve(ref); + }, []); + + const promoteValue = () => { + const serializedValue = serialize(editor.children); + controllerOnChange(serializedValue); + } + + const resizeObserver = React.useMemo(function syncCustomOptionsPosition() { + return new ResizeObserver(() => { + forceUpdate(); + }) + }, []); + + React.useEffect(() => { + const hasDependencies = dependsOnValues.length; + + if (hasDependencies) { + // Reset the field when a dependent has been updated + resetEditor(editor); + } + }, dependsOnValues); + + React.useEffect(function updateInitialValue() { + const hasOptions = options.length; + const isOptionsLoaded = loading === false; + if (!isInitialValueSet && hasOptions && isOptionsLoaded) { + setInitialValue(true); + + const option: IFieldDropdownOption | undefined = options.find((option) => option.value === value); + + if (option) { + overrideEditorValue(editor, { option, focus: false }); + setSingleChoice(true); + } else if (value) { + setSingleChoice(false); + } + } + }, [isInitialValueSet, options, loading]); + + const hideSuggestionsOnShift = (event: React.KeyboardEvent) => { + if (event.code === 'Tab') { + setShowVariableSuggestions(false); + } + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + hideSuggestionsOnShift(event); + if (event.code === 'Tab') { + promoteValue(); + } + + if (isSingleChoice && event.code !== 'Tab') { + event.preventDefault(); + } + }; + + const stepsWithVariables = React.useMemo(() => { + return processStepWithExecutions(priorStepsWithExecutions); + }, [priorStepsWithExecutions]); + + const handleVariableSuggestionClick = React.useCallback( + (variable: Pick) => { + insertVariable(editor, variable, stepsWithVariables); + }, + [stepsWithVariables] + ); + + const handleOptionClick = React.useCallback( + (event: React.MouseEvent, option: IFieldDropdownOption) => { + event.stopPropagation(); + overrideEditorValue(editor, { option, focus: false }); + + setShowVariableSuggestions(false); + + promoteValue(); + }, + [stepsWithVariables] + ); + + const reset = (tabIndex: 0 | 1) => { + const isOptions = tabIndex === 0; + + setSingleChoice(isOptions); + + resetEditor(editor, { focus: true }); + } + + return ( + + { + promoteValue(); + + setShowVariableSuggestions(false); + }} + > + {/* ref-able single child for ClickAwayListener */} + + { + focusEditor(editor); + }} + > + + + {label} + + + + { + setShowVariableSuggestions(true); + }} + onBlur={() => { + controllerOnBlur(); + }} + /> + + + + + + {/* ghost placer for the variables popover */} +
+ + + + + {fieldState.isTouched + ? fieldState.error?.message || description + : description} + + + + + ); +} + +export default ControlledCustomAutocomplete; diff --git a/packages/web/src/components/ControlledCustomAutocomplete/style.ts b/packages/web/src/components/ControlledCustomAutocomplete/style.ts new file mode 100644 index 00000000..c7617241 --- /dev/null +++ b/packages/web/src/components/ControlledCustomAutocomplete/style.ts @@ -0,0 +1,18 @@ +import { styled } from '@mui/material/styles'; +import MuiIconButton from '@mui/material/IconButton'; +import MuiTabs from '@mui/material/Tabs'; + +export const FakeDropdownButton = styled(MuiIconButton)` + position: absolute; + right: ${({ theme }) => theme.spacing(1)}; + top: 50%; + transform: translateY(-50%); +`; + +export const Tabs = styled(MuiTabs)` + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; +`; + +export const SearchInputWrapper = styled('div')` + padding: ${({ theme }) => theme.spacing(0, 2, 2, 2)}; +`; diff --git a/packages/web/src/components/FlowStep/index.tsx b/packages/web/src/components/FlowStep/index.tsx index 2d81c12f..9c251ffb 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -41,6 +41,7 @@ import { Header, Wrapper, } from './style'; +import isEmpty from 'helpers/isEmpty'; type FlowStepProps = { collapsed?: boolean; @@ -75,7 +76,13 @@ function generateValidationSchema(substeps: ISubstep[]) { if (required) { substepArgumentValidations[key] = substepArgumentValidations[ key - ].required(`${key} is required.`); + ] + .required(`${key} is required.`) + .test( + 'empty-check', + `${key} must be not empty`, + (value: any) => !isEmpty(value), + ); } // if the field depends on another field, add the dependsOn required validation diff --git a/packages/web/src/components/FlowSubstep/index.tsx b/packages/web/src/components/FlowSubstep/index.tsx index 53c97a58..b2c70031 100644 --- a/packages/web/src/components/FlowSubstep/index.tsx +++ b/packages/web/src/components/FlowSubstep/index.tsx @@ -4,7 +4,7 @@ import Collapse from '@mui/material/Collapse'; import ListItem from '@mui/material/ListItem'; import Button from '@mui/material/Button'; import Stack from '@mui/material/Stack'; -import type { IField, IStep, ISubstep } from '@automatisch/types'; +import type { IStep, ISubstep } from '@automatisch/types'; import { EditorContext } from 'contexts/Editor'; import FlowSubstepTitle from 'components/FlowSubstepTitle'; @@ -21,25 +21,6 @@ type FlowSubstepProps = { step: IStep; }; -const validateSubstep = (substep: ISubstep, step: IStep) => { - if (!substep) return true; - - const args: IField[] = substep.arguments || []; - - return args.every((arg) => { - if (arg.required === false) { - return true; - } - - const argValue = step.parameters?.[arg.key]; - - // `false` is an exceptional valid value - if (argValue === false) return true; - - return argValue !== undefined && argValue !== null; - }); -}; - function FlowSubstep(props: FlowSubstepProps): React.ReactElement { const { substep, @@ -54,19 +35,7 @@ function FlowSubstep(props: FlowSubstepProps): React.ReactElement { const editorContext = React.useContext(EditorContext); const formContext = useFormContext(); - const [validationStatus, setValidationStatus] = React.useState< - boolean | null - >(validateSubstep(substep, formContext.getValues() as IStep)); - - React.useEffect(() => { - function validate(step: unknown) { - const validationResult = validateSubstep(substep, step as IStep); - setValidationStatus(validationResult); - } - const subscription = formContext.watch(validate); - - return () => subscription.unsubscribe(); - }, [substep, formContext.watch]); + const validationStatus = formContext.formState.isValid; const onToggle = expanded ? onCollapse : onExpand; diff --git a/packages/web/src/components/InputCreator/index.tsx b/packages/web/src/components/InputCreator/index.tsx index 2556aaf0..f82906f1 100644 --- a/packages/web/src/components/InputCreator/index.tsx +++ b/packages/web/src/components/InputCreator/index.tsx @@ -8,6 +8,7 @@ import useDynamicData from 'hooks/useDynamicData'; import PowerInput from 'components/PowerInput'; import TextField from 'components/TextField'; import ControlledAutocomplete from 'components/ControlledAutocomplete'; +import ControlledCustomAutocomplete from 'components/ControlledCustomAutocomplete'; import DynamicField from 'components/DynamicField'; type InputCreatorProps = { @@ -81,22 +82,44 @@ export default function InputCreator( return ( - } - defaultValue={value as string} - description={description} - loading={loading} - disabled={disabled} - showOptionValue={showOptionValue} - shouldUnregister={shouldUnregister} - /> + {!schema.variables && ( + } + defaultValue={value as string} + description={description} + loading={loading} + disabled={disabled} + showOptionValue={showOptionValue} + shouldUnregister={shouldUnregister} + /> + )} + + {schema.variables && ( + } + defaultValue={value as string} + description={description} + loading={loading} + disabled={disabled} + showOptionValue={showOptionValue} + shouldUnregister={shouldUnregister} + /> + )} {(additionalFieldsLoading && !additionalFields?.length) &&
diff --git a/packages/web/src/components/PowerInput/Popper.tsx b/packages/web/src/components/PowerInput/Popper.tsx new file mode 100644 index 00000000..45285897 --- /dev/null +++ b/packages/web/src/components/PowerInput/Popper.tsx @@ -0,0 +1,61 @@ +import Paper from '@mui/material/Paper'; +import MuiPopper from '@mui/material/Popper'; +import Tab from '@mui/material/Tab'; +import * as React from 'react'; + +import Suggestions from 'components/PowerInput/Suggestions'; +import TabPanel from 'components/TabPanel'; + +import { Tabs } from './style'; + +interface PopperProps { + open: boolean; + anchorEl: any; + data: any; + onSuggestionClick: any; +}; + +const Popper = (props: PopperProps) => { + const { + open, + anchorEl, + data, + onSuggestionClick, + } = props; + + return ( + + + + + + + + + + + + ); +}; + + +export default Popper; diff --git a/packages/web/src/components/PowerInput/Suggestions.tsx b/packages/web/src/components/PowerInput/Suggestions.tsx index 2425852d..462d1cb8 100644 --- a/packages/web/src/components/PowerInput/Suggestions.tsx +++ b/packages/web/src/components/PowerInput/Suggestions.tsx @@ -1,35 +1,99 @@ -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 type { IStep } from '@automatisch/types'; import ExpandLess from '@mui/icons-material/ExpandLess'; import ExpandMore from '@mui/icons-material/ExpandMore'; -import type { IStep } from '@automatisch/types'; +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 ListItemButton from '@mui/material/ListItemButton'; +import ListItemText from '@mui/material/ListItemText'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import throttle from 'lodash/throttle'; +import * as React from 'react'; +import { FixedSizeList, ListChildComponentProps } from 'react-window'; -const ListItemText = styled(MuiListItemText)``; +import SearchInput from 'components/SearchInput'; +import useFormatMessage from 'hooks/useFormatMessage'; type SuggestionsProps = { - data: any[]; + data: { + id: string; + name: string; + output: Record[] + }[]; onSuggestionClick: (variable: any) => void; }; const SHORT_LIST_LENGTH = 4; -const LIST_HEIGHT = 256; +const LIST_ITEM_HEIGHT = 64; + +const computeListHeight = (currentLength: number) => { + const numberOfRenderedItems = Math.min(SHORT_LIST_LENGTH, currentLength); + return LIST_ITEM_HEIGHT * numberOfRenderedItems; +} const getPartialArray = (array: any[], length = array.length) => { return array.slice(0, length); }; +const renderItemFactory = ({ onSuggestionClick }: Pick) => (props: ListChildComponentProps) => { + const { index, style, data } = props; + + const suboption = data[index]; + + return ( + onSuggestionClick(suboption)} + data-test="power-input-suggestion-item" + key={index} + style={style} + > + + + ); +} + const Suggestions = (props: SuggestionsProps) => { - const { data, onSuggestionClick = () => null } = props; + const formatMessage = useFormatMessage(); + const { + data, + onSuggestionClick = () => null + } = props; const [current, setCurrent] = React.useState(0); const [listLength, setListLength] = React.useState(SHORT_LIST_LENGTH); + const [filteredData, setFilteredData] = React.useState( + data + ); + + React.useEffect(function syncOptions() { + setFilteredData((filteredData) => { + if (filteredData.length === 0 && filteredData.length !== data.length) { + return data; + } + + return filteredData; + }) + }, [data]); + + const renderItem = React.useMemo(() => renderItemFactory({ + onSuggestionClick + }), [onSuggestionClick]); const expandList = () => { setListLength(Infinity); @@ -43,79 +107,95 @@ const Suggestions = (props: SuggestionsProps) => { setListLength(SHORT_LIST_LENGTH); }, [current]); - return ( - - - Variables - - - {data.map((option: IStep, index: number) => ( - <> - - setCurrent((currentIndex) => - currentIndex === index ? null : index + const onSearchChange = React.useMemo( + () => + throttle((event: React.ChangeEvent) => { + const search = (event.target as HTMLInputElement).value.toLowerCase(); + + if (!search) { + setFilteredData(data); + return; + } + + const newFilteredData = data + .map((stepWithOutput) => { + return { + id: stepWithOutput.id, + name: stepWithOutput.name, + output: stepWithOutput.output + .filter(option => `${option.label}\n${option.sampleValue}` + .toLowerCase() + .includes(search.toLowerCase()) ) - } - sx={{ py: 0.5 }} - > - + } + }) + .filter((stepWithOutput) => stepWithOutput.output.length); - {!!option.output?.length && - (current === index ? : )} - + setFilteredData(newFilteredData); + }, 400), + [data] + ); - - - {getPartialArray((option.output as any) || [], listLength).map( - (suboption: any, index: number) => ( - onSuggestionClick(suboption)} - data-test="power-input-suggestion-item" - key={`suggestion-${suboption.name}`} - > - - + return ( + + + + + + {filteredData.length > 0 && ( + + {filteredData.map((option: IStep, index: number) => ( + + + setCurrent((currentIndex) => + currentIndex === index ? null : index ) + } + sx={{ py: 0.5 }} + > + + + {!!option.output?.length && + (current === index ? : )} + + + + + {renderItem} + + + {(option.output?.length || 0) > listLength && ( + )} - - {(option.output?.length || 0) > listLength && ( - - )} + {listLength === Infinity && ( + + )} + + + ))} + + )} - {listLength === Infinity && ( - - )} - - - ))} - + {filteredData.length === 0 && ( + theme.spacing(0, 0, 2, 2) }}> + {formatMessage('powerInputSuggestions.noOptions')} + + )} ); }; diff --git a/packages/web/src/components/PowerInput/data.ts b/packages/web/src/components/PowerInput/data.ts index a62606e3..22bb879f 100644 --- a/packages/web/src/components/PowerInput/data.ts +++ b/packages/web/src/components/PowerInput/data.ts @@ -3,38 +3,63 @@ import type { IStep } from '@automatisch/types'; const joinBy = (delimiter = '.', ...args: string[]) => args.filter(Boolean).join(delimiter); -const process = (data: any, parentKey?: any, index?: number): any[] => { +type TProcessPayload = { + data: any; + parentKey: string; + index?: number; + parentLabel?: string; +}; + +const process = ({ data, parentKey, index, parentLabel = '' }: TProcessPayload): any[] => { if (typeof data !== 'object') { return [ { - name: `${parentKey}.${index}`, - value: data, + label: `${parentLabel}.${index}`, + value: `${parentKey}.${index}`, + sampleValue: data, }, ]; } const entries = Object.entries(data); - return entries.flatMap(([name, value]) => { - const fullName = joinBy( + return entries.flatMap(([name, sampleValue]) => { + const label = joinBy( + '.', + parentLabel, + (index as number)?.toString(), + name + ); + + const value = joinBy( '.', parentKey, (index as number)?.toString(), name ); - if (Array.isArray(value)) { - return value.flatMap((item, index) => process(item, fullName, index)); + if (Array.isArray(sampleValue)) { + return sampleValue.flatMap((item, index) => process({ + data: item, + parentKey: value, + index, + parentLabel: label + })); } - if (typeof value === 'object' && value !== null) { - return process(value, fullName); + if (typeof sampleValue === 'object' && sampleValue !== null) { + return process({ + data: sampleValue, + parentKey: value, + parentLabel: label, + }); } return [ { - name: fullName, + label, value, + sampleValue, }, ]; }); @@ -52,12 +77,11 @@ export const processStepWithExecutions = (steps: IStep[]): any[] => { .map((step: IStep, index: number) => ({ id: step.id, // TODO: replace with step.name once introduced - name: `${index + 1}. ${ - (step.appKey || '').charAt(0)?.toUpperCase() + step.appKey?.slice(1) - }`, - output: process( - step.executionSteps?.[0]?.dataOut || {}, - `step.${step.id}` - ), + name: `${index + 1}. ${(step.appKey || '').charAt(0)?.toUpperCase() + step.appKey?.slice(1) + }`, + output: process({ + data: step.executionSteps?.[0]?.dataOut || {}, + parentKey: `step.${step.id}`, + }), })); }; diff --git a/packages/web/src/components/PowerInput/index.tsx b/packages/web/src/components/PowerInput/index.tsx index 150f850a..9322abfd 100644 --- a/packages/web/src/components/PowerInput/index.tsx +++ b/packages/web/src/components/PowerInput/index.tsx @@ -1,25 +1,26 @@ -import * as React from 'react'; import ClickAwayListener from '@mui/base/ClickAwayListener'; -import Chip from '@mui/material/Chip'; -import Popper from '@mui/material/Popper'; -import InputLabel from '@mui/material/InputLabel'; import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import * as React from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { createEditor } from 'slate'; -import { Slate, Editable, useSelected, useFocused } from 'slate-react'; +import { Editable } from 'slate-react'; + +import Slate from 'components/Slate'; +import Element from 'components/Slate/Element'; import { - serialize, + customizeEditor, deserialize, insertVariable, - customizeEditor, -} from './utils'; -import Suggestions from './Suggestions'; + serialize, +} from 'components/Slate/utils'; import { StepExecutionsContext } from 'contexts/StepExecutions'; -import { FakeInput, InputLabelWrapper, ChildrenWrapper } from './style'; -import { VariableElement } from './types'; +import { VariableElement } from 'components/Slate/types'; +import Popper from './Popper'; import { processStepWithExecutions } from './data'; +import { ChildrenWrapper, FakeInput, InputLabelWrapper } from './style'; type PowerInputProps = { onChange?: (value: string) => void; @@ -59,6 +60,12 @@ const PowerInput = (props: PowerInputProps) => { const [showVariableSuggestions, setShowVariableSuggestions] = React.useState(false); + const disappearSuggestionsOnShift = (event: React.KeyboardEvent) => { + if (event.code === 'Tab') { + setShowVariableSuggestions(false); + } + } + const stepsWithVariables = React.useMemo(() => { return processStepWithExecutions(priorStepsWithExecutions); }, [priorStepsWithExecutions]); @@ -93,7 +100,7 @@ const PowerInput = (props: PowerInputProps) => { }) => ( { controllerOnChange(serialize(value)); }} @@ -122,6 +129,7 @@ const PowerInput = (props: PowerInputProps) => { readOnly={disabled} style={{ width: '100%' }} renderElement={renderElement} + onKeyDown={disappearSuggestionsOnShift} onFocus={() => { setShowVariableSuggestions(true); }} @@ -134,14 +142,14 @@ const PowerInput = (props: PowerInputProps) => { {/* ghost placer for the variables popover */}
- {description} - - + + {description} @@ -150,60 +158,4 @@ const PowerInput = (props: PowerInputProps) => { ); }; -const SuggestionsPopper = (props: any) => { - const { open, anchorEl, data, onSuggestionClick } = props; - - return ( - - - - ); -}; - -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 index f918aece..ac8b78ed 100644 --- a/packages/web/src/components/PowerInput/style.ts +++ b/packages/web/src/components/PowerInput/style.ts @@ -1,3 +1,4 @@ +import MuiTabs from '@mui/material/Tabs'; import { styled } from '@mui/material/styles'; export const ChildrenWrapper = styled('div')` @@ -18,27 +19,41 @@ export const FakeInput = styled('div', { shouldForwardProp: (prop) => prop !== 'disabled', }) <{ disabled?: boolean }>` border: 1px solid #eee; - min-height: 52px; + min-height: 56px; width: 100%; display: block; - padding: ${({ theme }) => theme.spacing(0, 1.75)}; + padding: ${({ theme }) => theme.spacing(0, 10, 0, 1.75)}; border-radius: ${({ theme }) => theme.spacing(0.5)}; border-color: rgba(0, 0, 0, 0.23); position: relative; ${({ disabled, theme }) => - !!disabled && - ` - color: ${theme.palette.action.disabled}, - border-color: ${theme.palette.action.disabled}, + !!disabled && ` + color: ${theme.palette.action.disabled}; + border-color: ${theme.palette.action.disabled}; `} &:hover { border-color: ${({ theme }) => theme.palette.text.primary}; } - &:focus-within { - border-color: ${({ theme }) => theme.palette.primary.main}; - border-width: 2px; + &:focus-within, &:focus { + &:before { + border-color: ${({ theme }) => theme.palette.primary.main}; + border-radius: ${({ theme }) => theme.spacing(0.5)}; + border-style: solid; + border-width: 2px; + bottom: -2px; + content: ''; + display: block; + left: -2px; + position: absolute; + right: -2px; + top: -2px; + } } `; + +export const Tabs = styled(MuiTabs)` + border-bottom: 1px solid ${({ theme }) => theme.palette.divider}; +`; diff --git a/packages/web/src/components/PowerInput/utils.ts b/packages/web/src/components/PowerInput/utils.ts deleted file mode 100644 index 1a44cf53..00000000 --- a/packages/web/src/components/PowerInput/utils.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Text, Descendant, Transforms } from 'slate'; -import { withHistory } from 'slate-history'; -import { withReact } from 'slate-react'; - -import type { CustomEditor, CustomElement, VariableElement } from './types'; - -function getStepPosition( - id: string, - stepsWithVariables: Record[] -) { - const stepIndex = stepsWithVariables.findIndex((stepWithVariables) => { - return stepWithVariables.id === id; - }); - - return stepIndex + 1; -} - -function humanizeVariableName( - variableName: string, - stepsWithVariables: Record[] -) { - const nameWithoutCurlies = variableName.replace(/{{|}}/g, ''); - const stepId = nameWithoutCurlies.match(stepIdRegExp)?.[1] || ''; - const stepPosition = getStepPosition(stepId, stepsWithVariables); - const humanizedVariableName = nameWithoutCurlies.replace( - `step.${stepId}.`, - `step${stepPosition}.` - ); - - return humanizedVariableName; -} - -const variableRegExp = /({{.*?}})/; -const stepIdRegExp = /^step.([\da-zA-Z-]*)/; -export const deserialize = ( - value: string, - stepsWithVariables: any[] -): Descendant[] => { - 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: humanizeVariableName(node, stepsWithVariables), - value: node, - 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.value as string; - } - - 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, - stepsWithVariables: Record[] -) => { - const variable: VariableElement = { - type: 'variable', - name: humanizeVariableName(variableData.name as string, stepsWithVariables), - value: `{{${variableData.name}}}`, - 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/components/SearchableJSONViewer/index.tsx b/packages/web/src/components/SearchableJSONViewer/index.tsx index 13195849..f60b1692 100644 --- a/packages/web/src/components/SearchableJSONViewer/index.tsx +++ b/packages/web/src/components/SearchableJSONViewer/index.tsx @@ -1,70 +1,18 @@ import * as React from 'react'; -import get from 'lodash/get'; -import set from 'lodash/set'; import throttle from 'lodash/throttle'; import isEmpty from 'lodash/isEmpty'; -import forIn from 'lodash/forIn'; -import isPlainObject from 'lodash/isPlainObject'; import { Box, Typography } from '@mui/material'; import { IJSONObject } from '@automatisch/types'; import JSONViewer from 'components/JSONViewer'; import SearchInput from 'components/SearchInput'; import useFormatMessage from 'hooks/useFormatMessage'; +import filterObject from 'helpers/filterObject'; type JSONViewerProps = { data: IJSONObject; }; -function aggregate( - data: any, - searchTerm: string, - result = {}, - prefix: string[] = [], - withinArray = false -) { - if (withinArray) { - const containerValue = get(result, prefix, []); - - result = aggregate( - data, - searchTerm, - result, - prefix.concat(containerValue.length.toString()) - ); - - return result; - } - - if (isPlainObject(data)) { - forIn(data, (value, key) => { - const fullKey = [...prefix, key]; - - if (key.toLowerCase().includes(searchTerm)) { - set(result, fullKey, value); - return; - } - - result = aggregate(value, searchTerm, result, fullKey); - }); - } - - if (Array.isArray(data)) { - forIn(data, (value) => { - result = aggregate(value, searchTerm, result, prefix, true); - }); - } - - if ( - ['string', 'number'].includes(typeof data) && - String(data).toLowerCase().includes(searchTerm) - ) { - set(result, prefix, data); - } - - return result; -} - const SearchableJSONViewer = ({ data }: JSONViewerProps) => { const [filteredData, setFilteredData] = React.useState( data @@ -81,7 +29,7 @@ const SearchableJSONViewer = ({ data }: JSONViewerProps) => { return; } - const newFilteredData = aggregate(data, search); + const newFilteredData = filterObject(data, search); if (isEmpty(newFilteredData)) { setFilteredData(null); diff --git a/packages/web/src/components/Slate/Element.tsx b/packages/web/src/components/Slate/Element.tsx new file mode 100644 index 00000000..5139a5a8 --- /dev/null +++ b/packages/web/src/components/Slate/Element.tsx @@ -0,0 +1,12 @@ +import Variable from './Variable'; + +export default function Element(props: any) { + const { attributes, children, element, disabled } = props; + + switch (element.type) { + case 'variable': + return ; + default: + return

{children}

; + } +}; diff --git a/packages/web/src/components/Slate/Variable.tsx b/packages/web/src/components/Slate/Variable.tsx new file mode 100644 index 00000000..8ffc16d7 --- /dev/null +++ b/packages/web/src/components/Slate/Variable.tsx @@ -0,0 +1,28 @@ +import Chip from '@mui/material/Chip'; +import { useSelected, useFocused } from 'slate-react'; + +export default function Variable({ attributes, children, element, disabled }: any) { + const selected = useSelected(); + const focused = useFocused(); + const label = ( + <> + {element.name}: {element.sampleValue} + {children} + + ); + + return ( + + ); +}; + diff --git a/packages/web/src/components/Slate/index.tsx b/packages/web/src/components/Slate/index.tsx new file mode 100644 index 00000000..86c238b9 --- /dev/null +++ b/packages/web/src/components/Slate/index.tsx @@ -0,0 +1,3 @@ +import { Slate } from 'slate-react'; + +export default Slate; diff --git a/packages/web/src/components/PowerInput/types.ts b/packages/web/src/components/Slate/types.ts similarity index 83% rename from packages/web/src/components/PowerInput/types.ts rename to packages/web/src/components/Slate/types.ts index 859730c6..c315865a 100644 --- a/packages/web/src/components/PowerInput/types.ts +++ b/packages/web/src/components/Slate/types.ts @@ -5,14 +5,21 @@ export type VariableElement = { type: 'variable'; value?: unknown; name?: string; + sampleValue?: unknown; children: Text[]; }; export type ParagraphElement = { type: 'paragraph'; + value?: string; children: Descendant[]; }; +export type CustomText = { + text: string; + value: string; +}; + export type CustomEditor = BaseEditor & ReactEditor; export type CustomElement = VariableElement | ParagraphElement; diff --git a/packages/web/src/components/Slate/utils.ts b/packages/web/src/components/Slate/utils.ts new file mode 100644 index 00000000..58a11d7b --- /dev/null +++ b/packages/web/src/components/Slate/utils.ts @@ -0,0 +1,280 @@ +import { Text, Descendant } from 'slate'; +import { withHistory } from 'slate-history'; +import { ReactEditor, withReact } from 'slate-react'; +import { IFieldDropdownOption } from '@automatisch/types'; + +import type { CustomEditor, CustomElement, CustomText, ParagraphElement, VariableElement } from './types'; + +type StepWithVariables = { + id: string; + name: string; + output: { + label: string; + sampleValue: string; + value: string; + }[]; +} + +type StepsWithVariables = StepWithVariables[]; + +function isCustomText(value: any): value is CustomText { + const isText = Text.isText(value); + const hasValueProperty = 'value' in value; + + if (isText && hasValueProperty) return true; + + return false; +} + +function getStepPosition( + id: string, + stepsWithVariables: StepsWithVariables +) { + const stepIndex = stepsWithVariables.findIndex((stepWithVariables) => { + return stepWithVariables.id === id; + }); + + return stepIndex + 1; +} + +function getVariableName(variable: string) { + return variable.replace(/{{|}}/g, ''); +} + +function getVariableStepId(variable: string) { + const nameWithoutCurlies = getVariableName(variable); + const stepId = nameWithoutCurlies.match(stepIdRegExp)?.[1] || ''; + + return stepId; +} + +function getVariableSampleValue(variable: string, stepsWithVariables: StepsWithVariables) { + const variableStepId = getVariableStepId(variable); + const stepWithVariables = stepsWithVariables.find(({ id }: { id: string }) => id === variableStepId); + + if (!stepWithVariables) return null; + + const variableName = getVariableName(variable); + const variableData = stepWithVariables.output.find(({ value }) => variableName === value); + + if (!variableData) return null; + + return variableData.sampleValue; +} + +function getVariableDetails(variable: string, stepsWithVariables: StepsWithVariables) { + const variableName = getVariableName(variable); + const stepId = getVariableStepId(variableName); + const stepPosition = getStepPosition(stepId, stepsWithVariables); + const sampleValue = getVariableSampleValue(variable, stepsWithVariables); + const label = variableName.replace( + `step.${stepId}.`, + `step${stepPosition}.` + ); + + return { + sampleValue, + label, + }; +} + +const variableRegExp = /({{.*?}})/; +const stepIdRegExp = /^step.([\da-zA-Z-]*)/; + +export const deserialize = ( + value: string, + options: readonly IFieldDropdownOption[], + stepsWithVariables: StepsWithVariables +): Descendant[] => { + if (!value) + return [ + { + type: 'paragraph', + children: [{ text: '' }], + }, + ]; + + const selectedNativeOption = options.find((option) => value === option.value); + + if (selectedNativeOption) { + return [ + { + type: 'paragraph', + value: selectedNativeOption.value as string, + children: [{ text: selectedNativeOption.label }], + }, + ]; + } + + 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)) { + const variableDetails = getVariableDetails(node, stepsWithVariables); + + return { + type: 'variable', + name: variableDetails.label, + sampleValue: variableDetails.sampleValue, + value: node, + children: [{ text: '' }], + }; + } + + return { + text: node, + }; + }), + }; + } + + return { + type: 'paragraph', + children: [{ text: line }], + }; + }); +}; + +export const serialize = (value: Descendant[]): string => { + const serializedNodes = value.map((node) => serializeNode(node)); + + const hasSingleNode = value.length === 1; + + /** + * return single serialize node alone so that we don't stringify. + * booleans stay booleans, numbers stay number + */ + if (hasSingleNode) { + return serializedNodes[0]; + } + + const serializedValue = serializedNodes.join('\n'); + return serializedValue; +}; + +const serializeNode = (node: CustomElement | Descendant): string => { + if (isCustomText(node)) return node.value; + + if (Text.isText(node)) { + return node.text; + } + + if (node.type === 'variable') { + return node.value as string; + } + + const hasSingleChild = node.children.length === 1; + + /** + * serialize alone so that we don't stringify. + * booleans stay booleans, numbers stay number + */ + if (hasSingleChild) { + return serializeNode(node.children[0]); + } + + 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: Record, + stepsWithVariables: StepsWithVariables +) => { + const variableDetails = getVariableDetails(`{{${variableData.value}}}`, stepsWithVariables); + + const variable: VariableElement = { + type: 'variable', + name: variableDetails.label, + sampleValue: variableDetails.sampleValue, + value: `{{${variableData.value}}}`, + children: [{ text: '' }], + }; + + editor.insertNodes(variable, { select: false }); + + focusEditor(editor); +}; + +export const focusEditor = (editor: CustomEditor) => { + ReactEditor.focus(editor); + editor.move(); +} + +export const resetEditor = (editor: CustomEditor, options?: { focus: boolean }) => { + const focus = options?.focus || false; + + editor.removeNodes({ + at: { + anchor: editor.start([]), + focus: editor.end([]) + }, + }); + + // `editor.normalize({ force: true })` doesn't add an empty node in the editor + editor.insertNode(createTextNode('')); + + if (focus) { + focusEditor(editor); + } +} + +export const overrideEditorValue = (editor: CustomEditor, options: { option: IFieldDropdownOption, focus: boolean }) => { + const { option, focus } = options; + + const variable: ParagraphElement = { + type: 'paragraph', + children: [ + { + value: option.value as string, + text: option.label as string + } + ], + }; + + editor.withoutNormalizing(() => { + editor.removeNodes({ + at: { + anchor: editor.start([]), + focus: editor.end([]) + }, + }); + + editor.insertNode(variable); + + if (focus) { + focusEditor(editor); + } + }); +}; + +export const createTextNode = (text: string): ParagraphElement => ({ + type: 'paragraph', + children: [ + { + text + } + ] +}); + +export const customizeEditor = (editor: CustomEditor): CustomEditor => { + return withVariables(withReact(withHistory(editor))); +}; diff --git a/packages/web/src/graphql/mutations/create-step.ts b/packages/web/src/graphql/mutations/create-step.ts index 72d1e4c1..4f60ba6c 100644 --- a/packages/web/src/graphql/mutations/create-step.ts +++ b/packages/web/src/graphql/mutations/create-step.ts @@ -8,7 +8,9 @@ export const CREATE_STEP = gql` key appKey parameters + iconUrl position + webhookUrl status connection { id diff --git a/packages/web/src/helpers/filterObject.ts b/packages/web/src/helpers/filterObject.ts new file mode 100644 index 00000000..eb297d78 --- /dev/null +++ b/packages/web/src/helpers/filterObject.ts @@ -0,0 +1,53 @@ +import get from 'lodash/get'; +import set from 'lodash/set'; +import forIn from 'lodash/forIn'; +import isPlainObject from 'lodash/isPlainObject'; + +export default function filterObject( + data: any, + searchTerm: string, + result = {}, + prefix: string[] = [], + withinArray = false +) { + if (withinArray) { + const containerValue = get(result, prefix, []); + + result = filterObject( + data, + searchTerm, + result, + prefix.concat(containerValue.length.toString()) + ); + + return result; + } + + if (isPlainObject(data)) { + forIn(data, (value, key) => { + const fullKey = [...prefix, key]; + + if (key.toLowerCase().includes(searchTerm)) { + set(result, fullKey, value); + return; + } + + result = filterObject(value, searchTerm, result, fullKey); + }); + } + + if (Array.isArray(data)) { + forIn(data, (value) => { + result = filterObject(value, searchTerm, result, prefix, true); + }); + } + + if ( + ['string', 'number'].includes(typeof data) && + String(data).toLowerCase().includes(searchTerm) + ) { + set(result, prefix, data); + } + + return result; +}; diff --git a/packages/web/src/helpers/isEmpty.ts b/packages/web/src/helpers/isEmpty.ts new file mode 100644 index 00000000..2365fc09 --- /dev/null +++ b/packages/web/src/helpers/isEmpty.ts @@ -0,0 +1,16 @@ +import lodashIsEmpty from 'lodash/isEmpty'; + +export default function isEmpty(value: any) { + if (value === undefined && value === null) return true; + + if (Array.isArray(value) || typeof value === 'string') { + return value.length === 0; + } + + if (!Number.isNaN(value)) { + return false; + } + + // covers objects and anything else possibly + return lodashIsEmpty(value); +}; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index 0d0cdfb3..616b18eb 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -162,5 +162,7 @@ "trialBadge.over": "Trial is over", "trialOverAlert.text": "Your free trial is over. Please upgrade your plan to continue using Automatisch.", "checkoutCompletedAlert.text": "Thank you for upgrading your subscription and supporting our self-funded business!", - "subscriptionCancelledAlert.text": "Your subscription is cancelled, but you can continue using Automatisch until {date}." + "subscriptionCancelledAlert.text": "Your subscription is cancelled, but you can continue using Automatisch until {date}.", + "customAutocomplete.noOptions": "No options available.", + "powerInputSuggestions.noOptions": "No options available." } diff --git a/yarn.lock b/yarn.lock index 9f024c8c..14e43ef5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1315,6 +1315,13 @@ core-js-pure "^3.20.2" regenerator-runtime "^0.13.4" +"@babel/runtime@^7.0.0": + version "7.21.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.5.tgz#8492dddda9644ae3bda3b45eabe87382caee7200" + integrity sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q== + dependencies: + regenerator-runtime "^0.13.11" + "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" @@ -2121,6 +2128,11 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@juggle/resize-observer@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@lerna/add@4.0.0": version "4.0.0" resolved "https://registry.yarnpkg.com/@lerna/add/-/add-4.0.0.tgz#c36f57d132502a57b9e7058d1548b7a565ef183f" @@ -4178,6 +4190,13 @@ dependencies: "@types/react" "*" +"@types/react-window@^1.8.5": + version "1.8.5" + resolved "https://registry.yarnpkg.com/@types/react-window/-/react-window-1.8.5.tgz#285fcc5cea703eef78d90f499e1457e9b5c02fc1" + integrity sha512-V9q3CvhC9Jk9bWBOysPGaWy/Z0lxYcTXLtLipkt2cnRj1JOSFNF7wqGpkScSXMgBwC+fnVRg/7shwgddBG5ICw== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@16 || 17", "@types/react@^17.0.0": version "17.0.38" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.38.tgz#f24249fefd89357d5fa71f739a686b8d7c7202bd" @@ -11978,6 +11997,11 @@ memfs@^3.1.2, memfs@^3.2.2: dependencies: fs-monkey "1.0.3" +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + memory-cache@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/memory-cache/-/memory-cache-0.2.0.tgz#7890b01d52c00c8ebc9d533e1f8eb17e3034871a" @@ -14770,6 +14794,14 @@ react-transition-group@^4.4.5: loose-envify "^1.4.0" prop-types "^15.6.2" +react-window@^1.8.9: + version "1.8.9" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8" + integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@^17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037" @@ -15722,18 +15754,19 @@ 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== +slate-history@^0.93.0: + version "0.93.0" + resolved "https://registry.yarnpkg.com/slate-history/-/slate-history-0.93.0.tgz#d2fad47e4e8b262ab7c86b653f5dd6d9b6d85277" + integrity sha512-Gr1GMGPipRuxIz41jD2/rbvzPj8eyar56TVMyJBvBeIpQSSjNISssvGNDYfJlSWM8eaRqf6DAcxMKzsLCYeX6g== 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== +slate-react@^0.94.2: + version "0.94.2" + resolved "https://registry.yarnpkg.com/slate-react/-/slate-react-0.94.2.tgz#3fc70f0212f42a1c417012d7a911f0ec9f6b11fe" + integrity sha512-4wDSuTuGBkdQ609CS55uc2Yhfa5but21usBgAtCVhPJQazL85kzN2vUUYTmGb7d/mpP9tdnJiVPopIyhqlRJ8Q== dependencies: + "@juggle/resize-observer" "^3.4.0" "@types/is-hotkey" "^0.1.1" "@types/lodash" "^4.14.149" direction "^1.0.3" @@ -15743,10 +15776,10 @@ slate-react@^0.72.9: 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== +slate@^0.94.1: + version "0.94.1" + resolved "https://registry.yarnpkg.com/slate/-/slate-0.94.1.tgz#13b0ba7d0a7eeb0ec89a87598e9111cbbd685696" + integrity sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA== dependencies: immer "^9.0.6" is-plain-object "^5.0.0"