Compare commits
	
		
			3 Commits
		
	
	
		
			AUT-157-AU
			...
			AUT-1024
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 6062cfafaf | ||
|   | 5263e774d2 | ||
|   | 22b4a04567 | 
							
								
								
									
										7
									
								
								packages/backend/src/apps/eventbrite/assets/favicon.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/backend/src/apps/eventbrite/assets/favicon.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"> | ||||
| 		<g> | ||||
| 				<circle fill="#F05537" cx="128" cy="128" r="128"></circle> | ||||
| 				<path d="M117.475323,82.7290398 C136.772428,78.4407943 156.069532,86.3025777 166.790146,101.311437 L81.5017079,120.608542 C84.3605382,102.26438 98.1782181,87.0172853 117.475323,82.7290398 Z M167.266618,153.48509 C160.596014,163.252761 150.351872,170.161601 138.678314,172.782195 C119.38121,177.070441 99.8458692,169.208657 89.1252554,153.961562 L174.651929,134.664457 L188.469609,131.567391 L215.152026,125.611495 C214.91379,119.893834 214.199082,114.176173 213.007903,108.696749 C202.287289,62.7172275 155.354825,33.8906884 108.42236,44.6113021 C61.4898956,55.3319159 32.1868848,101.073201 43.1457344,147.290958 C54.1045839,193.508715 100.798813,222.097018 147.731277,211.376404 C175.366637,205.182272 196.807864,186.599875 207.766714,163.014525 L167.266618,153.48509 L167.266618,153.48509 Z" fill="#FFFFFF" fill-rule="nonzero"></path> | ||||
| 		</g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
| @@ -0,0 +1,19 @@ | ||||
| import { URLSearchParams } from 'url'; | ||||
|  | ||||
| export default async function generateAuthUrl($) { | ||||
|   const oauthRedirectUrlField = $.app.auth.fields.find( | ||||
|     (field) => field.key == 'oAuthRedirectUrl' | ||||
|   ); | ||||
|   const redirectUri = oauthRedirectUrlField.value; | ||||
|   const searchParams = new URLSearchParams({ | ||||
|     response_type: 'code', | ||||
|     client_id: $.auth.data.clientId, | ||||
|     redirect_uri: redirectUri, | ||||
|   }); | ||||
|  | ||||
|   const url = `https://www.eventbrite.com/oauth/authorize?${searchParams.toString()}`; | ||||
|  | ||||
|   await $.auth.set({ | ||||
|     url, | ||||
|   }); | ||||
| } | ||||
							
								
								
									
										46
									
								
								packages/backend/src/apps/eventbrite/auth/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								packages/backend/src/apps/eventbrite/auth/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import generateAuthUrl from './generate-auth-url.js'; | ||||
| import verifyCredentials from './verify-credentials.js'; | ||||
| import isStillVerified from './is-still-verified.js'; | ||||
|  | ||||
| export default { | ||||
|   fields: [ | ||||
|     { | ||||
|       key: 'oAuthRedirectUrl', | ||||
|       label: 'OAuth Redirect URL', | ||||
|       type: 'string', | ||||
|       required: true, | ||||
|       readOnly: true, | ||||
|       value: '{WEB_APP_URL}/app/eventbrite/connections/add', | ||||
|       placeholder: null, | ||||
|       description: | ||||
|         'When asked to input a redirect URL in Eventbrite, enter the URL above.', | ||||
|       clickToCopy: true, | ||||
|     }, | ||||
|     { | ||||
|       key: 'clientId', | ||||
|       label: 'API Key', | ||||
|       type: 'string', | ||||
|       required: true, | ||||
|       readOnly: false, | ||||
|       value: null, | ||||
|       placeholder: null, | ||||
|       description: null, | ||||
|       clickToCopy: false, | ||||
|     }, | ||||
|     { | ||||
|       key: 'clientSecret', | ||||
|       label: 'Client Secret', | ||||
|       type: 'string', | ||||
|       required: true, | ||||
|       readOnly: false, | ||||
|       value: null, | ||||
|       placeholder: null, | ||||
|       description: null, | ||||
|       clickToCopy: false, | ||||
|     }, | ||||
|   ], | ||||
|  | ||||
|   generateAuthUrl, | ||||
|   verifyCredentials, | ||||
|   isStillVerified, | ||||
| }; | ||||
| @@ -0,0 +1,8 @@ | ||||
| import getCurrentUser from '../common/get-current-user.js'; | ||||
|  | ||||
| const isStillVerified = async ($) => { | ||||
|   const currentUser = await getCurrentUser($); | ||||
|   return !!currentUser.id; | ||||
| }; | ||||
|  | ||||
| export default isStillVerified; | ||||
| @@ -0,0 +1,42 @@ | ||||
| import getCurrentUser from '../common/get-current-user.js'; | ||||
|  | ||||
| const verifyCredentials = async ($) => { | ||||
|   const oauthRedirectUrlField = $.app.auth.fields.find( | ||||
|     (field) => field.key == 'oAuthRedirectUrl' | ||||
|   ); | ||||
|   const redirectUri = oauthRedirectUrlField.value; | ||||
|   const { data } = await $.http.post( | ||||
|     'https://www.eventbrite.com/oauth/token', | ||||
|     { | ||||
|       grant_type: 'authorization_code', | ||||
|       client_id: $.auth.data.clientId, | ||||
|       client_secret: $.auth.data.clientSecret, | ||||
|       code: $.auth.data.code, | ||||
|       redirect_uri: redirectUri, | ||||
|     }, | ||||
|     { | ||||
|       headers: { | ||||
|         'Content-Type': 'application/x-www-form-urlencoded', | ||||
|       }, | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   await $.auth.set({ | ||||
|     accessToken: data.access_token, | ||||
|     tokenType: data.token_type, | ||||
|   }); | ||||
|  | ||||
|   const currentUser = await getCurrentUser($); | ||||
|  | ||||
|   const screenName = [currentUser.name, currentUser.emails[0].email] | ||||
|     .filter(Boolean) | ||||
|     .join(' @ '); | ||||
|  | ||||
|   await $.auth.set({ | ||||
|     clientId: $.auth.data.clientId, | ||||
|     clientSecret: $.auth.data.clientSecret, | ||||
|     screenName, | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export default verifyCredentials; | ||||
| @@ -0,0 +1,9 @@ | ||||
| const addAuthHeader = ($, requestConfig) => { | ||||
|   if ($.auth.data?.accessToken) { | ||||
|     requestConfig.headers.Authorization = `Bearer ${$.auth.data.accessToken}`; | ||||
|   } | ||||
|  | ||||
|   return requestConfig; | ||||
| }; | ||||
|  | ||||
| export default addAuthHeader; | ||||
| @@ -0,0 +1,6 @@ | ||||
| const getCurrentUser = async ($) => { | ||||
|   const { data: currentUser } = await $.http.get('/v3/users/me'); | ||||
|   return currentUser; | ||||
| }; | ||||
|  | ||||
| export default getCurrentUser; | ||||
| @@ -0,0 +1,4 @@ | ||||
| import listEvents from './list-events/index.js'; | ||||
| import listOrganizations from './list-organizations/index.js'; | ||||
|  | ||||
| export default [listEvents, listOrganizations]; | ||||
| @@ -0,0 +1,44 @@ | ||||
| export default { | ||||
|   name: 'List events', | ||||
|   key: 'listEvents', | ||||
|  | ||||
|   async run($) { | ||||
|     const events = { | ||||
|       data: [], | ||||
|     }; | ||||
|     const organizationId = $.step.parameters.organizationId; | ||||
|  | ||||
|     if (!organizationId) { | ||||
|       return events; | ||||
|     } | ||||
|  | ||||
|     const params = { | ||||
|       continuation: undefined, | ||||
|       order_by: 'created_desc', | ||||
|     }; | ||||
|  | ||||
|     do { | ||||
|       const { data } = await $.http.get( | ||||
|         `/v3/organizations/${organizationId}/events/`, | ||||
|         { | ||||
|           params, | ||||
|         } | ||||
|       ); | ||||
|  | ||||
|       if (data.pagination.has_more_items) { | ||||
|         params.continuation = data.pagination.continuation; | ||||
|       } | ||||
|  | ||||
|       if (data.events) { | ||||
|         for (const event of data.events) { | ||||
|           events.data.push({ | ||||
|             value: event.id, | ||||
|             name: `${event.name.text} (${event.status})`, | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     } while (params.continuation); | ||||
|  | ||||
|     return events; | ||||
|   }, | ||||
| }; | ||||
| @@ -0,0 +1,35 @@ | ||||
| export default { | ||||
|   name: 'List organizations', | ||||
|   key: 'listOrganizations', | ||||
|  | ||||
|   async run($) { | ||||
|     const organizations = { | ||||
|       data: [], | ||||
|     }; | ||||
|  | ||||
|     const params = { | ||||
|       continuation: undefined, | ||||
|     }; | ||||
|  | ||||
|     do { | ||||
|       const { data } = await $.http.get('/v3/users/me/organizations', { | ||||
|         params, | ||||
|       }); | ||||
|  | ||||
|       if (data.pagination.has_more_items) { | ||||
|         params.continuation = data.pagination.continuation; | ||||
|       } | ||||
|  | ||||
|       if (data.organizations) { | ||||
|         for (const organization of data.organizations) { | ||||
|           organizations.data.push({ | ||||
|             value: organization.id, | ||||
|             name: organization.name, | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     } while (params.continuation); | ||||
|  | ||||
|     return organizations; | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										20
									
								
								packages/backend/src/apps/eventbrite/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								packages/backend/src/apps/eventbrite/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| import defineApp from '../../helpers/define-app.js'; | ||||
| import addAuthHeader from './common/add-auth-header.js'; | ||||
| import auth from './auth/index.js'; | ||||
| import dynamicData from './dynamic-data/index.js'; | ||||
| import triggers from './triggers/index.js'; | ||||
|  | ||||
| export default defineApp({ | ||||
|   name: 'Eventbrite', | ||||
|   key: 'eventbrite', | ||||
|   baseUrl: 'https://www.eventbrite.com', | ||||
|   apiBaseUrl: 'https://www.eventbriteapi.com', | ||||
|   iconUrl: '{BASE_URL}/apps/eventbrite/assets/favicon.svg', | ||||
|   authDocUrl: '{DOCS_URL}/apps/eventbrite/connection', | ||||
|   primaryColor: 'F05537', | ||||
|   supportsConnections: true, | ||||
|   beforeRequest: [addAuthHeader], | ||||
|   auth, | ||||
|   dynamicData, | ||||
|   triggers, | ||||
| }); | ||||
							
								
								
									
										4
									
								
								packages/backend/src/apps/eventbrite/triggers/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								packages/backend/src/apps/eventbrite/triggers/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| import newAttendeeCheckIn from './new-attendee-check-in/index.js'; | ||||
| import newEvents from './new-events/index.js'; | ||||
|  | ||||
| export default [newAttendeeCheckIn, newEvents]; | ||||
| @@ -0,0 +1,120 @@ | ||||
| import Crypto from 'crypto'; | ||||
| import defineTrigger from '../../../../helpers/define-trigger.js'; | ||||
|  | ||||
| export default defineTrigger({ | ||||
|   name: 'New attendee check in', | ||||
|   key: 'newAttendeeCheckIn', | ||||
|   type: 'webhook', | ||||
|   description: "Triggers when an attendee's barcode is scanned in.", | ||||
|   arguments: [ | ||||
|     { | ||||
|       label: 'Organization', | ||||
|       key: 'organizationId', | ||||
|       type: 'dropdown', | ||||
|       required: true, | ||||
|       description: '', | ||||
|       variables: true, | ||||
|       source: { | ||||
|         type: 'query', | ||||
|         name: 'getDynamicData', | ||||
|         arguments: [ | ||||
|           { | ||||
|             name: 'key', | ||||
|             value: 'listOrganizations', | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|     { | ||||
|       label: 'Event', | ||||
|       key: 'eventId', | ||||
|       type: 'dropdown', | ||||
|       required: false, | ||||
|       description: '', | ||||
|       variables: true, | ||||
|       source: { | ||||
|         type: 'query', | ||||
|         name: 'getDynamicData', | ||||
|         arguments: [ | ||||
|           { | ||||
|             name: 'key', | ||||
|             value: 'listEvents', | ||||
|           }, | ||||
|           { | ||||
|             name: 'parameters.organizationId', | ||||
|             value: '{parameters.organizationId}', | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|   ], | ||||
|  | ||||
|   async run($) { | ||||
|     const dataItem = { | ||||
|       raw: $.request.body, | ||||
|       meta: { | ||||
|         internalId: Crypto.randomUUID(), | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     $.pushTriggerItem(dataItem); | ||||
|   }, | ||||
|  | ||||
|   async testRun($) { | ||||
|     const eventId = $.step.parameters.eventId; | ||||
|     const organizationId = $.step.parameters.organizationId; | ||||
|  | ||||
|     const params = { | ||||
|       event_id: eventId, | ||||
|     }; | ||||
|  | ||||
|     const { | ||||
|       data: { orders }, | ||||
|     } = await $.http.get(`/v3/events/${eventId}/orders/`, params); | ||||
|  | ||||
|     if (orders.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const computedWebhookEvent = { | ||||
|       config: { | ||||
|         action: 'barcode.checked_in', | ||||
|         user_id: organizationId, | ||||
|         webhook_id: '11111111', | ||||
|         endpoint_url: $.webhookUrl, | ||||
|       }, | ||||
|       api_url: orders[0].resource_uri, | ||||
|     }; | ||||
|  | ||||
|     const dataItem = { | ||||
|       raw: computedWebhookEvent, | ||||
|       meta: { | ||||
|         internalId: computedWebhookEvent.user_id, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     $.pushTriggerItem(dataItem); | ||||
|   }, | ||||
|  | ||||
|   async registerHook($) { | ||||
|     const organizationId = $.step.parameters.organizationId; | ||||
|     const eventId = $.step.parameters.eventId; | ||||
|  | ||||
|     const payload = { | ||||
|       endpoint_url: $.webhookUrl, | ||||
|       actions: 'attendee.checked_in', | ||||
|       event_id: eventId, | ||||
|     }; | ||||
|  | ||||
|     const { data } = await $.http.post( | ||||
|       `/v3/organizations/${organizationId}/webhooks/`, | ||||
|       payload | ||||
|     ); | ||||
|  | ||||
|     await $.flow.setRemoteWebhookId(data.id); | ||||
|   }, | ||||
|  | ||||
|   async unregisterHook($) { | ||||
|     await $.http.delete(`/v3/webhooks/${$.flow.remoteWebhookId}/`); | ||||
|   }, | ||||
| }); | ||||
| @@ -0,0 +1,98 @@ | ||||
| import Crypto from 'crypto'; | ||||
| import defineTrigger from '../../../../helpers/define-trigger.js'; | ||||
|  | ||||
| export default defineTrigger({ | ||||
|   name: 'New events', | ||||
|   key: 'newEvents', | ||||
|   type: 'webhook', | ||||
|   description: | ||||
|     'Triggers when a new event is published and live within an organization.', | ||||
|   arguments: [ | ||||
|     { | ||||
|       label: 'Organization', | ||||
|       key: 'organizationId', | ||||
|       type: 'dropdown', | ||||
|       required: true, | ||||
|       description: '', | ||||
|       variables: true, | ||||
|       source: { | ||||
|         type: 'query', | ||||
|         name: 'getDynamicData', | ||||
|         arguments: [ | ||||
|           { | ||||
|             name: 'key', | ||||
|             value: 'listOrganizations', | ||||
|           }, | ||||
|         ], | ||||
|       }, | ||||
|     }, | ||||
|   ], | ||||
|  | ||||
|   async run($) { | ||||
|     const dataItem = { | ||||
|       raw: $.request.body, | ||||
|       meta: { | ||||
|         internalId: Crypto.randomUUID(), | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     $.pushTriggerItem(dataItem); | ||||
|   }, | ||||
|  | ||||
|   async testRun($) { | ||||
|     const organizationId = $.step.parameters.organizationId; | ||||
|  | ||||
|     const params = { | ||||
|       orderBy: 'created_desc', | ||||
|       status: 'all', | ||||
|     }; | ||||
|  | ||||
|     const { | ||||
|       data: { events }, | ||||
|     } = await $.http.get(`/v3/organizations/${organizationId}/events/`, params); | ||||
|  | ||||
|     if (events.length === 0) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const computedWebhookEvent = { | ||||
|       config: { | ||||
|         action: 'event.published', | ||||
|         user_id: events[0].organization_id, | ||||
|         webhook_id: '11111111', | ||||
|         endpoint_url: $.webhookUrl, | ||||
|       }, | ||||
|       api_url: events[0].resource_uri, | ||||
|     }; | ||||
|  | ||||
|     const dataItem = { | ||||
|       raw: computedWebhookEvent, | ||||
|       meta: { | ||||
|         internalId: computedWebhookEvent.user_id, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     $.pushTriggerItem(dataItem); | ||||
|   }, | ||||
|  | ||||
|   async registerHook($) { | ||||
|     const organizationId = $.step.parameters.organizationId; | ||||
|  | ||||
|     const payload = { | ||||
|       endpoint_url: $.webhookUrl, | ||||
|       actions: 'event.published', | ||||
|       event_id: '', | ||||
|     }; | ||||
|  | ||||
|     const { data } = await $.http.post( | ||||
|       `/v3/organizations/${organizationId}/webhooks/`, | ||||
|       payload | ||||
|     ); | ||||
|  | ||||
|     await $.flow.setRemoteWebhookId(data.id); | ||||
|   }, | ||||
|  | ||||
|   async unregisterHook($) { | ||||
|     await $.http.delete(`/v3/webhooks/${$.flow.remoteWebhookId}/`); | ||||
|   }, | ||||
| }); | ||||
| @@ -33,8 +33,8 @@ class User extends Base { | ||||
|       fullName: { type: 'string', minLength: 1 }, | ||||
|       email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, | ||||
|       password: { type: 'string' }, | ||||
|       resetPasswordToken: { type: ['string', 'null'] }, | ||||
|       resetPasswordTokenSentAt: { type: ['string', 'null'], format: 'date-time' }, | ||||
|       resetPasswordToken: { type: 'string' }, | ||||
|       resetPasswordTokenSentAt: { type: 'string' }, | ||||
|       trialExpiryDate: { type: 'string' }, | ||||
|       roleId: { type: 'string', format: 'uuid' }, | ||||
|       deletedAt: { type: 'string' }, | ||||
|   | ||||
| @@ -40,7 +40,6 @@ export const worker = new Worker( | ||||
|       await user.$relatedQuery('usageData').withSoftDeleted().hardDelete(); | ||||
|     } | ||||
|  | ||||
|     await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete(); | ||||
|     await user.$query().withSoftDeleted().hardDelete(); | ||||
|   }, | ||||
|   { connection: redisConfig } | ||||
|   | ||||
| @@ -113,6 +113,15 @@ export default defineConfig({ | ||||
|             { text: 'Connection', link: '/apps/dropbox/connection' }, | ||||
|           ], | ||||
|         }, | ||||
|         { | ||||
|           text: 'Eventbrite', | ||||
|           collapsible: true, | ||||
|           collapsed: true, | ||||
|           items: [ | ||||
|             { text: 'Triggers', link: '/apps/eventbrite/triggers' }, | ||||
|             { text: 'Connection', link: '/apps/eventbrite/connection' }, | ||||
|           ], | ||||
|         }, | ||||
|         { | ||||
|           text: 'Filter', | ||||
|           collapsible: true, | ||||
|   | ||||
							
								
								
									
										18
									
								
								packages/docs/pages/apps/eventbrite/connection.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								packages/docs/pages/apps/eventbrite/connection.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| # Eventbrite | ||||
|  | ||||
| :::info | ||||
| This page explains the steps you need to follow to set up the Eventbrite | ||||
| connection in Automatisch. If any of the steps are outdated, please let us know! | ||||
| ::: | ||||
|  | ||||
| 1. Go to your Eventbrite account settings. | ||||
| 2. Click on the **Developer Links**, and click on the **API Keys** button. | ||||
| 3. Click on the **Create API Key** button. | ||||
| 4. Fill the form. | ||||
| 5. Copy **OAuth Redirect URL** from Automatisch to **OAuth Redirect URI** field in the form. | ||||
| 6. After filling the form, click on the **Create Key** button. | ||||
| 7. Click on the **Show API key, client secret and tokens** in the middle of the page. | ||||
| 8. Copy the **API Key** value to the `API Key` field on Automatisch. | ||||
| 9. Copy the **Client secret** value to the `Client Secret` field on Automatisch. | ||||
| 10. Click **Submit** button on Automatisch. | ||||
| 11. Congrats! Start using your new Eventbrite connection within the flows. | ||||
							
								
								
									
										14
									
								
								packages/docs/pages/apps/eventbrite/triggers.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								packages/docs/pages/apps/eventbrite/triggers.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| --- | ||||
| favicon: /favicons/eventbrite.svg | ||||
| items: | ||||
|   - name: New attendee check in | ||||
|     desc: Triggers when an attendee's barcode is scanned in. | ||||
|   - name: New events | ||||
|     desc: Triggers when a new event is published and live within an organization. | ||||
| --- | ||||
|  | ||||
| <script setup> | ||||
|   import CustomListing from '../../components/CustomListing.vue' | ||||
| </script> | ||||
|  | ||||
| <CustomListing /> | ||||
| @@ -6,12 +6,16 @@ We use `lerna` with `yarn workspaces` to manage the mono repository. We have the | ||||
| . | ||||
| ├── packages | ||||
| │   ├── backend | ||||
| │   ├── cli | ||||
| │   ├── docs | ||||
| │   ├── e2e-tests | ||||
| │   ├── types | ||||
| │   └── web | ||||
| ``` | ||||
|  | ||||
| - `backend` - The backend package contains the backend application and all integrations. | ||||
| - `cli` - The cli package contains the CLI application of Automatisch. | ||||
| - `docs` - The docs package contains the documentation website. | ||||
| - `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage. | ||||
| - `types` - The types package contains the shared types for both the backend and web packages. | ||||
| - `web` - The web package contains the frontend application of Automatisch. | ||||
|   | ||||
| @@ -11,6 +11,7 @@ The following integrations are currently supported by Automatisch. | ||||
| - [Discord](/apps/discord/actions) | ||||
| - [Disqus](/apps/disqus/triggers) | ||||
| - [Dropbox](/apps/dropbox/actions) | ||||
| - [Eventbrite](/apps/eventbrite/triggers) | ||||
| - [Filter](/apps/filter/actions) | ||||
| - [Flickr](/apps/flickr/triggers) | ||||
| - [Formatter](/apps/formatter/actions) | ||||
|   | ||||
							
								
								
									
										7
									
								
								packages/docs/pages/public/favicons/eventbrite.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								packages/docs/pages/public/favicons/eventbrite.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <svg width="256px" height="256px" viewBox="0 0 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid"> | ||||
| 		<g> | ||||
| 				<circle fill="#F05537" cx="128" cy="128" r="128"></circle> | ||||
| 				<path d="M117.475323,82.7290398 C136.772428,78.4407943 156.069532,86.3025777 166.790146,101.311437 L81.5017079,120.608542 C84.3605382,102.26438 98.1782181,87.0172853 117.475323,82.7290398 Z M167.266618,153.48509 C160.596014,163.252761 150.351872,170.161601 138.678314,172.782195 C119.38121,177.070441 99.8458692,169.208657 89.1252554,153.961562 L174.651929,134.664457 L188.469609,131.567391 L215.152026,125.611495 C214.91379,119.893834 214.199082,114.176173 213.007903,108.696749 C202.287289,62.7172275 155.354825,33.8906884 108.42236,44.6113021 C61.4898956,55.3319159 32.1868848,101.073201 43.1457344,147.290958 C54.1045839,193.508715 100.798813,222.097018 147.731277,211.376404 C175.366637,205.182272 196.807864,186.599875 207.766714,163.014525 L167.266618,153.48509 L167.266618,153.48509 Z" fill="#FFFFFF" fill-rule="nonzero"></path> | ||||
| 		</g> | ||||
| </svg> | ||||
| After Width: | Height: | Size: 1.1 KiB | 
| @@ -68,10 +68,7 @@ function AccountDropdownMenu(props) { | ||||
| AccountDropdownMenu.propTypes = { | ||||
|   open: PropTypes.bool.isRequired, | ||||
|   onClose: PropTypes.func.isRequired, | ||||
|   anchorEl: PropTypes.oneOfType([ | ||||
|     PropTypes.func, | ||||
|     PropTypes.shape({ current: PropTypes.instanceOf(Element) }), | ||||
|   ]), | ||||
|   anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), | ||||
|   id: PropTypes.string.isRequired, | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -8,7 +8,6 @@ import * as URLS from 'config/urls'; | ||||
| import useFormatMessage from 'hooks/useFormatMessage'; | ||||
| import { ConnectionPropType } from 'propTypes/propTypes'; | ||||
| import { useQueryClient } from '@tanstack/react-query'; | ||||
| import Can from 'components/Can'; | ||||
|  | ||||
| function ContextMenu(props) { | ||||
|   const { | ||||
| @@ -45,35 +44,21 @@ function ContextMenu(props) { | ||||
|       hideBackdrop={false} | ||||
|       anchorEl={anchorEl} | ||||
|     > | ||||
|       <Can I="read" a="Flow" passThrough> | ||||
|         {(allowed) => ( | ||||
|       <MenuItem | ||||
|         component={Link} | ||||
|         to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)} | ||||
|         onClick={createActionHandler({ type: 'viewFlows' })} | ||||
|             disabled={!allowed} | ||||
|       > | ||||
|         {formatMessage('connection.viewFlows')} | ||||
|       </MenuItem> | ||||
|         )} | ||||
|       </Can> | ||||
|  | ||||
|       <Can I="update" a="Connection" passThrough> | ||||
|         {(allowed) => ( | ||||
|           <MenuItem | ||||
|             onClick={createActionHandler({ type: 'test' })} | ||||
|             disabled={!allowed} | ||||
|           > | ||||
|       <MenuItem onClick={createActionHandler({ type: 'test' })}> | ||||
|         {formatMessage('connection.testConnection')} | ||||
|       </MenuItem> | ||||
|         )} | ||||
|       </Can> | ||||
|  | ||||
|       <Can I="create" a="Connection" passThrough> | ||||
|         {(allowed) => ( | ||||
|       <MenuItem | ||||
|         component={Link} | ||||
|             disabled={!allowed || disableReconnection} | ||||
|         disabled={disableReconnection} | ||||
|         to={URLS.APP_RECONNECT_CONNECTION( | ||||
|           appKey, | ||||
|           connection.id, | ||||
| @@ -83,19 +68,10 @@ function ContextMenu(props) { | ||||
|       > | ||||
|         {formatMessage('connection.reconnect')} | ||||
|       </MenuItem> | ||||
|         )} | ||||
|       </Can> | ||||
|  | ||||
|       <Can I="delete" a="Connection" passThrough> | ||||
|         {(allowed) => ( | ||||
|           <MenuItem | ||||
|             onClick={createActionHandler({ type: 'delete' })} | ||||
|             disabled={!allowed} | ||||
|           > | ||||
|       <MenuItem onClick={createActionHandler({ type: 'delete' })}> | ||||
|         {formatMessage('connection.delete')} | ||||
|       </MenuItem> | ||||
|         )} | ||||
|       </Can> | ||||
|     </Menu> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; | ||||
|  | ||||
| import AppConnectionRow from 'components/AppConnectionRow'; | ||||
| import NoResultFound from 'components/NoResultFound'; | ||||
| import Can from 'components/Can'; | ||||
| import useFormatMessage from 'hooks/useFormatMessage'; | ||||
| import * as URLS from 'config/urls'; | ||||
| import useAppConnections from 'hooks/useAppConnections'; | ||||
| @@ -17,15 +16,11 @@ function AppConnections(props) { | ||||
|  | ||||
|   if (!hasConnections) { | ||||
|     return ( | ||||
|       <Can I="create" a="Connection" passThrough> | ||||
|         {(allowed) => ( | ||||
|       <NoResultFound | ||||
|         to={URLS.APP_ADD_CONNECTION(appKey)} | ||||
|         text={formatMessage('app.noConnections')} | ||||
|         data-test="connections-no-results" | ||||
|             {...(allowed && { to: URLS.APP_ADD_CONNECTION(appKey) })} | ||||
|       /> | ||||
|         )} | ||||
|       </Can> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -5,7 +5,6 @@ import PaginationItem from '@mui/material/PaginationItem'; | ||||
|  | ||||
| import * as URLS from 'config/urls'; | ||||
| import AppFlowRow from 'components/FlowRow'; | ||||
| import Can from 'components/Can'; | ||||
| import NoResultFound from 'components/NoResultFound'; | ||||
| import useFormatMessage from 'hooks/useFormatMessage'; | ||||
| import useConnectionFlows from 'hooks/useConnectionFlows'; | ||||
| @@ -37,20 +36,11 @@ function AppFlows(props) { | ||||
|  | ||||
|   if (!hasFlows) { | ||||
|     return ( | ||||
|       <Can I="create" a="Flow" passThrough> | ||||
|         {(allowed) => ( | ||||
|       <NoResultFound | ||||
|         to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(appKey, connectionId)} | ||||
|         text={formatMessage('app.noFlows')} | ||||
|         data-test="flows-no-results" | ||||
|             {...(allowed && { | ||||
|               to: URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION( | ||||
|                 appKey, | ||||
|                 connectionId | ||||
|               ), | ||||
|             })} | ||||
|       /> | ||||
|         )} | ||||
|       </Can> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,7 @@ const CustomOptions = (props) => { | ||||
|     label, | ||||
|     initialTabIndex, | ||||
|   } = props; | ||||
|  | ||||
|   const [activeTabIndex, setActiveTabIndex] = React.useState(undefined); | ||||
|  | ||||
|   React.useEffect( | ||||
|     function applyInitialActiveTabIndex() { | ||||
|       setActiveTabIndex((currentActiveTabIndex) => { | ||||
| @@ -35,7 +33,6 @@ const CustomOptions = (props) => { | ||||
|     }, | ||||
|     [initialTabIndex], | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Popper | ||||
|       open={open} | ||||
| @@ -79,10 +76,7 @@ const CustomOptions = (props) => { | ||||
|  | ||||
| CustomOptions.propTypes = { | ||||
|   open: PropTypes.bool.isRequired, | ||||
|   anchorEl: PropTypes.oneOfType([ | ||||
|     PropTypes.func, | ||||
|     PropTypes.shape({ current: PropTypes.instanceOf(Element) }), | ||||
|   ]), | ||||
|   anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired, | ||||
|   data: PropTypes.arrayOf( | ||||
|     PropTypes.shape({ | ||||
|       id: PropTypes.string.isRequired, | ||||
|   | ||||
| @@ -61,7 +61,6 @@ function ControlledCustomAutocomplete(props) { | ||||
|   const [isSingleChoice, setSingleChoice] = React.useState(undefined); | ||||
|   const priorStepsWithExecutions = React.useContext(StepExecutionsContext); | ||||
|   const editorRef = React.useRef(null); | ||||
|   const mountedRef = React.useRef(false); | ||||
|  | ||||
|   const renderElement = React.useCallback( | ||||
|     (props) => <Element {...props} disabled={disabled} />, | ||||
| @@ -95,15 +94,11 @@ function ControlledCustomAutocomplete(props) { | ||||
|   }, []); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (mountedRef.current) { | ||||
|     const hasDependencies = dependsOnValues.length; | ||||
|     if (hasDependencies) { | ||||
|       // Reset the field when a dependent has been updated | ||||
|       resetEditor(editor); | ||||
|     } | ||||
|     } else { | ||||
|       mountedRef.current = true; | ||||
|     } | ||||
|   }, dependsOnValues); | ||||
|  | ||||
|   React.useEffect( | ||||
|   | ||||
| @@ -64,19 +64,11 @@ function DynamicField(props) { | ||||
|           <Stack | ||||
|             direction={{ xs: 'column', sm: 'row' }} | ||||
|             spacing={{ xs: 2 }} | ||||
|             sx={{ | ||||
|               display: 'flex', | ||||
|               flex: 1, | ||||
|               minWidth: 0, | ||||
|             }} | ||||
|             sx={{ display: 'flex', flex: 1 }} | ||||
|           > | ||||
|             {fields.map((fieldSchema, fieldSchemaIndex) => ( | ||||
|               <Box | ||||
|                 sx={{ | ||||
|                   display: 'flex', | ||||
|                   flex: '1 0 0px', | ||||
|                   minWidth: 0, | ||||
|                 }} | ||||
|                 sx={{ display: 'flex', flex: '1 0 0px' }} | ||||
|                 key={`field-${field.__id}-${fieldSchemaIndex}`} | ||||
|               > | ||||
|                 <InputCreator | ||||
|   | ||||
| @@ -59,7 +59,6 @@ export default function EditorLayout() { | ||||
|  | ||||
|   const onFlowStatusUpdate = React.useCallback( | ||||
|     async (active) => { | ||||
|       try { | ||||
|       await updateFlowStatus({ | ||||
|         variables: { | ||||
|           input: { | ||||
| @@ -77,7 +76,6 @@ export default function EditorLayout() { | ||||
|       }); | ||||
|  | ||||
|       await queryClient.invalidateQueries({ queryKey: ['flows', flowId] }); | ||||
|       } catch (err) {} | ||||
|     }, | ||||
|     [flowId, queryClient], | ||||
|   ); | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import { EdgeLabelRenderer, getStraightPath } from 'reactflow'; | ||||
| import IconButton from '@mui/material/IconButton'; | ||||
| import AddIcon from '@mui/icons-material/Add'; | ||||
|  | ||||
| import { useMutation } from '@apollo/client'; | ||||
| import { CREATE_STEP } from 'graphql/mutations/create-step'; | ||||
| import { useQueryClient } from '@tanstack/react-query'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { useContext } from 'react'; | ||||
| import { EdgesContext } from '../EditorNew'; | ||||
|  | ||||
| export default function Edge({ | ||||
|   sourceX, | ||||
| @@ -12,11 +12,11 @@ export default function Edge({ | ||||
|   targetX, | ||||
|   targetY, | ||||
|   source, | ||||
|   data: { laidOut }, | ||||
|   data: { flowId, setCurrentStepId, flowActive, layouted }, | ||||
| }) { | ||||
|   const { stepCreationInProgress, flowActive, onAddStep } = | ||||
|     useContext(EdgesContext); | ||||
|  | ||||
|   const [createStep, { loading: creationInProgress }] = | ||||
|     useMutation(CREATE_STEP); | ||||
|   const queryClient = useQueryClient(); | ||||
|   const [edgePath, labelX, labelY] = getStraightPath({ | ||||
|     sourceX, | ||||
|     sourceY, | ||||
| @@ -24,19 +24,38 @@ export default function Edge({ | ||||
|     targetY, | ||||
|   }); | ||||
|  | ||||
|   const addStep = async (previousStepId) => { | ||||
|     const mutationInput = { | ||||
|       previousStep: { | ||||
|         id: previousStepId, | ||||
|       }, | ||||
|       flow: { | ||||
|         id: flowId, | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     const createdStep = await createStep({ | ||||
|       variables: { input: mutationInput }, | ||||
|     }); | ||||
|  | ||||
|     const createdStepId = createdStep.data.createStep.id; | ||||
|     setCurrentStepId(createdStepId); | ||||
|     await queryClient.invalidateQueries({ queryKey: ['flows', flowId] }); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <EdgeLabelRenderer> | ||||
|         <IconButton | ||||
|           onClick={() => onAddStep(source)} | ||||
|           onClick={() => addStep(source)} | ||||
|           color="primary" | ||||
|           sx={{ | ||||
|             position: 'absolute', | ||||
|             transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, | ||||
|             pointerEvents: 'all', | ||||
|             visibility: laidOut ? 'visible' : 'hidden', | ||||
|             visibility: layouted ? 'visible' : 'hidden', | ||||
|           }} | ||||
|           disabled={stepCreationInProgress || flowActive} | ||||
|           disabled={creationInProgress || flowActive} | ||||
|         > | ||||
|           <AddIcon /> | ||||
|         </IconButton> | ||||
| @@ -52,6 +71,9 @@ Edge.propTypes = { | ||||
|   targetY: PropTypes.number.isRequired, | ||||
|   source: PropTypes.string.isRequired, | ||||
|   data: PropTypes.shape({ | ||||
|     laidOut: PropTypes.bool, | ||||
|     flowId: PropTypes.string.isRequired, | ||||
|     setCurrentStepId: PropTypes.func.isRequired, | ||||
|     flowActive: PropTypes.bool.isRequired, | ||||
|     layouted: PropTypes.bool, | ||||
|   }).isRequired, | ||||
| }; | ||||
|   | ||||
| @@ -1,81 +1,50 @@ | ||||
| import { useEffect, useCallback, createContext, useRef } from 'react'; | ||||
| import { useEffect, useState, useCallback } from 'react'; | ||||
| import { useMutation } from '@apollo/client'; | ||||
| import { useQueryClient } from '@tanstack/react-query'; | ||||
| import { FlowPropType } from 'propTypes/propTypes'; | ||||
| import ReactFlow, { useNodesState, useEdgesState } from 'reactflow'; | ||||
| import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow'; | ||||
| import 'reactflow/dist/style.css'; | ||||
| import { UPDATE_STEP } from 'graphql/mutations/update-step'; | ||||
| import { CREATE_STEP } from 'graphql/mutations/create-step'; | ||||
|  | ||||
| import { useAutoLayout } from './useAutoLayout'; | ||||
| import { useScrollBoundaries } from './useScrollBoundaries'; | ||||
| import { useScrollBoundries } from './useScrollBoundries'; | ||||
| import FlowStepNode from './FlowStepNode/FlowStepNode'; | ||||
| import Edge from './Edge/Edge'; | ||||
| import InvisibleNode from './InvisibleNode/InvisibleNode'; | ||||
| import { EditorWrapper } from './style'; | ||||
| import { | ||||
|   generateEdgeId, | ||||
|   generateInitialEdges, | ||||
|   generateInitialNodes, | ||||
|   updatedCollapsedNodes, | ||||
| } from './utils'; | ||||
| import { EDGE_TYPES, INVISIBLE_NODE_ID, NODE_TYPES } from './constants'; | ||||
|  | ||||
| export const EdgesContext = createContext(); | ||||
| export const NodesContext = createContext(); | ||||
|  | ||||
| const nodeTypes = { | ||||
|   [NODE_TYPES.FLOW_STEP]: FlowStepNode, | ||||
|   [NODE_TYPES.INVISIBLE]: InvisibleNode, | ||||
| }; | ||||
| const nodeTypes = { flowStep: FlowStepNode, invisible: InvisibleNode }; | ||||
|  | ||||
| const edgeTypes = { | ||||
|   [EDGE_TYPES.ADD_NODE_EDGE]: Edge, | ||||
|   addNodeEdge: Edge, | ||||
| }; | ||||
|  | ||||
| const INVISIBLE_NODE_ID = 'invisible-node'; | ||||
|  | ||||
| const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`; | ||||
|  | ||||
| const EditorNew = ({ flow }) => { | ||||
|   const [triggerStep] = flow.steps; | ||||
|   const [currentStepId, setCurrentStepId] = useState(triggerStep.id); | ||||
|  | ||||
|   const [updateStep] = useMutation(UPDATE_STEP); | ||||
|   const queryClient = useQueryClient(); | ||||
|   const [createStep, { loading: stepCreationInProgress }] = | ||||
|     useMutation(CREATE_STEP); | ||||
|  | ||||
|   const [nodes, setNodes, onNodesChange] = useNodesState( | ||||
|     generateInitialNodes(flow), | ||||
|   ); | ||||
|   const [edges, setEdges, onEdgesChange] = useEdgesState( | ||||
|     generateInitialEdges(flow), | ||||
|   ); | ||||
|  | ||||
|   const [nodes, setNodes, onNodesChange] = useNodesState([]); | ||||
|   const [edges, setEdges, onEdgesChange] = useEdgesState([]); | ||||
|   useAutoLayout(); | ||||
|   useScrollBoundaries(); | ||||
|   useScrollBoundries(); | ||||
|  | ||||
|   const createdStepIdRef = useRef(null); | ||||
|   const onConnect = useCallback( | ||||
|     (params) => setEdges((eds) => addEdge(params, eds)), | ||||
|     [setEdges], | ||||
|   ); | ||||
|  | ||||
|   const openNextStep = useCallback( | ||||
|     (currentStepId) => { | ||||
|       setNodes((nodes) => { | ||||
|         const currentStepIndex = nodes.findIndex( | ||||
|           (node) => node.id === currentStepId, | ||||
|         ); | ||||
|         if (currentStepIndex >= 0) { | ||||
|           const nextStep = nodes[currentStepIndex + 1]; | ||||
|           return updatedCollapsedNodes(nodes, nextStep.id); | ||||
|         } | ||||
|         return nodes; | ||||
|       }); | ||||
|     (nextStep) => () => { | ||||
|       setCurrentStepId(nextStep?.id); | ||||
|     }, | ||||
|     [setNodes], | ||||
|   ); | ||||
|  | ||||
|   const onStepClose = useCallback(() => { | ||||
|     setNodes((nodes) => updatedCollapsedNodes(nodes)); | ||||
|   }, [setNodes]); | ||||
|  | ||||
|   const onStepOpen = useCallback( | ||||
|     (stepId) => { | ||||
|       setNodes((nodes) => updatedCollapsedNodes(nodes, stepId)); | ||||
|     }, | ||||
|     [setNodes], | ||||
|     [], | ||||
|   ); | ||||
|  | ||||
|   const onStepChange = useCallback( | ||||
| @@ -107,82 +76,13 @@ const EditorNew = ({ flow }) => { | ||||
|     [flow.id, updateStep, queryClient], | ||||
|   ); | ||||
|  | ||||
|   const onAddStep = useCallback( | ||||
|     async (previousStepId) => { | ||||
|       const mutationInput = { | ||||
|         previousStep: { | ||||
|           id: previousStepId, | ||||
|         }, | ||||
|         flow: { | ||||
|           id: flow.id, | ||||
|         }, | ||||
|       }; | ||||
|  | ||||
|       const { | ||||
|         data: { createStep: createdStep }, | ||||
|       } = await createStep({ | ||||
|         variables: { input: mutationInput }, | ||||
|       }); | ||||
|  | ||||
|       const createdStepId = createdStep.id; | ||||
|       await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] }); | ||||
|       createdStepIdRef.current = createdStepId; | ||||
|     }, | ||||
|     [flow.id, createStep, queryClient], | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (flow.steps.length + 1 !== nodes.length) { | ||||
|       setNodes((nodes) => { | ||||
|         const newNodes = flow.steps.map((step) => { | ||||
|           const createdStepId = createdStepIdRef.current; | ||||
|           const prevNode = nodes.find(({ id }) => id === step.id); | ||||
|           if (prevNode) { | ||||
|             return { | ||||
|               ...prevNode, | ||||
|               zIndex: createdStepId ? 0 : prevNode.zIndex, | ||||
|               data: { | ||||
|                 ...prevNode.data, | ||||
|                 collapsed: createdStepId ? true : prevNode.data.collapsed, | ||||
|               }, | ||||
|             }; | ||||
|           } else { | ||||
|             return { | ||||
|               id: step.id, | ||||
|               type: NODE_TYPES.FLOW_STEP, | ||||
|               position: { | ||||
|                 x: 0, | ||||
|                 y: 0, | ||||
|               }, | ||||
|               zIndex: 1, | ||||
|               data: { | ||||
|                 collapsed: false, | ||||
|                 laidOut: false, | ||||
|               }, | ||||
|             }; | ||||
|           } | ||||
|         }); | ||||
|  | ||||
|         const prevInvisible = nodes.find(({ id }) => id === INVISIBLE_NODE_ID); | ||||
|         return [ | ||||
|           ...newNodes, | ||||
|           { | ||||
|             id: INVISIBLE_NODE_ID, | ||||
|             type: NODE_TYPES.INVISIBLE, | ||||
|             position: { | ||||
|               x: prevInvisible?.position.x || 0, | ||||
|               y: prevInvisible?.position.y || 0, | ||||
|             }, | ||||
|           }, | ||||
|         ]; | ||||
|       }); | ||||
|  | ||||
|       setEdges((edges) => { | ||||
|         const newEdges = flow.steps | ||||
|   const generateEdges = useCallback((flow, prevEdges) => { | ||||
|     const newEdges = | ||||
|       flow.steps | ||||
|         .map((step, i) => { | ||||
|           const sourceId = step.id; | ||||
|           const targetId = flow.steps[i + 1]?.id; | ||||
|             const edge = edges?.find( | ||||
|           const edge = prevEdges?.find( | ||||
|             (edge) => edge.id === generateEdgeId(sourceId, targetId), | ||||
|           ); | ||||
|           if (targetId) { | ||||
| @@ -192,16 +92,17 @@ const EditorNew = ({ flow }) => { | ||||
|               target: targetId, | ||||
|               type: 'addNodeEdge', | ||||
|               data: { | ||||
|                   laidOut: edge ? edge?.data.laidOut : false, | ||||
|                 flowId: flow.id, | ||||
|                 flowActive: flow.active, | ||||
|                 setCurrentStepId, | ||||
|                 layouted: !!edge, | ||||
|               }, | ||||
|             }; | ||||
|           } | ||||
|             return null; | ||||
|         }) | ||||
|           .filter((edge) => !!edge); | ||||
|         .filter((edge) => !!edge) || []; | ||||
|  | ||||
|     const lastStep = flow.steps[flow.steps.length - 1]; | ||||
|         const lastEdge = edges[edges.length - 1]; | ||||
|  | ||||
|     return lastStep | ||||
|       ? [ | ||||
| @@ -212,47 +113,129 @@ const EditorNew = ({ flow }) => { | ||||
|             target: INVISIBLE_NODE_ID, | ||||
|             type: 'addNodeEdge', | ||||
|             data: { | ||||
|                   laidOut: | ||||
|                     lastEdge?.id === | ||||
|                     generateEdgeId(lastStep.id, INVISIBLE_NODE_ID) | ||||
|                       ? lastEdge?.data.laidOut | ||||
|                       : false, | ||||
|               flowId: flow.id, | ||||
|               flowActive: flow.active, | ||||
|               setCurrentStepId, | ||||
|               layouted: false, | ||||
|             }, | ||||
|           }, | ||||
|         ] | ||||
|       : newEdges; | ||||
|   }, []); | ||||
|  | ||||
|   const generateNodes = useCallback( | ||||
|     (flow, prevNodes) => { | ||||
|       const newNodes = flow.steps.map((step, index) => { | ||||
|         const node = prevNodes?.find(({ id }) => id === step.id); | ||||
|         const collapsed = currentStepId !== step.id; | ||||
|         return { | ||||
|           id: step.id, | ||||
|           type: 'flowStep', | ||||
|           position: { | ||||
|             x: node ? node.position.x : 0, | ||||
|             y: node ? node.position.y : 0, | ||||
|           }, | ||||
|           zIndex: collapsed ? 0 : 1, | ||||
|           data: { | ||||
|             step, | ||||
|             index: index, | ||||
|             flowId: flow.id, | ||||
|             collapsed, | ||||
|             openNextStep: openNextStep(flow.steps[index + 1]), | ||||
|             onOpen: () => setCurrentStepId(step.id), | ||||
|             onClose: () => setCurrentStepId(null), | ||||
|             onChange: onStepChange, | ||||
|             layouted: !!node, | ||||
|           }, | ||||
|         }; | ||||
|       }); | ||||
|  | ||||
|       if (createdStepIdRef.current) { | ||||
|         createdStepIdRef.current = null; | ||||
|       const prevInvisibleNode = nodes.find((node) => node.type === 'invisible'); | ||||
|  | ||||
|       return [ | ||||
|         ...newNodes, | ||||
|         { | ||||
|           id: INVISIBLE_NODE_ID, | ||||
|           type: 'invisible', | ||||
|           position: { | ||||
|             x: prevInvisibleNode ? prevInvisibleNode.position.x : 0, | ||||
|             y: prevInvisibleNode ? prevInvisibleNode.position.y : 0, | ||||
|           }, | ||||
|         }, | ||||
|       ]; | ||||
|     }, | ||||
|     [currentStepId, nodes, onStepChange, openNextStep], | ||||
|   ); | ||||
|  | ||||
|   const updateNodesData = useCallback( | ||||
|     (steps) => { | ||||
|       setNodes((nodes) => | ||||
|         nodes.map((node) => { | ||||
|           const step = steps.find((step) => step.id === node.id); | ||||
|           if (step) { | ||||
|             return { ...node, data: { ...node.data, step: { ...step } } }; | ||||
|           } | ||||
|           return node; | ||||
|         }), | ||||
|       ); | ||||
|     }, | ||||
|     [setNodes], | ||||
|   ); | ||||
|  | ||||
|   const updateEdgesData = useCallback( | ||||
|     (flow) => { | ||||
|       setEdges((edges) => | ||||
|         edges.map((edge) => { | ||||
|           return { | ||||
|             ...edge, | ||||
|             data: { ...edge.data, flowId: flow.id, flowActive: flow.active }, | ||||
|           }; | ||||
|         }), | ||||
|       ); | ||||
|     }, | ||||
|     [setEdges], | ||||
|   ); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setNodes( | ||||
|       nodes.map((node) => { | ||||
|         if (node.type === 'flowStep') { | ||||
|           const collapsed = currentStepId !== node.data.step.id; | ||||
|           return { | ||||
|             ...node, | ||||
|             zIndex: collapsed ? 0 : 1, | ||||
|             data: { | ||||
|               ...node.data, | ||||
|               collapsed, | ||||
|             }, | ||||
|           }; | ||||
|         } | ||||
|   }, [flow.steps]); | ||||
|         return node; | ||||
|       }), | ||||
|     ); | ||||
|   }, [currentStepId]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (flow.steps.length + 1 !== nodes.length) { | ||||
|       const newNodes = generateNodes(flow, nodes); | ||||
|       const newEdges = generateEdges(flow, edges); | ||||
|  | ||||
|       setNodes(newNodes); | ||||
|       setEdges(newEdges); | ||||
|     } else { | ||||
|       updateNodesData(flow.steps); | ||||
|       updateEdgesData(flow); | ||||
|     } | ||||
|   }, [flow]); | ||||
|  | ||||
|   return ( | ||||
|     <NodesContext.Provider | ||||
|       value={{ | ||||
|         openNextStep, | ||||
|         onStepOpen, | ||||
|         onStepClose, | ||||
|         onStepChange, | ||||
|         flowId: flow.id, | ||||
|         steps: flow.steps, | ||||
|       }} | ||||
|     > | ||||
|       <EdgesContext.Provider | ||||
|         value={{ | ||||
|           stepCreationInProgress, | ||||
|           onAddStep, | ||||
|           flowActive: flow.active, | ||||
|         }} | ||||
|       > | ||||
|     <EditorWrapper direction="column"> | ||||
|       <ReactFlow | ||||
|         nodes={nodes} | ||||
|         edges={edges} | ||||
|         onNodesChange={onNodesChange} | ||||
|         onEdgesChange={onEdgesChange} | ||||
|         onConnect={onConnect} | ||||
|         nodeTypes={nodeTypes} | ||||
|         edgeTypes={edgeTypes} | ||||
|         panOnScroll | ||||
| @@ -265,8 +248,6 @@ const EditorNew = ({ flow }) => { | ||||
|         proOptions={{ hideAttribution: true }} | ||||
|       /> | ||||
|     </EditorWrapper> | ||||
|       </EdgesContext.Provider> | ||||
|     </NodesContext.Provider> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,23 +1,30 @@ | ||||
| import { Handle, Position } from 'reactflow'; | ||||
| import { Box } from '@mui/material'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| import FlowStep from 'components/FlowStep'; | ||||
| import { StepPropType } from 'propTypes/propTypes'; | ||||
|  | ||||
| import { NodeWrapper, NodeInnerWrapper } from './style.js'; | ||||
| import { useContext } from 'react'; | ||||
| import { NodesContext } from '../EditorNew.jsx'; | ||||
|  | ||||
| function FlowStepNode({ data: { collapsed, laidOut }, id }) { | ||||
|   const { openNextStep, onStepOpen, onStepClose, onStepChange, flowId, steps } = | ||||
|     useContext(NodesContext); | ||||
|  | ||||
|   const step = steps.find(({ id: stepId }) => stepId === id); | ||||
|  | ||||
| function FlowStepNode({ | ||||
|   data: { | ||||
|     step, | ||||
|     index, | ||||
|     flowId, | ||||
|     collapsed, | ||||
|     openNextStep, | ||||
|     onOpen, | ||||
|     onClose, | ||||
|     onChange, | ||||
|     layouted, | ||||
|   }, | ||||
| }) { | ||||
|   return ( | ||||
|     <NodeWrapper | ||||
|       className="nodrag" | ||||
|       sx={{ | ||||
|         visibility: laidOut ? 'visible' : 'hidden', | ||||
|         visibility: layouted ? 'visible' : 'hidden', | ||||
|       }} | ||||
|     > | ||||
|       <NodeInnerWrapper> | ||||
| @@ -27,17 +34,16 @@ function FlowStepNode({ data: { collapsed, laidOut }, id }) { | ||||
|           isConnectable={false} | ||||
|           style={{ visibility: 'hidden' }} | ||||
|         /> | ||||
|         {step && ( | ||||
|         <FlowStep | ||||
|           step={step} | ||||
|           index={index + 1} | ||||
|           collapsed={collapsed} | ||||
|             onOpen={() => onStepOpen(step.id)} | ||||
|             onClose={onStepClose} | ||||
|             onChange={onStepChange} | ||||
|           onOpen={onOpen} | ||||
|           onClose={onClose} | ||||
|           onChange={onChange} | ||||
|           flowId={flowId} | ||||
|             onContinue={() => openNextStep(step.id)} | ||||
|           onContinue={openNextStep} | ||||
|         /> | ||||
|         )} | ||||
|         <Handle | ||||
|           type="source" | ||||
|           position={Position.Bottom} | ||||
| @@ -50,10 +56,16 @@ function FlowStepNode({ data: { collapsed, laidOut }, id }) { | ||||
| } | ||||
|  | ||||
| FlowStepNode.propTypes = { | ||||
|   id: PropTypes.string, | ||||
|   data: PropTypes.shape({ | ||||
|     step: StepPropType.isRequired, | ||||
|     index: PropTypes.number.isRequired, | ||||
|     flowId: PropTypes.string.isRequired, | ||||
|     collapsed: PropTypes.bool.isRequired, | ||||
|     laidOut: PropTypes.bool.isRequired, | ||||
|     openNextStep: PropTypes.func.isRequired, | ||||
|     onOpen: PropTypes.func.isRequired, | ||||
|     onClose: PropTypes.func.isRequired, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|     layouted: PropTypes.bool.isRequired, | ||||
|   }).isRequired, | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -1,10 +0,0 @@ | ||||
| export const INVISIBLE_NODE_ID = 'invisible-node'; | ||||
|  | ||||
| export const NODE_TYPES = { | ||||
|   FLOW_STEP: 'flowStep', | ||||
|   INVISIBLE: 'invisible', | ||||
| }; | ||||
|  | ||||
| export const EDGE_TYPES = { | ||||
|   ADD_NODE_EDGE: 'addNodeEdge', | ||||
| }; | ||||
| @@ -4,7 +4,7 @@ import { usePrevious } from 'hooks/usePrevious'; | ||||
| import { isEqual } from 'lodash'; | ||||
| import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow'; | ||||
|  | ||||
| const getLaidOutElements = (nodes, edges) => { | ||||
| const getLayoutedElements = (nodes, edges) => { | ||||
|   const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); | ||||
|   graph.setGraph({ | ||||
|     rankdir: 'TB', | ||||
| @@ -36,18 +36,18 @@ export const useAutoLayout = () => { | ||||
|  | ||||
|   const onLayout = useCallback( | ||||
|     (nodes, edges) => { | ||||
|       const laidOutElements = getLaidOutElements(nodes, edges); | ||||
|       const layoutedElements = getLayoutedElements(nodes, edges); | ||||
|  | ||||
|       setNodes([ | ||||
|         ...laidOutElements.nodes.map((node) => ({ | ||||
|         ...layoutedElements.nodes.map((node) => ({ | ||||
|           ...node, | ||||
|           data: { ...node.data, laidOut: true }, | ||||
|           data: { ...node.data, layouted: true }, | ||||
|         })), | ||||
|       ]); | ||||
|       setEdges([ | ||||
|         ...laidOutElements.edges.map((edge) => ({ | ||||
|         ...layoutedElements.edges.map((edge) => ({ | ||||
|           ...edge, | ||||
|           data: { ...edge.data, laidOut: true }, | ||||
|           data: { ...edge.data, layouted: true }, | ||||
|         })), | ||||
|       ]); | ||||
|     }, | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { useEffect } from 'react'; | ||||
| import { useViewport, useReactFlow } from 'reactflow'; | ||||
| 
 | ||||
| export const useScrollBoundaries = () => { | ||||
| export const useScrollBoundries = () => { | ||||
|   const { setViewport } = useReactFlow(); | ||||
|   const { x, y, zoom } = useViewport(); | ||||
| 
 | ||||
| @@ -1,88 +0,0 @@ | ||||
| import { INVISIBLE_NODE_ID, NODE_TYPES } from './constants'; | ||||
|  | ||||
| export const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`; | ||||
|  | ||||
| export const updatedCollapsedNodes = (nodes, openStepId) => { | ||||
|   return nodes.map((node) => { | ||||
|     if (node.type !== NODE_TYPES.FLOW_STEP) { | ||||
|       return node; | ||||
|     } | ||||
|  | ||||
|     const collapsed = node.id !== openStepId; | ||||
|     return { | ||||
|       ...node, | ||||
|       zIndex: collapsed ? 0 : 1, | ||||
|       data: { ...node.data, collapsed }, | ||||
|     }; | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| export const generateInitialNodes = (flow) => { | ||||
|   const newNodes = flow.steps.map((step, index) => { | ||||
|     const collapsed = index !== 0; | ||||
|  | ||||
|     return { | ||||
|       id: step.id, | ||||
|       type: NODE_TYPES.FLOW_STEP, | ||||
|       position: { | ||||
|         x: 0, | ||||
|         y: 0, | ||||
|       }, | ||||
|       zIndex: collapsed ? 0 : 1, | ||||
|       data: { | ||||
|         collapsed, | ||||
|         laidOut: false, | ||||
|       }, | ||||
|     }; | ||||
|   }); | ||||
|  | ||||
|   return [ | ||||
|     ...newNodes, | ||||
|     { | ||||
|       id: INVISIBLE_NODE_ID, | ||||
|       type: NODE_TYPES.INVISIBLE, | ||||
|       position: { | ||||
|         x: 0, | ||||
|         y: 0, | ||||
|       }, | ||||
|     }, | ||||
|   ]; | ||||
| }; | ||||
|  | ||||
| export const generateInitialEdges = (flow) => { | ||||
|   const newEdges = flow.steps | ||||
|     .map((step, i) => { | ||||
|       const sourceId = step.id; | ||||
|       const targetId = flow.steps[i + 1]?.id; | ||||
|       if (targetId) { | ||||
|         return { | ||||
|           id: generateEdgeId(sourceId, targetId), | ||||
|           source: sourceId, | ||||
|           target: targetId, | ||||
|           type: 'addNodeEdge', | ||||
|           data: { | ||||
|             laidOut: false, | ||||
|           }, | ||||
|         }; | ||||
|       } | ||||
|       return null; | ||||
|     }) | ||||
|     .filter((edge) => !!edge); | ||||
|  | ||||
|   const lastStep = flow.steps[flow.steps.length - 1]; | ||||
|  | ||||
|   return lastStep | ||||
|     ? [ | ||||
|         ...newEdges, | ||||
|         { | ||||
|           id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID), | ||||
|           source: lastStep.id, | ||||
|           target: INVISIBLE_NODE_ID, | ||||
|           type: 'addNodeEdge', | ||||
|           data: { | ||||
|             laidOut: false, | ||||
|           }, | ||||
|         }, | ||||
|       ] | ||||
|     : newEdges; | ||||
| }; | ||||
| @@ -11,6 +11,9 @@ import IconButton from '@mui/material/IconButton'; | ||||
| import ErrorIcon from '@mui/icons-material/Error'; | ||||
| import CircularProgress from '@mui/material/CircularProgress'; | ||||
| import CheckCircleIcon from '@mui/icons-material/CheckCircle'; | ||||
| import { yupResolver } from '@hookform/resolvers/yup'; | ||||
| import * as yup from 'yup'; | ||||
|  | ||||
| import { EditorContext } from 'contexts/Editor'; | ||||
| import { StepExecutionsProvider } from 'contexts/StepExecutions'; | ||||
| import TestSubstep from 'components/TestSubstep'; | ||||
| @@ -30,18 +33,77 @@ import { | ||||
|   Header, | ||||
|   Wrapper, | ||||
| } from './style'; | ||||
| import isEmpty from 'helpers/isEmpty'; | ||||
| import { StepPropType } from 'propTypes/propTypes'; | ||||
| import useTriggers from 'hooks/useTriggers'; | ||||
| import useActions from 'hooks/useActions'; | ||||
| import useTriggerSubsteps from 'hooks/useTriggerSubsteps'; | ||||
| import useActionSubsteps from 'hooks/useActionSubsteps'; | ||||
| import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions'; | ||||
| import { validationSchemaResolver } from './validation'; | ||||
| import { isEqual } from 'lodash'; | ||||
|  | ||||
| const validIcon = <CheckCircleIcon color="success" />; | ||||
| const errorIcon = <ErrorIcon color="error" />; | ||||
|  | ||||
| function generateValidationSchema(substeps) { | ||||
|   const fieldValidations = substeps?.reduce( | ||||
|     (allValidations, { arguments: args }) => { | ||||
|       if (!args || !Array.isArray(args)) return allValidations; | ||||
|       const substepArgumentValidations = {}; | ||||
|       for (const arg of args) { | ||||
|         const { key, required } = arg; | ||||
|         // base validation for the field if not exists | ||||
|         if (!substepArgumentValidations[key]) { | ||||
|           substepArgumentValidations[key] = yup.mixed(); | ||||
|         } | ||||
|         if ( | ||||
|           typeof substepArgumentValidations[key] === 'object' && | ||||
|           (arg.type === 'string' || arg.type === 'dropdown') | ||||
|         ) { | ||||
|           // if the field is required, add the required validation | ||||
|           if (required) { | ||||
|             substepArgumentValidations[key] = substepArgumentValidations[key] | ||||
|               .required(`${key} is required.`) | ||||
|               .test( | ||||
|                 'empty-check', | ||||
|                 `${key} must be not empty`, | ||||
|                 (value) => !isEmpty(value), | ||||
|               ); | ||||
|           } | ||||
|           // if the field depends on another field, add the dependsOn required validation | ||||
|           if (Array.isArray(arg.dependsOn) && arg.dependsOn.length > 0) { | ||||
|             for (const dependsOnKey of arg.dependsOn) { | ||||
|               const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`; | ||||
|               // 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) => Boolean(value) === false, | ||||
|                 then: (schema) => | ||||
|                   schema | ||||
|                     .notOneOf([''], missingDependencyValueMessage) | ||||
|                     .required(missingDependencyValueMessage), | ||||
|               }); | ||||
|             } | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       return { | ||||
|         ...allValidations, | ||||
|         ...substepArgumentValidations, | ||||
|       }; | ||||
|     }, | ||||
|     {}, | ||||
|   ); | ||||
|  | ||||
|   const validationSchema = yup.object({ | ||||
|     parameters: yup.object(fieldValidations), | ||||
|   }); | ||||
|  | ||||
|   return yupResolver(validationSchema); | ||||
| } | ||||
|  | ||||
| function FlowStep(props) { | ||||
|   const { collapsed, onChange, onContinue, flowId } = props; | ||||
|   const editorContext = React.useContext(EditorContext); | ||||
| @@ -52,10 +114,6 @@ function FlowStep(props) { | ||||
|   const isAction = step.type === 'action'; | ||||
|   const formatMessage = useFormatMessage(); | ||||
|   const [currentSubstep, setCurrentSubstep] = React.useState(0); | ||||
|   const [formResolverContext, setFormResolverContext] = React.useState({ | ||||
|     substeps: [], | ||||
|     additionalFields: {}, | ||||
|   }); | ||||
|   const useAppsOptions = {}; | ||||
|  | ||||
|   if (isTrigger) { | ||||
| @@ -110,12 +168,6 @@ function FlowStep(props) { | ||||
|       ? triggerSubstepsData | ||||
|       : actionSubstepsData || []; | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     if (!isEqual(substeps, formResolverContext.substeps)) { | ||||
|       setFormResolverContext({ substeps, additionalFields: {} }); | ||||
|     } | ||||
|   }, [substeps]); | ||||
|  | ||||
|   const handleChange = React.useCallback(({ step }) => { | ||||
|     onChange(step); | ||||
|   }, []); | ||||
| @@ -128,6 +180,11 @@ function FlowStep(props) { | ||||
|     handleChange({ step: val }); | ||||
|   }; | ||||
|  | ||||
|   const stepValidationSchema = React.useMemo( | ||||
|     () => generateValidationSchema(substeps), | ||||
|     [substeps], | ||||
|   ); | ||||
|  | ||||
|   if (!apps?.data) { | ||||
|     return ( | ||||
|       <CircularProgress | ||||
| @@ -156,15 +213,6 @@ function FlowStep(props) { | ||||
|       value !== substepIndex ? substepIndex : null, | ||||
|     ); | ||||
|  | ||||
|   const addAdditionalFieldsValidation = (additionalFields) => { | ||||
|     if (additionalFields) { | ||||
|       setFormResolverContext((prev) => ({ | ||||
|         ...prev, | ||||
|         additionalFields: { ...prev.additionalFields, ...additionalFields }, | ||||
|       })); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const validationStatusIcon = | ||||
|     step.status === 'completed' ? validIcon : errorIcon; | ||||
|  | ||||
| @@ -218,8 +266,7 @@ function FlowStep(props) { | ||||
|               <Form | ||||
|                 defaultValues={step} | ||||
|                 onSubmit={handleSubmit} | ||||
|                 resolver={validationSchemaResolver} | ||||
|                 context={formResolverContext} | ||||
|                 resolver={stepValidationSchema} | ||||
|               > | ||||
|                 <ChooseAppAndEventSubstep | ||||
|                   expanded={currentSubstep === 0} | ||||
| @@ -283,9 +330,6 @@ function FlowStep(props) { | ||||
|                             onSubmit={expandNextStep} | ||||
|                             onChange={handleChange} | ||||
|                             step={step} | ||||
|                             addAdditionalFieldsValidation={ | ||||
|                               addAdditionalFieldsValidation | ||||
|                             } | ||||
|                           /> | ||||
|                         )} | ||||
|                     </React.Fragment> | ||||
| @@ -316,6 +360,7 @@ function FlowStep(props) { | ||||
| FlowStep.propTypes = { | ||||
|   collapsed: PropTypes.bool, | ||||
|   step: StepPropType.isRequired, | ||||
|   index: PropTypes.number, | ||||
|   onOpen: PropTypes.func, | ||||
|   onClose: PropTypes.func, | ||||
|   onChange: PropTypes.func.isRequired, | ||||
|   | ||||
| @@ -1,120 +0,0 @@ | ||||
| import * as yup from 'yup'; | ||||
| import { yupResolver } from '@hookform/resolvers/yup'; | ||||
| import isEmpty from 'helpers/isEmpty'; | ||||
|  | ||||
| function addRequiredValidation({ required, schema, key }) { | ||||
|   // if the field is required, add the required validation | ||||
|   if (required) { | ||||
|     return schema | ||||
|       .required(`${key} is required.`) | ||||
|       .test( | ||||
|         'empty-check', | ||||
|         `${key} must be not empty`, | ||||
|         (value) => !isEmpty(value), | ||||
|       ); | ||||
|   } | ||||
|   return schema; | ||||
| } | ||||
|  | ||||
| function addDependsOnValidation({ schema, dependsOn, key, args }) { | ||||
|   // if the field depends on another field, add the dependsOn required validation | ||||
|   if (Array.isArray(dependsOn) && dependsOn.length > 0) { | ||||
|     for (const dependsOnKey of dependsOn) { | ||||
|       const dependsOnKeyShort = dependsOnKey.replace('parameters.', ''); | ||||
|       const dependsOnField = args.find(({ key }) => key === dependsOnKeyShort); | ||||
|  | ||||
|       if (dependsOnField?.required) { | ||||
|         const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`; | ||||
|  | ||||
|         // 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. | ||||
|         return schema.when(dependsOnKeyShort, { | ||||
|           is: (dependsOnValue) => Boolean(dependsOnValue) === false, | ||||
|           then: (schema) => | ||||
|             schema | ||||
|               .notOneOf([''], missingDependencyValueMessage) | ||||
|               .required(missingDependencyValueMessage), | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|   return schema; | ||||
| } | ||||
|  | ||||
| export function validationSchemaResolver(data, context, options) { | ||||
|   const { substeps = [], additionalFields = {} } = context; | ||||
|  | ||||
|   const fieldValidations = [ | ||||
|     ...substeps, | ||||
|     { | ||||
|       arguments: Object.values(additionalFields) | ||||
|         .filter((field) => !!field) | ||||
|         .flat(), | ||||
|     }, | ||||
|   ].reduce((allValidations, { arguments: args }) => { | ||||
|     if (!args || !Array.isArray(args)) return allValidations; | ||||
|  | ||||
|     const substepArgumentValidations = {}; | ||||
|  | ||||
|     for (const arg of args) { | ||||
|       const { key, required } = arg; | ||||
|  | ||||
|       // base validation for the field if not exists | ||||
|       if (!substepArgumentValidations[key]) { | ||||
|         substepArgumentValidations[key] = yup.mixed(); | ||||
|       } | ||||
|  | ||||
|       if (arg.type === 'dynamic') { | ||||
|         const fieldsSchema = {}; | ||||
|  | ||||
|         for (const field of arg.fields) { | ||||
|           fieldsSchema[field.key] = yup.mixed(); | ||||
|  | ||||
|           fieldsSchema[field.key] = addRequiredValidation({ | ||||
|             required: field.required, | ||||
|             schema: fieldsSchema[field.key], | ||||
|             key: field.key, | ||||
|           }); | ||||
|  | ||||
|           fieldsSchema[field.key] = addDependsOnValidation({ | ||||
|             schema: fieldsSchema[field.key], | ||||
|             dependsOn: field.dependsOn, | ||||
|             key: field.key, | ||||
|             args, | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         substepArgumentValidations[key] = yup | ||||
|           .array() | ||||
|           .of(yup.object(fieldsSchema)); | ||||
|       } else if ( | ||||
|         typeof substepArgumentValidations[key] === 'object' && | ||||
|         (arg.type === 'string' || arg.type === 'dropdown') | ||||
|       ) { | ||||
|         substepArgumentValidations[key] = addRequiredValidation({ | ||||
|           required, | ||||
|           schema: substepArgumentValidations[key], | ||||
|           key, | ||||
|         }); | ||||
|  | ||||
|         substepArgumentValidations[key] = addDependsOnValidation({ | ||||
|           schema: substepArgumentValidations[key], | ||||
|           dependsOn: arg.dependsOn, | ||||
|           key, | ||||
|           args, | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|       ...allValidations, | ||||
|       ...substepArgumentValidations, | ||||
|     }; | ||||
|   }, {}); | ||||
|  | ||||
|   const validationSchema = yup.object({ | ||||
|     parameters: yup.object(fieldValidations), | ||||
|   }); | ||||
|  | ||||
|   return yupResolver(validationSchema)(data, context, options); | ||||
| } | ||||
| @@ -43,10 +43,7 @@ function FlowStepContextMenu(props) { | ||||
| FlowStepContextMenu.propTypes = { | ||||
|   stepId: PropTypes.string.isRequired, | ||||
|   onClose: PropTypes.func.isRequired, | ||||
|   anchorEl: PropTypes.oneOfType([ | ||||
|     PropTypes.func, | ||||
|     PropTypes.shape({ current: PropTypes.instanceOf(Element) }), | ||||
|   ]).isRequired, | ||||
|   anchorEl: PropTypes.element.isRequired, | ||||
|   deletable: PropTypes.bool.isRequired, | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -19,9 +19,7 @@ function FlowSubstep(props) { | ||||
|     onCollapse, | ||||
|     onSubmit, | ||||
|     step, | ||||
|     addAdditionalFieldsValidation, | ||||
|   } = props; | ||||
|  | ||||
|   const { name, arguments: args } = substep; | ||||
|   const editorContext = React.useContext(EditorContext); | ||||
|   const formContext = useFormContext(); | ||||
| @@ -56,7 +54,6 @@ function FlowSubstep(props) { | ||||
|                   stepId={step.id} | ||||
|                   disabled={editorContext.readOnly} | ||||
|                   showOptionValue={true} | ||||
|                   addAdditionalFieldsValidation={addAdditionalFieldsValidation} | ||||
|                 /> | ||||
|               ))} | ||||
|             </Stack> | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| import * as React from 'react'; | ||||
| import { FormProvider, useForm, useWatch } from 'react-hook-form'; | ||||
|  | ||||
| const noop = () => null; | ||||
|  | ||||
| export default function Form(props) { | ||||
|   const { | ||||
|     children, | ||||
| @@ -11,31 +9,24 @@ export default function Form(props) { | ||||
|     resolver, | ||||
|     render, | ||||
|     mode = 'all', | ||||
|     context, | ||||
|     ...formProps | ||||
|   } = props; | ||||
|  | ||||
|   const methods = useForm({ | ||||
|     defaultValues, | ||||
|     reValidateMode: 'onBlur', | ||||
|     resolver, | ||||
|     mode, | ||||
|     context, | ||||
|   }); | ||||
|  | ||||
|   const form = useWatch({ control: methods.control }); | ||||
|  | ||||
|   /** | ||||
|    * For fields having `dependsOn` fields, we need to re-validate the form. | ||||
|    */ | ||||
|   React.useEffect(() => { | ||||
|     methods.trigger(); | ||||
|   }, [methods.trigger, form]); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     methods.reset(defaultValues); | ||||
|   }, [defaultValues]); | ||||
|  | ||||
|   return ( | ||||
|     <FormProvider {...methods}> | ||||
|       <form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}> | ||||
|   | ||||
| @@ -23,9 +23,7 @@ export default function InputCreator(props) { | ||||
|     disabled, | ||||
|     showOptionValue, | ||||
|     shouldUnregister, | ||||
|     addAdditionalFieldsValidation, | ||||
|   } = props; | ||||
|  | ||||
|   const { | ||||
|     key: name, | ||||
|     label, | ||||
| @@ -35,7 +33,6 @@ export default function InputCreator(props) { | ||||
|     description, | ||||
|     type, | ||||
|   } = schema; | ||||
|  | ||||
|   const { data, loading } = useDynamicData(stepId, schema); | ||||
|   const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } = | ||||
|     useDynamicFields(stepId, schema); | ||||
| @@ -43,10 +40,6 @@ export default function InputCreator(props) { | ||||
|  | ||||
|   const computedName = namePrefix ? `${namePrefix}.${name}` : name; | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     addAdditionalFieldsValidation?.({ [name]: additionalFields }); | ||||
|   }, [additionalFields]); | ||||
|  | ||||
|   if (type === 'dynamic') { | ||||
|     return ( | ||||
|       <DynamicField | ||||
|   | ||||
| @@ -5,10 +5,8 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'; | ||||
| import CardActionArea from '@mui/material/CardActionArea'; | ||||
| import Typography from '@mui/material/Typography'; | ||||
| import { CardContent } from './style'; | ||||
|  | ||||
| export default function NoResultFound(props) { | ||||
|   const { text, to } = props; | ||||
|  | ||||
|   const ActionAreaLink = React.useMemo( | ||||
|     () => | ||||
|       React.forwardRef(function InlineLink(linkProps, ref) { | ||||
| @@ -17,12 +15,12 @@ export default function NoResultFound(props) { | ||||
|       }), | ||||
|     [to], | ||||
|   ); | ||||
|  | ||||
|   return ( | ||||
|     <Card elevation={0}> | ||||
|       <CardActionArea component={ActionAreaLink} {...props}> | ||||
|         <CardContent> | ||||
|           {!!to && <AddCircleIcon color="primary" />} | ||||
|  | ||||
|           <Typography variant="body1">{text}</Typography> | ||||
|         </CardContent> | ||||
|       </CardActionArea> | ||||
|   | ||||
| @@ -7,7 +7,6 @@ import { useQuery } from '@tanstack/react-query'; | ||||
| import api from 'helpers/api'; | ||||
|  | ||||
| const variableRegExp = /({.*?})/; | ||||
|  | ||||
| // TODO: extract this function to a separate file | ||||
| function computeArguments(args, getValues) { | ||||
|   const initialValue = {}; | ||||
|   | ||||
| @@ -1,6 +1,4 @@ | ||||
| import { createRoot } from 'react-dom/client'; | ||||
| import { Settings } from 'luxon'; | ||||
|  | ||||
| import ThemeProvider from 'components/ThemeProvider'; | ||||
| import IntlProvider from 'components/IntlProvider'; | ||||
| import ApolloProvider from 'components/ApolloProvider'; | ||||
| @@ -12,9 +10,6 @@ import Router from 'components/Router'; | ||||
| import routes from 'routes'; | ||||
| import reportWebVitals from './reportWebVitals'; | ||||
|  | ||||
| // Sets the default locale to English for all luxon DateTime instances created afterwards. | ||||
| Settings.defaultLocale = 'en'; | ||||
|  | ||||
| const container = document.getElementById('root'); | ||||
| const root = createRoot(container); | ||||
|  | ||||
|   | ||||
| @@ -30,7 +30,6 @@ import AppIcon from 'components/AppIcon'; | ||||
| import Container from 'components/Container'; | ||||
| import PageTitle from 'components/PageTitle'; | ||||
| import useApp from 'hooks/useApp'; | ||||
| import Can from 'components/Can'; | ||||
|  | ||||
| const ReconnectConnection = (props) => { | ||||
|   const { application, onClose } = props; | ||||
| @@ -93,7 +92,7 @@ export default function Application() { | ||||
|     } | ||||
|  | ||||
|     return options; | ||||
|   }, [appKey, appConfig?.data, currentUserAbility, formatMessage]); | ||||
|   }, [appKey, appConfig?.data, currentUserAbility]); | ||||
|  | ||||
|   if (loading) return null; | ||||
|  | ||||
| @@ -119,8 +118,6 @@ export default function Application() { | ||||
|                 <Route | ||||
|                   path={`${URLS.FLOWS}/*`} | ||||
|                   element={ | ||||
|                     <Can I="create" a="Flow" passThrough> | ||||
|                       {(allowed) => ( | ||||
|                     <ConditionalIconButton | ||||
|                       type="submit" | ||||
|                       variant="contained" | ||||
| @@ -133,23 +130,18 @@ export default function Application() { | ||||
|                       )} | ||||
|                       fullWidth | ||||
|                       icon={<AddIcon />} | ||||
|                           disabled={!allowed} | ||||
|                       disabled={!currentUserAbility.can('create', 'Flow')} | ||||
|                     > | ||||
|                       {formatMessage('app.createFlow')} | ||||
|                     </ConditionalIconButton> | ||||
|                       )} | ||||
|                     </Can> | ||||
|                   } | ||||
|                 /> | ||||
|  | ||||
|                 <Route | ||||
|                   path={`${URLS.CONNECTIONS}/*`} | ||||
|                   element={ | ||||
|                     <Can I="create" a="Connection" passThrough> | ||||
|                       {(allowed) => ( | ||||
|                     <SplitButton | ||||
|                       disabled={ | ||||
|                             !allowed || | ||||
|                         (appConfig?.data && | ||||
|                           !appConfig?.data?.canConnect && | ||||
|                           !appConfig?.data?.canCustomConnect) || | ||||
| @@ -157,8 +149,6 @@ export default function Application() { | ||||
|                       } | ||||
|                       options={connectionOptions} | ||||
|                     /> | ||||
|                       )} | ||||
|                     </Can> | ||||
|                   } | ||||
|                 /> | ||||
|               </Routes> | ||||
| @@ -179,20 +169,17 @@ export default function Application() { | ||||
|                     label={formatMessage('app.connections')} | ||||
|                     to={URLS.APP_CONNECTIONS(appKey)} | ||||
|                     value={URLS.APP_CONNECTIONS_PATTERN} | ||||
|                     disabled={ | ||||
|                       !currentUserAbility.can('read', 'Connection') || | ||||
|                       !app.supportsConnections | ||||
|                     } | ||||
|                     disabled={!app.supportsConnections} | ||||
|                     component={Link} | ||||
|                     data-test="connections-tab" | ||||
|                   /> | ||||
|  | ||||
|                   <Tab | ||||
|                     label={formatMessage('app.flows')} | ||||
|                     to={URLS.APP_FLOWS(appKey)} | ||||
|                     value={URLS.APP_FLOWS_PATTERN} | ||||
|                     component={Link} | ||||
|                     data-test="flows-tab" | ||||
|                     disabled={!currentUserAbility.can('read', 'Flow')} | ||||
|                   /> | ||||
|                 </Tabs> | ||||
|               </Box> | ||||
| @@ -200,20 +187,14 @@ export default function Application() { | ||||
|               <Routes> | ||||
|                 <Route | ||||
|                   path={`${URLS.FLOWS}/*`} | ||||
|                   element={ | ||||
|                     <Can I="read" a="Flow"> | ||||
|                       <AppFlows appKey={appKey} /> | ||||
|                     </Can> | ||||
|                   } | ||||
|                   element={<AppFlows appKey={appKey} />} | ||||
|                 /> | ||||
|  | ||||
|                 <Route | ||||
|                   path={`${URLS.CONNECTIONS}/*`} | ||||
|                   element={ | ||||
|                     <Can I="read" a="Connection"> | ||||
|                       <AppConnections appKey={appKey} /> | ||||
|                     </Can> | ||||
|                   } | ||||
|                   element={<AppConnections appKey={appKey} />} | ||||
|                 /> | ||||
|  | ||||
|                 <Route | ||||
|                   path="/" | ||||
|                   element={ | ||||
| @@ -237,24 +218,17 @@ export default function Application() { | ||||
|         <Route | ||||
|           path="/connections/add" | ||||
|           element={ | ||||
|             <Can I="create" a="Connection"> | ||||
|               <AddAppConnection | ||||
|                 onClose={goToApplicationPage} | ||||
|                 application={app} | ||||
|               /> | ||||
|             </Can> | ||||
|             <AddAppConnection onClose={goToApplicationPage} application={app} /> | ||||
|           } | ||||
|         /> | ||||
|  | ||||
|         <Route | ||||
|           path="/connections/:connectionId/reconnect" | ||||
|           element={ | ||||
|             <Can I="create" a="Connection"> | ||||
|             <ReconnectConnection | ||||
|               application={app} | ||||
|               onClose={goToApplicationPage} | ||||
|             /> | ||||
|             </Can> | ||||
|           } | ||||
|         /> | ||||
|       </Routes> | ||||
|   | ||||
| @@ -84,15 +84,11 @@ export default function Applications() { | ||||
|         )} | ||||
|  | ||||
|         {!isLoading && !hasApps && ( | ||||
|           <Can I="create" a="Connection" passThrough> | ||||
|             {(allowed) => ( | ||||
|           <NoResultFound | ||||
|             text={formatMessage('apps.noConnections')} | ||||
|                 {...(allowed && { to: URLS.NEW_APP_CONNECTION })} | ||||
|             to={URLS.NEW_APP_CONNECTION} | ||||
|           /> | ||||
|         )} | ||||
|           </Can> | ||||
|         )} | ||||
|  | ||||
|         {!isLoading && | ||||
|           apps?.map((app) => ( | ||||
|   | ||||
| @@ -7,15 +7,13 @@ import * as URLS from 'config/urls'; | ||||
| import useFormatMessage from 'hooks/useFormatMessage'; | ||||
| import { CREATE_FLOW } from 'graphql/mutations/create-flow'; | ||||
| import Box from '@mui/material/Box'; | ||||
|  | ||||
| export default function CreateFlow() { | ||||
|   const [searchParams] = useSearchParams(); | ||||
|   const navigate = useNavigate(); | ||||
|   const formatMessage = useFormatMessage(); | ||||
|   const [createFlow, { error }] = useMutation(CREATE_FLOW); | ||||
|   const [createFlow] = useMutation(CREATE_FLOW); | ||||
|   const appKey = searchParams.get('appKey'); | ||||
|   const connectionId = searchParams.get('connectionId'); | ||||
|  | ||||
|   React.useEffect(() => { | ||||
|     async function initiate() { | ||||
|       const variables = {}; | ||||
| @@ -35,11 +33,6 @@ export default function CreateFlow() { | ||||
|     } | ||||
|     initiate(); | ||||
|   }, [createFlow, navigate, appKey, connectionId]); | ||||
|  | ||||
|   if (error) { | ||||
|     return null; | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <Box | ||||
|       sx={{ | ||||
| @@ -52,6 +45,7 @@ export default function CreateFlow() { | ||||
|       }} | ||||
|     > | ||||
|       <CircularProgress size={16} thickness={7.5} /> | ||||
|  | ||||
|       <Typography variant="body2"> | ||||
|         {formatMessage('createFlow.creating')} | ||||
|       </Typography> | ||||
|   | ||||
| @@ -17,7 +17,6 @@ import Container from 'components/Container'; | ||||
| import PageTitle from 'components/PageTitle'; | ||||
| import SearchInput from 'components/SearchInput'; | ||||
| import useFormatMessage from 'hooks/useFormatMessage'; | ||||
| import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; | ||||
| import * as URLS from 'config/urls'; | ||||
| import useLazyFlows from 'hooks/useLazyFlows'; | ||||
|  | ||||
| @@ -27,7 +26,6 @@ export default function Flows() { | ||||
|   const page = parseInt(searchParams.get('page') || '', 10) || 1; | ||||
|   const [flowName, setFlowName] = React.useState(''); | ||||
|   const [isLoading, setIsLoading] = React.useState(false); | ||||
|   const currentUserAbility = useCurrentUserAbility(); | ||||
|  | ||||
|   const { data, mutate: fetchFlows } = useLazyFlows( | ||||
|     { flowName, page }, | ||||
| @@ -126,9 +124,7 @@ export default function Flows() { | ||||
|         {!isLoading && !hasFlows && ( | ||||
|           <NoResultFound | ||||
|             text={formatMessage('flows.noFlows')} | ||||
|             {...(currentUserAbility.can('create', 'Flow') && { | ||||
|               to: URLS.CREATE_FLOW, | ||||
|             })} | ||||
|             to={URLS.CREATE_FLOW} | ||||
|           /> | ||||
|         )} | ||||
|         {!isLoading && pageInfo && pageInfo.totalPages > 1 && ( | ||||
|   | ||||
		Reference in New Issue
	
	Block a user