diff --git a/packages/backend/src/apps/github/data.ts b/packages/backend/src/apps/github/data.ts index 51d027f9..239cb148 100644 --- a/packages/backend/src/apps/github/data.ts +++ b/packages/backend/src/apps/github/data.ts @@ -1,10 +1,13 @@ import { IJSONObject } from '@automatisch/types'; import ListRepos from './data/list-repos'; +import ListBranches from './data/list-branches'; export default class Data { listRepos: ListRepos; + listBranches: ListBranches; - constructor(connectionData: IJSONObject) { + constructor(connectionData: IJSONObject, parameters: IJSONObject) { this.listRepos = new ListRepos(connectionData); + this.listBranches = new ListBranches(connectionData, parameters); } } diff --git a/packages/backend/src/apps/github/data/list-branches.ts b/packages/backend/src/apps/github/data/list-branches.ts new file mode 100644 index 00000000..f77b6610 --- /dev/null +++ b/packages/backend/src/apps/github/data/list-branches.ts @@ -0,0 +1,36 @@ +import { Octokit } from 'octokit'; +import type { IJSONObject } from '@automatisch/types'; + +import { assignOwnerAndRepo } from '../utils'; + +export default class ListBranches { + client?: Octokit; + repoOwner?: string; + repo?: string; + + constructor(connectionData: IJSONObject, parameters?: IJSONObject) { + if (connectionData.accessToken) { + this.client = new Octokit({ + auth: connectionData.accessToken as string, + }); + } + + assignOwnerAndRepo(this, parameters?.repo as string); + } + + get options() { + return { + owner: this.repoOwner, + repo: this.repo, + }; + } + + async run() { + const branches = await this.client.paginate(this.client.rest.repos.listBranches, this.options); + + return branches.map((branch) => ({ + value: branch.name, + name: branch.name, + })); + } +} diff --git a/packages/backend/src/apps/github/index.ts b/packages/backend/src/apps/github/index.ts index e52de52b..72f6b28e 100644 --- a/packages/backend/src/apps/github/index.ts +++ b/packages/backend/src/apps/github/index.ts @@ -19,7 +19,7 @@ export default class Github implements IService { parameters: IJSONObject ) { this.authenticationClient = new Authentication(appData, connectionData); - this.data = new Data(connectionData); + this.data = new Data(connectionData, parameters); this.triggers = new Triggers(connectionData, parameters); } } diff --git a/packages/backend/src/apps/github/info.json b/packages/backend/src/apps/github/info.json index bdb3659a..a86d89d1 100644 --- a/packages/backend/src/apps/github/info.json +++ b/packages/backend/src/apps/github/info.json @@ -599,6 +599,68 @@ "name": "Test trigger" } ] + }, + { + "name": "New commit", + "key": "newCommit", + "interval": "15m", + "description": "Triggers when a new commit is created", + "substeps": [ + { + "key": "chooseAccount", + "name": "Choose account" + }, + { + "key": "chooseTrigger", + "name": "Set up a trigger", + "arguments": [ + { + "label": "Repo", + "key": "repo", + "type": "dropdown", + "required": true, + "variables": false, + "source": { + "type": "query", + "name": "getData", + "arguments": [ + { + "name": "key", + "value": "listRepos" + } + ] + } + }, + { + "label": "Head", + "key": "head", + "type": "dropdown", + "description": "Branch to pull commits from. If unspecified, will use the repository's default branch (usually main or develop).", + "required": false, + "variables": false, + "dependsOn": ["parameters.repo"], + "source": { + "type": "query", + "name": "getData", + "arguments": [ + { + "name": "key", + "value": "listBranches" + }, + { + "name": "parameters.repo", + "value": "{parameters.repo}" + } + ] + } + } + ] + }, + { + "key": "testStep", + "name": "Test trigger" + } + ] } ] } diff --git a/packages/backend/src/apps/github/triggers.ts b/packages/backend/src/apps/github/triggers.ts index 55b8efbf..a8e9c467 100644 --- a/packages/backend/src/apps/github/triggers.ts +++ b/packages/backend/src/apps/github/triggers.ts @@ -6,6 +6,7 @@ import NewNotification from './triggers/new-notification'; import NewPullRequest from './triggers/new-pull-request'; import NewWatcher from './triggers/new-watcher'; import NewMilestone from './triggers/new-milestone'; +import NewCommit from './triggers/new-commit'; import NewCommitComment from './triggers/new-commit-comment'; import NewLabel from './triggers/new-label'; import NewCollaborator from './triggers/new-collaborator'; @@ -19,6 +20,7 @@ export default class Triggers { newPullRequest: NewPullRequest; newWatcher: NewWatcher; newMilestone: NewMilestone; + newCommit: NewCommit; newCommitComment: NewCommitComment; newLabel: NewLabel; newCollaborator: NewCollaborator; @@ -32,6 +34,7 @@ export default class Triggers { this.newPullRequest = new NewPullRequest(connectionData, parameters); this.newWatcher = new NewWatcher(connectionData, parameters); this.newMilestone = new NewMilestone(connectionData, parameters); + this.newCommit = new NewCommit(connectionData, parameters); this.newCommitComment = new NewCommitComment(connectionData, parameters); this.newLabel = new NewLabel(connectionData, parameters); this.newCollaborator = new NewCollaborator(connectionData, parameters); diff --git a/packages/backend/src/apps/github/triggers/new-branch.ts b/packages/backend/src/apps/github/triggers/new-branch.ts index da3cfdb2..d434877d 100644 --- a/packages/backend/src/apps/github/triggers/new-branch.ts +++ b/packages/backend/src/apps/github/triggers/new-branch.ts @@ -1,6 +1,8 @@ import { Octokit } from 'octokit'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewBranch { client?: Octokit; repoOwner?: string; @@ -13,12 +15,7 @@ export default class NewBranch { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/triggers/new-collaborator.ts b/packages/backend/src/apps/github/triggers/new-collaborator.ts index b6b5888a..76329324 100644 --- a/packages/backend/src/apps/github/triggers/new-collaborator.ts +++ b/packages/backend/src/apps/github/triggers/new-collaborator.ts @@ -1,6 +1,8 @@ import { Octokit } from 'octokit'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewCollaborator { client?: Octokit; repoOwner?: string; @@ -13,12 +15,7 @@ export default class NewCollaborator { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/triggers/new-commit-comment.ts b/packages/backend/src/apps/github/triggers/new-commit-comment.ts index b21e479b..73f8d757 100644 --- a/packages/backend/src/apps/github/triggers/new-commit-comment.ts +++ b/packages/backend/src/apps/github/triggers/new-commit-comment.ts @@ -2,6 +2,8 @@ import { Octokit } from 'octokit'; import { DateTime } from 'luxon'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewCommitComment { client?: Octokit; repoOwner?: string; @@ -14,12 +16,7 @@ export default class NewCommitComment { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/triggers/new-commit.ts b/packages/backend/src/apps/github/triggers/new-commit.ts new file mode 100644 index 00000000..aeec4f3f --- /dev/null +++ b/packages/backend/src/apps/github/triggers/new-commit.ts @@ -0,0 +1,59 @@ +import { Octokit } from 'octokit'; +import { DateTime } from 'luxon'; +import { IJSONObject } from '@automatisch/types'; + +import { assignOwnerAndRepo } from '../utils'; + +export default class NewCommit { + client?: Octokit; + repoOwner?: string; + repo?: string; + head?: string; + + constructor(connectionData: IJSONObject, parameters: IJSONObject) { + if (connectionData.accessToken) { + this.client = new Octokit({ + auth: connectionData.accessToken as string, + }); + } + + if (parameters?.head) { + this.head = parameters.head as string; + } + + assignOwnerAndRepo(this, parameters?.repo as string); + } + + get options() { + const options = { + owner: this.repoOwner, + repo: this.repo, + }; + + if (this.head) { + return { + ...options, + sha: this.head, + }; + } + + return options; + } + + async run(startTime: Date) { + const options = { + ...this.options, + since: DateTime.fromJSDate(startTime).toISO(), + }; + return await this.client.paginate(this.client.rest.repos.listCommits, options); + } + + async testRun() { + const options = { + ...this.options, + per_page: 1, + }; + + return (await this.client.rest.repos.listCommits(options)).data; + } +} diff --git a/packages/backend/src/apps/github/triggers/new-label.ts b/packages/backend/src/apps/github/triggers/new-label.ts index 21fa61f8..061c0bb7 100644 --- a/packages/backend/src/apps/github/triggers/new-label.ts +++ b/packages/backend/src/apps/github/triggers/new-label.ts @@ -1,6 +1,8 @@ import { Octokit } from 'octokit'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewLabel { client?: Octokit; repoOwner?: string; @@ -13,12 +15,7 @@ export default class NewLabel { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/triggers/new-milestone.ts b/packages/backend/src/apps/github/triggers/new-milestone.ts index 946853bd..a8c017a7 100644 --- a/packages/backend/src/apps/github/triggers/new-milestone.ts +++ b/packages/backend/src/apps/github/triggers/new-milestone.ts @@ -2,6 +2,8 @@ import { Octokit } from 'octokit'; import { DateTime } from 'luxon'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewMilestone { client?: Octokit; repoOwner?: string; @@ -14,12 +16,7 @@ export default class NewMilestone { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/triggers/new-notification.ts b/packages/backend/src/apps/github/triggers/new-notification.ts index 7009be99..00f496fa 100644 --- a/packages/backend/src/apps/github/triggers/new-notification.ts +++ b/packages/backend/src/apps/github/triggers/new-notification.ts @@ -2,6 +2,8 @@ import { Octokit } from 'octokit'; import { DateTime } from 'luxon'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewNotification { client?: Octokit; connectionData?: IJSONObject; @@ -19,12 +21,7 @@ export default class NewNotification { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get hasRepo() { diff --git a/packages/backend/src/apps/github/triggers/new-pull-request.ts b/packages/backend/src/apps/github/triggers/new-pull-request.ts index 4bf907c8..103ea880 100644 --- a/packages/backend/src/apps/github/triggers/new-pull-request.ts +++ b/packages/backend/src/apps/github/triggers/new-pull-request.ts @@ -2,6 +2,8 @@ import { Octokit } from 'octokit'; import { DateTime } from 'luxon'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewPullRequest { client?: Octokit; repoOwner?: string; @@ -14,12 +16,7 @@ export default class NewPullRequest { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/triggers/new-release.ts b/packages/backend/src/apps/github/triggers/new-release.ts index 1c5fb1f5..1626b2e1 100644 --- a/packages/backend/src/apps/github/triggers/new-release.ts +++ b/packages/backend/src/apps/github/triggers/new-release.ts @@ -2,6 +2,8 @@ import { Octokit } from 'octokit'; import { DateTime } from 'luxon'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewRelease { client?: Octokit; repoOwner?: string; @@ -14,12 +16,7 @@ export default class NewRelease { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = 'facebook' || owner; - this.repo = 'react' || repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/triggers/new-watcher.ts b/packages/backend/src/apps/github/triggers/new-watcher.ts index bba1dd1d..3a6e2f20 100644 --- a/packages/backend/src/apps/github/triggers/new-watcher.ts +++ b/packages/backend/src/apps/github/triggers/new-watcher.ts @@ -1,6 +1,8 @@ import { Octokit } from 'octokit'; import { IJSONObject } from '@automatisch/types'; +import { assignOwnerAndRepo } from '../utils'; + export default class NewWatcher { client?: Octokit; repoOwner?: string; @@ -13,12 +15,7 @@ export default class NewWatcher { }); } - if (parameters?.repo) { - const [owner, repo] = (parameters.repo as string).split('/'); - - this.repoOwner = owner; - this.repo = repo; - } + assignOwnerAndRepo(this, parameters?.repo as string); } get options() { diff --git a/packages/backend/src/apps/github/utils.ts b/packages/backend/src/apps/github/utils.ts new file mode 100644 index 00000000..b4de8bc7 --- /dev/null +++ b/packages/backend/src/apps/github/utils.ts @@ -0,0 +1,9 @@ +export function assignOwnerAndRepo(object: T, repoFullName: string): T { + if (object && repoFullName) { + const [repoOwner, repo] = repoFullName.split('/'); + object.repoOwner = repoOwner; + object.repo = repo; + } + + return object; +} diff --git a/packages/backend/src/graphql/queries/get-data.ts b/packages/backend/src/graphql/queries/get-data.ts index 89379b1e..938fb639 100644 --- a/packages/backend/src/graphql/queries/get-data.ts +++ b/packages/backend/src/graphql/queries/get-data.ts @@ -1,9 +1,11 @@ +import { IJSONObject } from '@automatisch/types'; import App from '../../models/app'; import Context from '../../types/express/context'; type Params = { stepId: string; key: string; + parameters: IJSONObject; }; const getData = async (_parent: unknown, params: Params, context: Context) => { @@ -21,7 +23,7 @@ const getData = async (_parent: unknown, params: Params, context: Context) => { const appData = App.findOneByKey(step.appKey); const AppClass = (await import(`../../apps/${step.appKey}`)).default; - const appInstance = new AppClass(appData, connection.formattedData); + const appInstance = new AppClass(appData, connection.formattedData, params.parameters); const command = appInstance.data[params.key]; const fetchedData = await command.run(); diff --git a/packages/backend/src/graphql/schema.graphql b/packages/backend/src/graphql/schema.graphql index 364bd233..037d443f 100644 --- a/packages/backend/src/graphql/schema.graphql +++ b/packages/backend/src/graphql/schema.graphql @@ -13,7 +13,7 @@ type Query { limit: Int! offset: Int! ): ExecutionStepConnection - getData(stepId: String!, key: String!): JSONObject + getData(stepId: String!, key: String!, parameters: JSONObject): JSONObject getCurrentUser: User } @@ -67,6 +67,7 @@ type ActionSubstepArgument { required: Boolean variables: Boolean source: ActionSubstepArgumentSource + dependsOn: [String] } type ActionSubstepArgumentSource { @@ -371,6 +372,7 @@ type TriggerSubstepArgument { required: Boolean variables: Boolean source: TriggerSubstepArgumentSource + dependsOn: [String] } type TriggerSubstepArgumentSource { diff --git a/packages/types/index.d.ts b/packages/types/index.d.ts index 1e26b71d..9ed88d86 100644 --- a/packages/types/index.d.ts +++ b/packages/types/index.d.ts @@ -85,6 +85,7 @@ export interface IFieldDropdown { clickToCopy: boolean; name: string; variables: boolean; + dependsOn: string[]; source: { type: string; name: string; @@ -108,6 +109,7 @@ export interface IFieldText { clickToCopy: boolean; name: string; variables: boolean; + dependsOn: string[]; } type IField = IFieldDropdown | IFieldText; diff --git a/packages/web/package.json b/packages/web/package.json index 72b2a3b6..bb4bfcea 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -15,14 +15,14 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@types/jest": "^26.0.15", - "@types/lodash.template": "^4.5.0", + "@types/lodash": "^4.14.182", "@types/luxon": "^2.0.8", "@types/node": "^12.0.0", "@types/react": "^17.0.0", "@types/react-dom": "^17.0.0", "clipboard-copy": "^4.0.1", "graphql": "^15.6.0", - "lodash.template": "^4.5.0", + "lodash": "^4.17.21", "luxon": "^2.3.1", "notistack": "^2.0.2", "react": "^17.0.2", diff --git a/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx b/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx index 6b53a0b7..bc61c3c2 100644 --- a/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx +++ b/packages/web/src/components/ChooseAppAndEventSubstep/index.tsx @@ -88,6 +88,7 @@ function ChooseAppAndEventSubstep(props: ChooseAppAndEventSubstepProps): React.R ...step, key: '', appKey, + parameters: {}, }, }); } diff --git a/packages/web/src/components/ControlledAutocomplete/index.tsx b/packages/web/src/components/ControlledAutocomplete/index.tsx index e2b965dd..bf4409c4 100644 --- a/packages/web/src/components/ControlledAutocomplete/index.tsx +++ b/packages/web/src/components/ControlledAutocomplete/index.tsx @@ -15,7 +15,7 @@ type Option = { value: string; } -const getOption = (options: readonly Option[], value: string) => options.find(option => option.value === value); +const getOption = (options: readonly Option[], value: string) => options.find(option => option.value === value) || null; function ControlledAutocomplete(props: ControlledAutocompleteProps): React.ReactElement { const { control } = useFormContext(); @@ -28,11 +28,10 @@ function ControlledAutocomplete(props: ControlledAutocompleteProps): React.React onBlur, onChange, description, + options = [], ...autocompleteProps } = props; - if (!autocompleteProps.options) return (); - return ( ( + render={({ field: { ref, onChange: controllerOnChange, onBlur: controllerOnBlur, ...field }, fieldState }) => (
{/* encapsulated with an element such as div to vertical spacing delegated from parent */} { const typedSelectedOption = selectedOption as Option; if (typedSelectedOption?.value) { @@ -63,8 +63,9 @@ function ControlledAutocomplete(props: ControlledAutocompleteProps): React.React - {description} + {fieldState.isTouched ? fieldState.error?.message || description : description}
)} diff --git a/packages/web/src/components/FlowStep/index.tsx b/packages/web/src/components/FlowStep/index.tsx index a3662943..f0c5888e 100644 --- a/packages/web/src/components/FlowStep/index.tsx +++ b/packages/web/src/components/FlowStep/index.tsx @@ -10,7 +10,10 @@ import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import IconButton from '@mui/material/IconButton'; import ErrorIcon from '@mui/icons-material/Error'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; -import type { IApp, IField, IStep } from '@automatisch/types'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import type { BaseSchema } from 'yup'; +import type { IApp, IField, IStep, ISubstep } from '@automatisch/types'; import { StepExecutionsProvider } from 'contexts/StepExecutions'; import TestSubstep from 'components/TestSubstep'; @@ -43,10 +46,57 @@ type FlowStepProps = { const validIcon = ; const errorIcon = ; +function generateValidationSchema(substeps: ISubstep[]) { + const fieldValidations = substeps?.reduce((allValidations, { arguments: args }) => { + if (!args || !Array.isArray(args)) return allValidations; + + const substepArgumentValidations: Record = {}; + + for (const arg of args) { + const { key, required, dependsOn } = arg; + + // base validation for the field if not exists + if (!substepArgumentValidations[key]) { + substepArgumentValidations[key] = yup.string(); + } + + if (typeof substepArgumentValidations[key] === 'object') { + // if the field is required, add the required validation + if (required) { + substepArgumentValidations[key] = substepArgumentValidations[key].required(`${key} is required.`); + } + + // if the field depends on another field, add the dependsOn required validation + if (dependsOn?.length > 0) { + for (const dependsOnKey of dependsOn) { + // TODO: make `dependsOnKey` agnostic to the field. However, nested validation schema is not supported. + // So the fields under the `parameters` key are subject to their siblings only and thus, `parameters.` is removed. + substepArgumentValidations[key] = substepArgumentValidations[key].when(`${dependsOnKey.replace('parameters.', '')}`, { + is: (value: string) => Boolean(value) === false, + then: (schema) => schema.required(`We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`), + }); + } + } + } + } + + return { + ...allValidations, + ...substepArgumentValidations, + } + }, {}); + + const validationSchema = yup.object({ + parameters: yup.object(fieldValidations), + }); + + return yupResolver(validationSchema); +}; + export default function FlowStep( props: FlowStepProps ): React.ReactElement | null { - const { collapsed, index, onChange } = props; + const { collapsed, onChange } = props; const contextButtonRef = React.useRef(null); const step: IStep = props.step; const [anchorEl, setAnchorEl] = React.useState( @@ -103,6 +153,8 @@ export default function FlowStep( handleChange({ step: val as IStep }); }; + const stepValidationSchema = React.useMemo(() => generateValidationSchema(substeps), [substeps]); + if (!apps) return null; const onContextMenuClose = (event: React.SyntheticEvent) => { @@ -171,7 +223,11 @@ export default function FlowStep( stepWithTestExecutionsData?.getStepWithTestExecutions as IStep[] } > -
+ { + methods.trigger(); + }, [methods.trigger, form]); + React.useEffect(() => { methods.reset(defaultValues); }, [defaultValues]); diff --git a/packages/web/src/components/InputCreator/index.tsx b/packages/web/src/components/InputCreator/index.tsx index f93e2ce2..bd8e984d 100644 --- a/packages/web/src/components/InputCreator/index.tsx +++ b/packages/web/src/components/InputCreator/index.tsx @@ -3,6 +3,7 @@ import { useLazyQuery } from '@apollo/client'; import MuiTextField from '@mui/material/TextField'; import type { IField, IFieldDropdown, IJSONObject } from '@automatisch/types'; +import useDynamicData from 'hooks/useDynamicData'; import { GET_DATA } from 'graphql/queries/get-data'; import PowerInput from 'components/PowerInput'; import TextField from 'components/TextField'; @@ -26,7 +27,6 @@ type Option = { value: string; }; -const computeArguments = (args: IFieldDropdown["source"]["arguments"]): IJSONObject => args.reduce((result, { name, value }) => ({ ...result, [name as string]: value as string }), {}); const optionGenerator = (options: RawOption[]): Option[] => options?.map(({ name, value }) => ({ label: name as string, value: value as string })); const getOption = (options: Option[], value: string) => options?.find(option => option.value === value); @@ -51,24 +51,11 @@ export default function InputCreator(props: InputCreatorProps): React.ReactEleme type, } = schema; - const [getData, { called, data }] = useLazyQuery(GET_DATA); - - React.useEffect(() => { - if (schema.type === 'dropdown' && stepId && schema.source && !called) { - getData({ - variables: { - stepId, - ...computeArguments(schema.source.arguments), - } - }) - } - }, [getData, called, stepId, schema]); - - + const { data, loading } = useDynamicData(stepId, schema); const computedName = namePrefix ? `${namePrefix}.${name}` : name; if (type === 'dropdown') { - const options = optionGenerator(data?.getData); + const options = optionGenerator(data); return ( ); } diff --git a/packages/web/src/graphql/queries/get-apps.ts b/packages/web/src/graphql/queries/get-apps.ts index 7e9758ef..5dd30a60 100644 --- a/packages/web/src/graphql/queries/get-apps.ts +++ b/packages/web/src/graphql/queries/get-apps.ts @@ -63,6 +63,7 @@ export const GET_APPS = gql` required description variables + dependsOn source { type name @@ -88,6 +89,7 @@ export const GET_APPS = gql` required description variables + dependsOn source { type name diff --git a/packages/web/src/graphql/queries/get-data.ts b/packages/web/src/graphql/queries/get-data.ts index 0eaae91c..4e2b27db 100644 --- a/packages/web/src/graphql/queries/get-data.ts +++ b/packages/web/src/graphql/queries/get-data.ts @@ -1,7 +1,7 @@ import { gql } from '@apollo/client'; export const GET_DATA = gql` - query GetData($stepId: String!, $key: String!) { - getData(stepId: $stepId, key: $key) + query GetData($stepId: String!, $key: String!, $parameters: JSONObject) { + getData(stepId: $stepId, key: $key, parameters: $parameters) } `; diff --git a/packages/web/src/helpers/computeAuthStepVariables.ts b/packages/web/src/helpers/computeAuthStepVariables.ts index 9d572e21..94312983 100644 --- a/packages/web/src/helpers/computeAuthStepVariables.ts +++ b/packages/web/src/helpers/computeAuthStepVariables.ts @@ -1,4 +1,4 @@ -import template from 'lodash.template'; +import template from 'lodash/template'; import type { IAuthenticationStepField, IJSONObject } from '@automatisch/types'; const interpolate = /{([\s\S]+?)}/g; diff --git a/packages/web/src/helpers/computeVariables.ts b/packages/web/src/helpers/computeVariables.ts new file mode 100644 index 00000000..94312983 --- /dev/null +++ b/packages/web/src/helpers/computeVariables.ts @@ -0,0 +1,32 @@ +import template from 'lodash/template'; +import type { IAuthenticationStepField, IJSONObject } from '@automatisch/types'; + +const interpolate = /{([\s\S]+?)}/g; + +type Variables = { + [key: string]: any +} + +type IVariable = Omit & Partial>; + +const computeAuthStepVariables = (variableSchema: IVariable[], aggregatedData: IJSONObject): IJSONObject => { + const variables: Variables = {}; + + for (const variable of variableSchema) { + if (variable.properties) { + variables[variable.name] = computeAuthStepVariables(variable.properties, aggregatedData); + + continue; + } + + if (variable.value) { + const computedVariable = template(variable.value, { interpolate })(aggregatedData); + + variables[variable.name] = computedVariable; + } + } + + return variables; +}; + +export default computeAuthStepVariables; diff --git a/packages/web/src/hooks/useDynamicData.ts b/packages/web/src/hooks/useDynamicData.ts new file mode 100644 index 00000000..f5375a7c --- /dev/null +++ b/packages/web/src/hooks/useDynamicData.ts @@ -0,0 +1,101 @@ +import * as React from 'react'; +import { useLazyQuery } from '@apollo/client'; +import { useFormContext } from 'react-hook-form'; +import set from 'lodash/set'; +import type { UseFormReturn } from 'react-hook-form'; +import isEqual from 'lodash/isEqual'; +import type { IField, IFieldDropdown, IJSONObject } from '@automatisch/types'; + +import { GET_DATA } from 'graphql/queries/get-data'; + +const variableRegExp = /({.*?})/g; + +function computeArguments(args: IFieldDropdown["source"]["arguments"], getValues: UseFormReturn["getValues"]): IJSONObject { + const initialValue = {}; + return args.reduce( + (result, { name, value }) => { + const isVariable = variableRegExp.test(value); + + if (isVariable) { + const sanitizedFieldPath = value.replace(/{|}/g, ''); + const computedValue = getValues(sanitizedFieldPath); + + if (!computedValue) throw new Error(`The ${sanitizedFieldPath} field is required.`); + + set(result, name, computedValue); + + return result; + }; + + set(result, name, value); + + return result; + }, + initialValue + ); +}; + + +/** + * Fetch the dynamic data for the given step. + * This hook must be within a react-hook-form context. + * + * @param stepId - the id of the step + * @param schema - the field that needs the dynamic data + */ +function useDynamicData(stepId: string | undefined, schema: IField) { + const lastComputedVariables = React.useRef({}); + const [getData, { called, data, loading }] = useLazyQuery(GET_DATA); + const { getValues } = useFormContext(); + const formValues = getValues(); + + /** + * Return `null` when even a field is missing value. + * + * This must return the same reference if no computed variable is changed. + * Otherwise, it causes redundant network request! + */ + const computedVariables = React.useMemo(() => { + if (schema.type === 'dropdown' && schema.source) { + try { + const variables = computeArguments(schema.source.arguments, getValues); + + // if computed variables are the same, return the last computed variables. + if (isEqual(variables, lastComputedVariables.current)) { + return lastComputedVariables.current; + } + + lastComputedVariables.current = variables; + + return variables; + } catch (err) { + return null; + } + } + + return null; + /** + * `formValues` is to trigger recomputation when form is updated. + * `getValues` is for convenience as it supports paths for fields like `getValues('foo.bar.baz')`. + */ + }, [schema, formValues, getValues]); + + React.useEffect(() => { + if (schema.type === 'dropdown' && stepId && schema.source && computedVariables) { + getData({ + variables: { + stepId, + ...computedVariables, + } + }) + } + }, [getData, stepId, schema, computedVariables]); + + return { + called, + data: data?.getData, + loading, + }; +} + +export default useDynamicData; diff --git a/packages/web/src/pages/ProfileSettings/index.tsx b/packages/web/src/pages/ProfileSettings/index.tsx index 51c93f77..7729e9f2 100644 --- a/packages/web/src/pages/ProfileSettings/index.tsx +++ b/packages/web/src/pages/ProfileSettings/index.tsx @@ -5,7 +5,7 @@ import Grid from '@mui/material/Grid'; import Button from '@mui/material/Button'; import { useSnackbar } from 'notistack'; import { yupResolver } from '@hookform/resolvers/yup'; -import * as yup from "yup"; +import * as yup from 'yup'; import PageTitle from 'components/PageTitle'; import Container from 'components/Container'; diff --git a/yarn.lock b/yarn.lock index 0cb04435..dabc0399 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4588,13 +4588,6 @@ dependencies: "@types/lodash" "*" -"@types/lodash.template@^4.5.0": - version "4.5.0" - resolved "https://registry.yarnpkg.com/@types/lodash.template/-/lodash.template-4.5.0.tgz#277654af717ed37ce2687c69f8f221c550276b7a" - integrity sha512-4LgHxK16IPbGR7TmXpPvNT7iNGsLCdQY6Rc0mi1a/JECt8et/D4hx6NMVAJej/d932sj1mJsg0QYHKL189O0Qw== - dependencies: - "@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" @@ -4605,6 +4598,11 @@ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.181.tgz#d1d3740c379fda17ab175165ba04e2d03389385d" integrity sha512-n3tyKthHJbkiWhDZs3DkhkCzt2MexYHXlX0td5iMplyfwketaOeKboEVBqzceH7juqvEg3q5oUoBFxSLu7zFag== +"@types/lodash@^4.14.182": + version "4.14.182" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" + integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q== + "@types/lru-cache@^5.1.0": version "5.1.1" resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef"