Compare commits
	
		
			2 Commits
		
	
	
		
			AUT-157-AU
			...
			AUT-1023
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 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,3 @@ | |||||||
|  | import listOrganizations from './list-organizations/index.js'; | ||||||
|  |  | ||||||
|  | export default [listOrganizations]; | ||||||
| @@ -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, | ||||||
|  | }); | ||||||
							
								
								
									
										3
									
								
								packages/backend/src/apps/eventbrite/triggers/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								packages/backend/src/apps/eventbrite/triggers/index.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | import newEvents from './new-events/index.js'; | ||||||
|  |  | ||||||
|  | export default [newEvents]; | ||||||
| @@ -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 }, |       fullName: { type: 'string', minLength: 1 }, | ||||||
|       email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, |       email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 }, | ||||||
|       password: { type: 'string' }, |       password: { type: 'string' }, | ||||||
|       resetPasswordToken: { type: ['string', 'null'] }, |       resetPasswordToken: { type: 'string' }, | ||||||
|       resetPasswordTokenSentAt: { type: ['string', 'null'], format: 'date-time' }, |       resetPasswordTokenSentAt: { type: 'string' }, | ||||||
|       trialExpiryDate: { type: 'string' }, |       trialExpiryDate: { type: 'string' }, | ||||||
|       roleId: { type: 'string', format: 'uuid' }, |       roleId: { type: 'string', format: 'uuid' }, | ||||||
|       deletedAt: { type: 'string' }, |       deletedAt: { type: 'string' }, | ||||||
|   | |||||||
| @@ -40,7 +40,6 @@ export const worker = new Worker( | |||||||
|       await user.$relatedQuery('usageData').withSoftDeleted().hardDelete(); |       await user.$relatedQuery('usageData').withSoftDeleted().hardDelete(); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete(); |  | ||||||
|     await user.$query().withSoftDeleted().hardDelete(); |     await user.$query().withSoftDeleted().hardDelete(); | ||||||
|   }, |   }, | ||||||
|   { connection: redisConfig } |   { connection: redisConfig } | ||||||
|   | |||||||
| @@ -113,6 +113,15 @@ export default defineConfig({ | |||||||
|             { text: 'Connection', link: '/apps/dropbox/connection' }, |             { 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', |           text: 'Filter', | ||||||
|           collapsible: true, |           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. | ||||||
							
								
								
									
										12
									
								
								packages/docs/pages/apps/eventbrite/triggers.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								packages/docs/pages/apps/eventbrite/triggers.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | --- | ||||||
|  | favicon: /favicons/eventbrite.svg | ||||||
|  | items: | ||||||
|  |   - 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 | ├── packages | ||||||
| │   ├── backend | │   ├── backend | ||||||
|  | │   ├── cli | ||||||
| │   ├── docs | │   ├── docs | ||||||
| │   ├── e2e-tests | │   ├── e2e-tests | ||||||
|  | │   ├── types | ||||||
| │   └── web | │   └── web | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| - `backend` - The backend package contains the backend application and all integrations. | - `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. | - `docs` - The docs package contains the documentation website. | ||||||
| - `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage. | - `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. | - `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) | - [Discord](/apps/discord/actions) | ||||||
| - [Disqus](/apps/disqus/triggers) | - [Disqus](/apps/disqus/triggers) | ||||||
| - [Dropbox](/apps/dropbox/actions) | - [Dropbox](/apps/dropbox/actions) | ||||||
|  | - [Eventbrite](/apps/eventbrite/triggers) | ||||||
| - [Filter](/apps/filter/actions) | - [Filter](/apps/filter/actions) | ||||||
| - [Flickr](/apps/flickr/triggers) | - [Flickr](/apps/flickr/triggers) | ||||||
| - [Formatter](/apps/formatter/actions) | - [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 = { | AccountDropdownMenu.propTypes = { | ||||||
|   open: PropTypes.bool.isRequired, |   open: PropTypes.bool.isRequired, | ||||||
|   onClose: PropTypes.func.isRequired, |   onClose: PropTypes.func.isRequired, | ||||||
|   anchorEl: PropTypes.oneOfType([ |   anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]), | ||||||
|     PropTypes.func, |  | ||||||
|     PropTypes.shape({ current: PropTypes.instanceOf(Element) }), |  | ||||||
|   ]), |  | ||||||
|   id: PropTypes.string.isRequired, |   id: PropTypes.string.isRequired, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,7 +8,6 @@ import * as URLS from 'config/urls'; | |||||||
| import useFormatMessage from 'hooks/useFormatMessage'; | import useFormatMessage from 'hooks/useFormatMessage'; | ||||||
| import { ConnectionPropType } from 'propTypes/propTypes'; | import { ConnectionPropType } from 'propTypes/propTypes'; | ||||||
| import { useQueryClient } from '@tanstack/react-query'; | import { useQueryClient } from '@tanstack/react-query'; | ||||||
| import Can from 'components/Can'; |  | ||||||
|  |  | ||||||
| function ContextMenu(props) { | function ContextMenu(props) { | ||||||
|   const { |   const { | ||||||
| @@ -45,57 +44,34 @@ function ContextMenu(props) { | |||||||
|       hideBackdrop={false} |       hideBackdrop={false} | ||||||
|       anchorEl={anchorEl} |       anchorEl={anchorEl} | ||||||
|     > |     > | ||||||
|       <Can I="read" a="Flow" passThrough> |       <MenuItem | ||||||
|         {(allowed) => ( |         component={Link} | ||||||
|           <MenuItem |         to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)} | ||||||
|             component={Link} |         onClick={createActionHandler({ type: 'viewFlows' })} | ||||||
|             to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)} |       > | ||||||
|             onClick={createActionHandler({ type: 'viewFlows' })} |         {formatMessage('connection.viewFlows')} | ||||||
|             disabled={!allowed} |       </MenuItem> | ||||||
|           > |  | ||||||
|             {formatMessage('connection.viewFlows')} |  | ||||||
|           </MenuItem> |  | ||||||
|         )} |  | ||||||
|       </Can> |  | ||||||
|  |  | ||||||
|       <Can I="update" a="Connection" passThrough> |       <MenuItem onClick={createActionHandler({ type: 'test' })}> | ||||||
|         {(allowed) => ( |         {formatMessage('connection.testConnection')} | ||||||
|           <MenuItem |       </MenuItem> | ||||||
|             onClick={createActionHandler({ type: 'test' })} |  | ||||||
|             disabled={!allowed} |  | ||||||
|           > |  | ||||||
|             {formatMessage('connection.testConnection')} |  | ||||||
|           </MenuItem> |  | ||||||
|         )} |  | ||||||
|       </Can> |  | ||||||
|  |  | ||||||
|       <Can I="create" a="Connection" passThrough> |       <MenuItem | ||||||
|         {(allowed) => ( |         component={Link} | ||||||
|           <MenuItem |         disabled={disableReconnection} | ||||||
|             component={Link} |         to={URLS.APP_RECONNECT_CONNECTION( | ||||||
|             disabled={!allowed || disableReconnection} |           appKey, | ||||||
|             to={URLS.APP_RECONNECT_CONNECTION( |           connection.id, | ||||||
|               appKey, |           connection.appAuthClientId, | ||||||
|               connection.id, |  | ||||||
|               connection.appAuthClientId, |  | ||||||
|             )} |  | ||||||
|             onClick={createActionHandler({ type: 'reconnect' })} |  | ||||||
|           > |  | ||||||
|             {formatMessage('connection.reconnect')} |  | ||||||
|           </MenuItem> |  | ||||||
|         )} |         )} | ||||||
|       </Can> |         onClick={createActionHandler({ type: 'reconnect' })} | ||||||
|  |       > | ||||||
|  |         {formatMessage('connection.reconnect')} | ||||||
|  |       </MenuItem> | ||||||
|  |  | ||||||
|       <Can I="delete" a="Connection" passThrough> |       <MenuItem onClick={createActionHandler({ type: 'delete' })}> | ||||||
|         {(allowed) => ( |         {formatMessage('connection.delete')} | ||||||
|           <MenuItem |       </MenuItem> | ||||||
|             onClick={createActionHandler({ type: 'delete' })} |  | ||||||
|             disabled={!allowed} |  | ||||||
|           > |  | ||||||
|             {formatMessage('connection.delete')} |  | ||||||
|           </MenuItem> |  | ||||||
|         )} |  | ||||||
|       </Can> |  | ||||||
|     </Menu> |     </Menu> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; | |||||||
|  |  | ||||||
| import AppConnectionRow from 'components/AppConnectionRow'; | import AppConnectionRow from 'components/AppConnectionRow'; | ||||||
| import NoResultFound from 'components/NoResultFound'; | import NoResultFound from 'components/NoResultFound'; | ||||||
| import Can from 'components/Can'; |  | ||||||
| import useFormatMessage from 'hooks/useFormatMessage'; | import useFormatMessage from 'hooks/useFormatMessage'; | ||||||
| import * as URLS from 'config/urls'; | import * as URLS from 'config/urls'; | ||||||
| import useAppConnections from 'hooks/useAppConnections'; | import useAppConnections from 'hooks/useAppConnections'; | ||||||
| @@ -17,15 +16,11 @@ function AppConnections(props) { | |||||||
|  |  | ||||||
|   if (!hasConnections) { |   if (!hasConnections) { | ||||||
|     return ( |     return ( | ||||||
|       <Can I="create" a="Connection" passThrough> |       <NoResultFound | ||||||
|         {(allowed) => ( |         to={URLS.APP_ADD_CONNECTION(appKey)} | ||||||
|           <NoResultFound |         text={formatMessage('app.noConnections')} | ||||||
|             text={formatMessage('app.noConnections')} |         data-test="connections-no-results" | ||||||
|             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 * as URLS from 'config/urls'; | ||||||
| import AppFlowRow from 'components/FlowRow'; | import AppFlowRow from 'components/FlowRow'; | ||||||
| import Can from 'components/Can'; |  | ||||||
| import NoResultFound from 'components/NoResultFound'; | import NoResultFound from 'components/NoResultFound'; | ||||||
| import useFormatMessage from 'hooks/useFormatMessage'; | import useFormatMessage from 'hooks/useFormatMessage'; | ||||||
| import useConnectionFlows from 'hooks/useConnectionFlows'; | import useConnectionFlows from 'hooks/useConnectionFlows'; | ||||||
| @@ -37,20 +36,11 @@ function AppFlows(props) { | |||||||
|  |  | ||||||
|   if (!hasFlows) { |   if (!hasFlows) { | ||||||
|     return ( |     return ( | ||||||
|       <Can I="create" a="Flow" passThrough> |       <NoResultFound | ||||||
|         {(allowed) => ( |         to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(appKey, connectionId)} | ||||||
|           <NoResultFound |         text={formatMessage('app.noFlows')} | ||||||
|             text={formatMessage('app.noFlows')} |         data-test="flows-no-results" | ||||||
|             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, |     label, | ||||||
|     initialTabIndex, |     initialTabIndex, | ||||||
|   } = props; |   } = props; | ||||||
|  |  | ||||||
|   const [activeTabIndex, setActiveTabIndex] = React.useState(undefined); |   const [activeTabIndex, setActiveTabIndex] = React.useState(undefined); | ||||||
|  |  | ||||||
|   React.useEffect( |   React.useEffect( | ||||||
|     function applyInitialActiveTabIndex() { |     function applyInitialActiveTabIndex() { | ||||||
|       setActiveTabIndex((currentActiveTabIndex) => { |       setActiveTabIndex((currentActiveTabIndex) => { | ||||||
| @@ -35,7 +33,6 @@ const CustomOptions = (props) => { | |||||||
|     }, |     }, | ||||||
|     [initialTabIndex], |     [initialTabIndex], | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Popper |     <Popper | ||||||
|       open={open} |       open={open} | ||||||
| @@ -79,10 +76,7 @@ const CustomOptions = (props) => { | |||||||
|  |  | ||||||
| CustomOptions.propTypes = { | CustomOptions.propTypes = { | ||||||
|   open: PropTypes.bool.isRequired, |   open: PropTypes.bool.isRequired, | ||||||
|   anchorEl: PropTypes.oneOfType([ |   anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired, | ||||||
|     PropTypes.func, |  | ||||||
|     PropTypes.shape({ current: PropTypes.instanceOf(Element) }), |  | ||||||
|   ]), |  | ||||||
|   data: PropTypes.arrayOf( |   data: PropTypes.arrayOf( | ||||||
|     PropTypes.shape({ |     PropTypes.shape({ | ||||||
|       id: PropTypes.string.isRequired, |       id: PropTypes.string.isRequired, | ||||||
|   | |||||||
| @@ -61,7 +61,6 @@ function ControlledCustomAutocomplete(props) { | |||||||
|   const [isSingleChoice, setSingleChoice] = React.useState(undefined); |   const [isSingleChoice, setSingleChoice] = React.useState(undefined); | ||||||
|   const priorStepsWithExecutions = React.useContext(StepExecutionsContext); |   const priorStepsWithExecutions = React.useContext(StepExecutionsContext); | ||||||
|   const editorRef = React.useRef(null); |   const editorRef = React.useRef(null); | ||||||
|   const mountedRef = React.useRef(false); |  | ||||||
|  |  | ||||||
|   const renderElement = React.useCallback( |   const renderElement = React.useCallback( | ||||||
|     (props) => <Element {...props} disabled={disabled} />, |     (props) => <Element {...props} disabled={disabled} />, | ||||||
| @@ -95,14 +94,10 @@ function ControlledCustomAutocomplete(props) { | |||||||
|   }, []); |   }, []); | ||||||
|  |  | ||||||
|   React.useEffect(() => { |   React.useEffect(() => { | ||||||
|     if (mountedRef.current) { |     const hasDependencies = dependsOnValues.length; | ||||||
|       const hasDependencies = dependsOnValues.length; |     if (hasDependencies) { | ||||||
|       if (hasDependencies) { |       // Reset the field when a dependent has been updated | ||||||
|         // Reset the field when a dependent has been updated |       resetEditor(editor); | ||||||
|         resetEditor(editor); |  | ||||||
|       } |  | ||||||
|     } else { |  | ||||||
|       mountedRef.current = true; |  | ||||||
|     } |     } | ||||||
|   }, dependsOnValues); |   }, dependsOnValues); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -64,19 +64,11 @@ function DynamicField(props) { | |||||||
|           <Stack |           <Stack | ||||||
|             direction={{ xs: 'column', sm: 'row' }} |             direction={{ xs: 'column', sm: 'row' }} | ||||||
|             spacing={{ xs: 2 }} |             spacing={{ xs: 2 }} | ||||||
|             sx={{ |             sx={{ display: 'flex', flex: 1 }} | ||||||
|               display: 'flex', |  | ||||||
|               flex: 1, |  | ||||||
|               minWidth: 0, |  | ||||||
|             }} |  | ||||||
|           > |           > | ||||||
|             {fields.map((fieldSchema, fieldSchemaIndex) => ( |             {fields.map((fieldSchema, fieldSchemaIndex) => ( | ||||||
|               <Box |               <Box | ||||||
|                 sx={{ |                 sx={{ display: 'flex', flex: '1 0 0px' }} | ||||||
|                   display: 'flex', |  | ||||||
|                   flex: '1 0 0px', |  | ||||||
|                   minWidth: 0, |  | ||||||
|                 }} |  | ||||||
|                 key={`field-${field.__id}-${fieldSchemaIndex}`} |                 key={`field-${field.__id}-${fieldSchemaIndex}`} | ||||||
|               > |               > | ||||||
|                 <InputCreator |                 <InputCreator | ||||||
|   | |||||||
| @@ -59,25 +59,23 @@ export default function EditorLayout() { | |||||||
|  |  | ||||||
|   const onFlowStatusUpdate = React.useCallback( |   const onFlowStatusUpdate = React.useCallback( | ||||||
|     async (active) => { |     async (active) => { | ||||||
|       try { |       await updateFlowStatus({ | ||||||
|         await updateFlowStatus({ |         variables: { | ||||||
|           variables: { |           input: { | ||||||
|             input: { |             id: flowId, | ||||||
|               id: flowId, |             active, | ||||||
|               active, |  | ||||||
|             }, |  | ||||||
|           }, |           }, | ||||||
|           optimisticResponse: { |         }, | ||||||
|             updateFlowStatus: { |         optimisticResponse: { | ||||||
|               __typename: 'Flow', |           updateFlowStatus: { | ||||||
|               id: flowId, |             __typename: 'Flow', | ||||||
|               active, |             id: flowId, | ||||||
|             }, |             active, | ||||||
|           }, |           }, | ||||||
|         }); |         }, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|         await queryClient.invalidateQueries({ queryKey: ['flows', flowId] }); |       await queryClient.invalidateQueries({ queryKey: ['flows', flowId] }); | ||||||
|       } catch (err) {} |  | ||||||
|     }, |     }, | ||||||
|     [flowId, queryClient], |     [flowId, queryClient], | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -1,10 +1,10 @@ | |||||||
| import { EdgeLabelRenderer, getStraightPath } from 'reactflow'; | import { EdgeLabelRenderer, getStraightPath } from 'reactflow'; | ||||||
| import IconButton from '@mui/material/IconButton'; | import IconButton from '@mui/material/IconButton'; | ||||||
| import AddIcon from '@mui/icons-material/Add'; | 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 PropTypes from 'prop-types'; | ||||||
| import { useContext } from 'react'; |  | ||||||
| import { EdgesContext } from '../EditorNew'; |  | ||||||
|  |  | ||||||
| export default function Edge({ | export default function Edge({ | ||||||
|   sourceX, |   sourceX, | ||||||
| @@ -12,11 +12,11 @@ export default function Edge({ | |||||||
|   targetX, |   targetX, | ||||||
|   targetY, |   targetY, | ||||||
|   source, |   source, | ||||||
|   data: { laidOut }, |   data: { flowId, setCurrentStepId, flowActive, layouted }, | ||||||
| }) { | }) { | ||||||
|   const { stepCreationInProgress, flowActive, onAddStep } = |   const [createStep, { loading: creationInProgress }] = | ||||||
|     useContext(EdgesContext); |     useMutation(CREATE_STEP); | ||||||
|  |   const queryClient = useQueryClient(); | ||||||
|   const [edgePath, labelX, labelY] = getStraightPath({ |   const [edgePath, labelX, labelY] = getStraightPath({ | ||||||
|     sourceX, |     sourceX, | ||||||
|     sourceY, |     sourceY, | ||||||
| @@ -24,19 +24,38 @@ export default function Edge({ | |||||||
|     targetY, |     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 ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <EdgeLabelRenderer> |       <EdgeLabelRenderer> | ||||||
|         <IconButton |         <IconButton | ||||||
|           onClick={() => onAddStep(source)} |           onClick={() => addStep(source)} | ||||||
|           color="primary" |           color="primary" | ||||||
|           sx={{ |           sx={{ | ||||||
|             position: 'absolute', |             position: 'absolute', | ||||||
|             transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, |             transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, | ||||||
|             pointerEvents: 'all', |             pointerEvents: 'all', | ||||||
|             visibility: laidOut ? 'visible' : 'hidden', |             visibility: layouted ? 'visible' : 'hidden', | ||||||
|           }} |           }} | ||||||
|           disabled={stepCreationInProgress || flowActive} |           disabled={creationInProgress || flowActive} | ||||||
|         > |         > | ||||||
|           <AddIcon /> |           <AddIcon /> | ||||||
|         </IconButton> |         </IconButton> | ||||||
| @@ -52,6 +71,9 @@ Edge.propTypes = { | |||||||
|   targetY: PropTypes.number.isRequired, |   targetY: PropTypes.number.isRequired, | ||||||
|   source: PropTypes.string.isRequired, |   source: PropTypes.string.isRequired, | ||||||
|   data: PropTypes.shape({ |   data: PropTypes.shape({ | ||||||
|     laidOut: PropTypes.bool, |     flowId: PropTypes.string.isRequired, | ||||||
|  |     setCurrentStepId: PropTypes.func.isRequired, | ||||||
|  |     flowActive: PropTypes.bool.isRequired, | ||||||
|  |     layouted: PropTypes.bool, | ||||||
|   }).isRequired, |   }).isRequired, | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,81 +1,50 @@ | |||||||
| import { useEffect, useCallback, createContext, useRef } from 'react'; | import { useEffect, useState, useCallback } from 'react'; | ||||||
| import { useMutation } from '@apollo/client'; | import { useMutation } from '@apollo/client'; | ||||||
| import { useQueryClient } from '@tanstack/react-query'; | import { useQueryClient } from '@tanstack/react-query'; | ||||||
| import { FlowPropType } from 'propTypes/propTypes'; | import { FlowPropType } from 'propTypes/propTypes'; | ||||||
| import ReactFlow, { useNodesState, useEdgesState } from 'reactflow'; | import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow'; | ||||||
| import 'reactflow/dist/style.css'; | import 'reactflow/dist/style.css'; | ||||||
| import { UPDATE_STEP } from 'graphql/mutations/update-step'; | import { UPDATE_STEP } from 'graphql/mutations/update-step'; | ||||||
| import { CREATE_STEP } from 'graphql/mutations/create-step'; |  | ||||||
|  |  | ||||||
| import { useAutoLayout } from './useAutoLayout'; | import { useAutoLayout } from './useAutoLayout'; | ||||||
| import { useScrollBoundaries } from './useScrollBoundaries'; | import { useScrollBoundries } from './useScrollBoundries'; | ||||||
| import FlowStepNode from './FlowStepNode/FlowStepNode'; | import FlowStepNode from './FlowStepNode/FlowStepNode'; | ||||||
| import Edge from './Edge/Edge'; | import Edge from './Edge/Edge'; | ||||||
| import InvisibleNode from './InvisibleNode/InvisibleNode'; | import InvisibleNode from './InvisibleNode/InvisibleNode'; | ||||||
| import { EditorWrapper } from './style'; | 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(); | const nodeTypes = { flowStep: FlowStepNode, invisible: InvisibleNode }; | ||||||
| export const NodesContext = createContext(); |  | ||||||
|  |  | ||||||
| const nodeTypes = { |  | ||||||
|   [NODE_TYPES.FLOW_STEP]: FlowStepNode, |  | ||||||
|   [NODE_TYPES.INVISIBLE]: InvisibleNode, |  | ||||||
| }; |  | ||||||
|  |  | ||||||
| const edgeTypes = { | 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 EditorNew = ({ flow }) => { | ||||||
|  |   const [triggerStep] = flow.steps; | ||||||
|  |   const [currentStepId, setCurrentStepId] = useState(triggerStep.id); | ||||||
|  |  | ||||||
|   const [updateStep] = useMutation(UPDATE_STEP); |   const [updateStep] = useMutation(UPDATE_STEP); | ||||||
|   const queryClient = useQueryClient(); |   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(); |   useAutoLayout(); | ||||||
|   useScrollBoundaries(); |   useScrollBoundries(); | ||||||
|  |  | ||||||
|   const createdStepIdRef = useRef(null); |   const onConnect = useCallback( | ||||||
|  |     (params) => setEdges((eds) => addEdge(params, eds)), | ||||||
|  |     [setEdges], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const openNextStep = useCallback( |   const openNextStep = useCallback( | ||||||
|     (currentStepId) => { |     (nextStep) => () => { | ||||||
|       setNodes((nodes) => { |       setCurrentStepId(nextStep?.id); | ||||||
|         const currentStepIndex = nodes.findIndex( |  | ||||||
|           (node) => node.id === currentStepId, |  | ||||||
|         ); |  | ||||||
|         if (currentStepIndex >= 0) { |  | ||||||
|           const nextStep = nodes[currentStepIndex + 1]; |  | ||||||
|           return updatedCollapsedNodes(nodes, nextStep.id); |  | ||||||
|         } |  | ||||||
|         return nodes; |  | ||||||
|       }); |  | ||||||
|     }, |     }, | ||||||
|     [setNodes], |     [], | ||||||
|   ); |  | ||||||
|  |  | ||||||
|   const onStepClose = useCallback(() => { |  | ||||||
|     setNodes((nodes) => updatedCollapsedNodes(nodes)); |  | ||||||
|   }, [setNodes]); |  | ||||||
|  |  | ||||||
|   const onStepOpen = useCallback( |  | ||||||
|     (stepId) => { |  | ||||||
|       setNodes((nodes) => updatedCollapsedNodes(nodes, stepId)); |  | ||||||
|     }, |  | ||||||
|     [setNodes], |  | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   const onStepChange = useCallback( |   const onStepChange = useCallback( | ||||||
| @@ -107,166 +76,178 @@ const EditorNew = ({ flow }) => { | |||||||
|     [flow.id, updateStep, queryClient], |     [flow.id, updateStep, queryClient], | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   const onAddStep = useCallback( |   const generateEdges = useCallback((flow, prevEdges) => { | ||||||
|     async (previousStepId) => { |     const newEdges = | ||||||
|       const mutationInput = { |       flow.steps | ||||||
|         previousStep: { |         .map((step, i) => { | ||||||
|           id: previousStepId, |           const sourceId = step.id; | ||||||
|         }, |           const targetId = flow.steps[i + 1]?.id; | ||||||
|         flow: { |           const edge = prevEdges?.find( | ||||||
|           id: flow.id, |             (edge) => edge.id === generateEdgeId(sourceId, targetId), | ||||||
|         }, |           ); | ||||||
|       }; |           if (targetId) { | ||||||
|  |  | ||||||
|       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 { |             return { | ||||||
|               ...prevNode, |               id: generateEdgeId(sourceId, targetId), | ||||||
|               zIndex: createdStepId ? 0 : prevNode.zIndex, |               source: sourceId, | ||||||
|  |               target: targetId, | ||||||
|  |               type: 'addNodeEdge', | ||||||
|               data: { |               data: { | ||||||
|                 ...prevNode.data, |                 flowId: flow.id, | ||||||
|                 collapsed: createdStepId ? true : prevNode.data.collapsed, |                 flowActive: flow.active, | ||||||
|               }, |                 setCurrentStepId, | ||||||
|             }; |                 layouted: !!edge, | ||||||
|           } else { |  | ||||||
|             return { |  | ||||||
|               id: step.id, |  | ||||||
|               type: NODE_TYPES.FLOW_STEP, |  | ||||||
|               position: { |  | ||||||
|                 x: 0, |  | ||||||
|                 y: 0, |  | ||||||
|               }, |  | ||||||
|               zIndex: 1, |  | ||||||
|               data: { |  | ||||||
|                 collapsed: false, |  | ||||||
|                 laidOut: false, |  | ||||||
|               }, |               }, | ||||||
|             }; |             }; | ||||||
|           } |           } | ||||||
|         }); |         }) | ||||||
|  |         .filter((edge) => !!edge) || []; | ||||||
|  |  | ||||||
|         const prevInvisible = nodes.find(({ id }) => id === INVISIBLE_NODE_ID); |     const lastStep = flow.steps[flow.steps.length - 1]; | ||||||
|         return [ |  | ||||||
|           ...newNodes, |     return lastStep | ||||||
|  |       ? [ | ||||||
|  |           ...newEdges, | ||||||
|           { |           { | ||||||
|             id: INVISIBLE_NODE_ID, |             id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID), | ||||||
|             type: NODE_TYPES.INVISIBLE, |             source: lastStep.id, | ||||||
|             position: { |             target: INVISIBLE_NODE_ID, | ||||||
|               x: prevInvisible?.position.x || 0, |             type: 'addNodeEdge', | ||||||
|               y: prevInvisible?.position.y || 0, |             data: { | ||||||
|  |               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, | ||||||
|  |           }, | ||||||
|  |         }; | ||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       setEdges((edges) => { |       const prevInvisibleNode = nodes.find((node) => node.type === 'invisible'); | ||||||
|         const newEdges = flow.steps |  | ||||||
|           .map((step, i) => { |  | ||||||
|             const sourceId = step.id; |  | ||||||
|             const targetId = flow.steps[i + 1]?.id; |  | ||||||
|             const edge = edges?.find( |  | ||||||
|               (edge) => edge.id === generateEdgeId(sourceId, targetId), |  | ||||||
|             ); |  | ||||||
|             if (targetId) { |  | ||||||
|               return { |  | ||||||
|                 id: generateEdgeId(sourceId, targetId), |  | ||||||
|                 source: sourceId, |  | ||||||
|                 target: targetId, |  | ||||||
|                 type: 'addNodeEdge', |  | ||||||
|                 data: { |  | ||||||
|                   laidOut: edge ? edge?.data.laidOut : false, |  | ||||||
|                 }, |  | ||||||
|               }; |  | ||||||
|             } |  | ||||||
|             return null; |  | ||||||
|           }) |  | ||||||
|           .filter((edge) => !!edge); |  | ||||||
|  |  | ||||||
|         const lastStep = flow.steps[flow.steps.length - 1]; |       return [ | ||||||
|         const lastEdge = edges[edges.length - 1]; |         ...newNodes, | ||||||
|  |         { | ||||||
|  |           id: INVISIBLE_NODE_ID, | ||||||
|  |           type: 'invisible', | ||||||
|  |           position: { | ||||||
|  |             x: prevInvisibleNode ? prevInvisibleNode.position.x : 0, | ||||||
|  |             y: prevInvisibleNode ? prevInvisibleNode.position.y : 0, | ||||||
|  |           }, | ||||||
|  |         }, | ||||||
|  |       ]; | ||||||
|  |     }, | ||||||
|  |     [currentStepId, nodes, onStepChange, openNextStep], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|         return lastStep |   const updateNodesData = useCallback( | ||||||
|           ? [ |     (steps) => { | ||||||
|               ...newEdges, |       setNodes((nodes) => | ||||||
|               { |         nodes.map((node) => { | ||||||
|                 id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID), |           const step = steps.find((step) => step.id === node.id); | ||||||
|                 source: lastStep.id, |           if (step) { | ||||||
|                 target: INVISIBLE_NODE_ID, |             return { ...node, data: { ...node.data, step: { ...step } } }; | ||||||
|                 type: 'addNodeEdge', |           } | ||||||
|                 data: { |           return node; | ||||||
|                   laidOut: |         }), | ||||||
|                     lastEdge?.id === |       ); | ||||||
|                     generateEdgeId(lastStep.id, INVISIBLE_NODE_ID) |     }, | ||||||
|                       ? lastEdge?.data.laidOut |     [setNodes], | ||||||
|                       : false, |   ); | ||||||
|                 }, |  | ||||||
|               }, |  | ||||||
|             ] |  | ||||||
|           : newEdges; |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       if (createdStepIdRef.current) { |   const updateEdgesData = useCallback( | ||||||
|         createdStepIdRef.current = null; |     (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, | ||||||
|  |             }, | ||||||
|  |           }; | ||||||
|  |         } | ||||||
|  |         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.steps]); |   }, [flow]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <NodesContext.Provider |     <EditorWrapper direction="column"> | ||||||
|       value={{ |       <ReactFlow | ||||||
|         openNextStep, |         nodes={nodes} | ||||||
|         onStepOpen, |         edges={edges} | ||||||
|         onStepClose, |         onNodesChange={onNodesChange} | ||||||
|         onStepChange, |         onEdgesChange={onEdgesChange} | ||||||
|         flowId: flow.id, |         onConnect={onConnect} | ||||||
|         steps: flow.steps, |         nodeTypes={nodeTypes} | ||||||
|       }} |         edgeTypes={edgeTypes} | ||||||
|     > |         panOnScroll | ||||||
|       <EdgesContext.Provider |         panOnScrollMode="vertical" | ||||||
|         value={{ |         panOnDrag={false} | ||||||
|           stepCreationInProgress, |         zoomOnScroll={false} | ||||||
|           onAddStep, |         zoomOnPinch={false} | ||||||
|           flowActive: flow.active, |         zoomOnDoubleClick={false} | ||||||
|         }} |         panActivationKeyCode={null} | ||||||
|       > |         proOptions={{ hideAttribution: true }} | ||||||
|         <EditorWrapper direction="column"> |       /> | ||||||
|           <ReactFlow |     </EditorWrapper> | ||||||
|             nodes={nodes} |  | ||||||
|             edges={edges} |  | ||||||
|             onNodesChange={onNodesChange} |  | ||||||
|             onEdgesChange={onEdgesChange} |  | ||||||
|             nodeTypes={nodeTypes} |  | ||||||
|             edgeTypes={edgeTypes} |  | ||||||
|             panOnScroll |  | ||||||
|             panOnScrollMode="vertical" |  | ||||||
|             panOnDrag={false} |  | ||||||
|             zoomOnScroll={false} |  | ||||||
|             zoomOnPinch={false} |  | ||||||
|             zoomOnDoubleClick={false} |  | ||||||
|             panActivationKeyCode={null} |  | ||||||
|             proOptions={{ hideAttribution: true }} |  | ||||||
|           /> |  | ||||||
|         </EditorWrapper> |  | ||||||
|       </EdgesContext.Provider> |  | ||||||
|     </NodesContext.Provider> |  | ||||||
|   ); |   ); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,23 +1,30 @@ | |||||||
| import { Handle, Position } from 'reactflow'; | import { Handle, Position } from 'reactflow'; | ||||||
|  | import { Box } from '@mui/material'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
|  |  | ||||||
| import FlowStep from 'components/FlowStep'; | import FlowStep from 'components/FlowStep'; | ||||||
|  | import { StepPropType } from 'propTypes/propTypes'; | ||||||
|  |  | ||||||
| import { NodeWrapper, NodeInnerWrapper } from './style.js'; | 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 ( |   return ( | ||||||
|     <NodeWrapper |     <NodeWrapper | ||||||
|       className="nodrag" |       className="nodrag" | ||||||
|       sx={{ |       sx={{ | ||||||
|         visibility: laidOut ? 'visible' : 'hidden', |         visibility: layouted ? 'visible' : 'hidden', | ||||||
|       }} |       }} | ||||||
|     > |     > | ||||||
|       <NodeInnerWrapper> |       <NodeInnerWrapper> | ||||||
| @@ -27,17 +34,16 @@ function FlowStepNode({ data: { collapsed, laidOut }, id }) { | |||||||
|           isConnectable={false} |           isConnectable={false} | ||||||
|           style={{ visibility: 'hidden' }} |           style={{ visibility: 'hidden' }} | ||||||
|         /> |         /> | ||||||
|         {step && ( |         <FlowStep | ||||||
|           <FlowStep |           step={step} | ||||||
|             step={step} |           index={index + 1} | ||||||
|             collapsed={collapsed} |           collapsed={collapsed} | ||||||
|             onOpen={() => onStepOpen(step.id)} |           onOpen={onOpen} | ||||||
|             onClose={onStepClose} |           onClose={onClose} | ||||||
|             onChange={onStepChange} |           onChange={onChange} | ||||||
|             flowId={flowId} |           flowId={flowId} | ||||||
|             onContinue={() => openNextStep(step.id)} |           onContinue={openNextStep} | ||||||
|           /> |         /> | ||||||
|         )} |  | ||||||
|         <Handle |         <Handle | ||||||
|           type="source" |           type="source" | ||||||
|           position={Position.Bottom} |           position={Position.Bottom} | ||||||
| @@ -50,10 +56,16 @@ function FlowStepNode({ data: { collapsed, laidOut }, id }) { | |||||||
| } | } | ||||||
|  |  | ||||||
| FlowStepNode.propTypes = { | FlowStepNode.propTypes = { | ||||||
|   id: PropTypes.string, |  | ||||||
|   data: PropTypes.shape({ |   data: PropTypes.shape({ | ||||||
|  |     step: StepPropType.isRequired, | ||||||
|  |     index: PropTypes.number.isRequired, | ||||||
|  |     flowId: PropTypes.string.isRequired, | ||||||
|     collapsed: PropTypes.bool.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, |   }).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 { isEqual } from 'lodash'; | ||||||
| import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow'; | import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow'; | ||||||
|  |  | ||||||
| const getLaidOutElements = (nodes, edges) => { | const getLayoutedElements = (nodes, edges) => { | ||||||
|   const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); |   const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); | ||||||
|   graph.setGraph({ |   graph.setGraph({ | ||||||
|     rankdir: 'TB', |     rankdir: 'TB', | ||||||
| @@ -36,18 +36,18 @@ export const useAutoLayout = () => { | |||||||
|  |  | ||||||
|   const onLayout = useCallback( |   const onLayout = useCallback( | ||||||
|     (nodes, edges) => { |     (nodes, edges) => { | ||||||
|       const laidOutElements = getLaidOutElements(nodes, edges); |       const layoutedElements = getLayoutedElements(nodes, edges); | ||||||
|  |  | ||||||
|       setNodes([ |       setNodes([ | ||||||
|         ...laidOutElements.nodes.map((node) => ({ |         ...layoutedElements.nodes.map((node) => ({ | ||||||
|           ...node, |           ...node, | ||||||
|           data: { ...node.data, laidOut: true }, |           data: { ...node.data, layouted: true }, | ||||||
|         })), |         })), | ||||||
|       ]); |       ]); | ||||||
|       setEdges([ |       setEdges([ | ||||||
|         ...laidOutElements.edges.map((edge) => ({ |         ...layoutedElements.edges.map((edge) => ({ | ||||||
|           ...edge, |           ...edge, | ||||||
|           data: { ...edge.data, laidOut: true }, |           data: { ...edge.data, layouted: true }, | ||||||
|         })), |         })), | ||||||
|       ]); |       ]); | ||||||
|     }, |     }, | ||||||
|   | |||||||
| @@ -1,7 +1,7 @@ | |||||||
| import { useEffect } from 'react'; | import { useEffect } from 'react'; | ||||||
| import { useViewport, useReactFlow } from 'reactflow'; | import { useViewport, useReactFlow } from 'reactflow'; | ||||||
| 
 | 
 | ||||||
| export const useScrollBoundaries = () => { | export const useScrollBoundries = () => { | ||||||
|   const { setViewport } = useReactFlow(); |   const { setViewport } = useReactFlow(); | ||||||
|   const { x, y, zoom } = useViewport(); |   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 ErrorIcon from '@mui/icons-material/Error'; | ||||||
| import CircularProgress from '@mui/material/CircularProgress'; | import CircularProgress from '@mui/material/CircularProgress'; | ||||||
| import CheckCircleIcon from '@mui/icons-material/CheckCircle'; | import CheckCircleIcon from '@mui/icons-material/CheckCircle'; | ||||||
|  | import { yupResolver } from '@hookform/resolvers/yup'; | ||||||
|  | import * as yup from 'yup'; | ||||||
|  |  | ||||||
| import { EditorContext } from 'contexts/Editor'; | import { EditorContext } from 'contexts/Editor'; | ||||||
| import { StepExecutionsProvider } from 'contexts/StepExecutions'; | import { StepExecutionsProvider } from 'contexts/StepExecutions'; | ||||||
| import TestSubstep from 'components/TestSubstep'; | import TestSubstep from 'components/TestSubstep'; | ||||||
| @@ -30,18 +33,77 @@ import { | |||||||
|   Header, |   Header, | ||||||
|   Wrapper, |   Wrapper, | ||||||
| } from './style'; | } from './style'; | ||||||
|  | import isEmpty from 'helpers/isEmpty'; | ||||||
| import { StepPropType } from 'propTypes/propTypes'; | import { StepPropType } from 'propTypes/propTypes'; | ||||||
| import useTriggers from 'hooks/useTriggers'; | import useTriggers from 'hooks/useTriggers'; | ||||||
| import useActions from 'hooks/useActions'; | import useActions from 'hooks/useActions'; | ||||||
| import useTriggerSubsteps from 'hooks/useTriggerSubsteps'; | import useTriggerSubsteps from 'hooks/useTriggerSubsteps'; | ||||||
| import useActionSubsteps from 'hooks/useActionSubsteps'; | import useActionSubsteps from 'hooks/useActionSubsteps'; | ||||||
| import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions'; | import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions'; | ||||||
| import { validationSchemaResolver } from './validation'; |  | ||||||
| import { isEqual } from 'lodash'; |  | ||||||
|  |  | ||||||
| const validIcon = <CheckCircleIcon color="success" />; | const validIcon = <CheckCircleIcon color="success" />; | ||||||
| const errorIcon = <ErrorIcon color="error" />; | 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) { | function FlowStep(props) { | ||||||
|   const { collapsed, onChange, onContinue, flowId } = props; |   const { collapsed, onChange, onContinue, flowId } = props; | ||||||
|   const editorContext = React.useContext(EditorContext); |   const editorContext = React.useContext(EditorContext); | ||||||
| @@ -52,10 +114,6 @@ function FlowStep(props) { | |||||||
|   const isAction = step.type === 'action'; |   const isAction = step.type === 'action'; | ||||||
|   const formatMessage = useFormatMessage(); |   const formatMessage = useFormatMessage(); | ||||||
|   const [currentSubstep, setCurrentSubstep] = React.useState(0); |   const [currentSubstep, setCurrentSubstep] = React.useState(0); | ||||||
|   const [formResolverContext, setFormResolverContext] = React.useState({ |  | ||||||
|     substeps: [], |  | ||||||
|     additionalFields: {}, |  | ||||||
|   }); |  | ||||||
|   const useAppsOptions = {}; |   const useAppsOptions = {}; | ||||||
|  |  | ||||||
|   if (isTrigger) { |   if (isTrigger) { | ||||||
| @@ -110,12 +168,6 @@ function FlowStep(props) { | |||||||
|       ? triggerSubstepsData |       ? triggerSubstepsData | ||||||
|       : actionSubstepsData || []; |       : actionSubstepsData || []; | ||||||
|  |  | ||||||
|   React.useEffect(() => { |  | ||||||
|     if (!isEqual(substeps, formResolverContext.substeps)) { |  | ||||||
|       setFormResolverContext({ substeps, additionalFields: {} }); |  | ||||||
|     } |  | ||||||
|   }, [substeps]); |  | ||||||
|  |  | ||||||
|   const handleChange = React.useCallback(({ step }) => { |   const handleChange = React.useCallback(({ step }) => { | ||||||
|     onChange(step); |     onChange(step); | ||||||
|   }, []); |   }, []); | ||||||
| @@ -128,6 +180,11 @@ function FlowStep(props) { | |||||||
|     handleChange({ step: val }); |     handleChange({ step: val }); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   const stepValidationSchema = React.useMemo( | ||||||
|  |     () => generateValidationSchema(substeps), | ||||||
|  |     [substeps], | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   if (!apps?.data) { |   if (!apps?.data) { | ||||||
|     return ( |     return ( | ||||||
|       <CircularProgress |       <CircularProgress | ||||||
| @@ -156,15 +213,6 @@ function FlowStep(props) { | |||||||
|       value !== substepIndex ? substepIndex : null, |       value !== substepIndex ? substepIndex : null, | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|   const addAdditionalFieldsValidation = (additionalFields) => { |  | ||||||
|     if (additionalFields) { |  | ||||||
|       setFormResolverContext((prev) => ({ |  | ||||||
|         ...prev, |  | ||||||
|         additionalFields: { ...prev.additionalFields, ...additionalFields }, |  | ||||||
|       })); |  | ||||||
|     } |  | ||||||
|   }; |  | ||||||
|  |  | ||||||
|   const validationStatusIcon = |   const validationStatusIcon = | ||||||
|     step.status === 'completed' ? validIcon : errorIcon; |     step.status === 'completed' ? validIcon : errorIcon; | ||||||
|  |  | ||||||
| @@ -218,8 +266,7 @@ function FlowStep(props) { | |||||||
|               <Form |               <Form | ||||||
|                 defaultValues={step} |                 defaultValues={step} | ||||||
|                 onSubmit={handleSubmit} |                 onSubmit={handleSubmit} | ||||||
|                 resolver={validationSchemaResolver} |                 resolver={stepValidationSchema} | ||||||
|                 context={formResolverContext} |  | ||||||
|               > |               > | ||||||
|                 <ChooseAppAndEventSubstep |                 <ChooseAppAndEventSubstep | ||||||
|                   expanded={currentSubstep === 0} |                   expanded={currentSubstep === 0} | ||||||
| @@ -283,9 +330,6 @@ function FlowStep(props) { | |||||||
|                             onSubmit={expandNextStep} |                             onSubmit={expandNextStep} | ||||||
|                             onChange={handleChange} |                             onChange={handleChange} | ||||||
|                             step={step} |                             step={step} | ||||||
|                             addAdditionalFieldsValidation={ |  | ||||||
|                               addAdditionalFieldsValidation |  | ||||||
|                             } |  | ||||||
|                           /> |                           /> | ||||||
|                         )} |                         )} | ||||||
|                     </React.Fragment> |                     </React.Fragment> | ||||||
| @@ -316,6 +360,7 @@ function FlowStep(props) { | |||||||
| FlowStep.propTypes = { | FlowStep.propTypes = { | ||||||
|   collapsed: PropTypes.bool, |   collapsed: PropTypes.bool, | ||||||
|   step: StepPropType.isRequired, |   step: StepPropType.isRequired, | ||||||
|  |   index: PropTypes.number, | ||||||
|   onOpen: PropTypes.func, |   onOpen: PropTypes.func, | ||||||
|   onClose: PropTypes.func, |   onClose: PropTypes.func, | ||||||
|   onChange: PropTypes.func.isRequired, |   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 = { | FlowStepContextMenu.propTypes = { | ||||||
|   stepId: PropTypes.string.isRequired, |   stepId: PropTypes.string.isRequired, | ||||||
|   onClose: PropTypes.func.isRequired, |   onClose: PropTypes.func.isRequired, | ||||||
|   anchorEl: PropTypes.oneOfType([ |   anchorEl: PropTypes.element.isRequired, | ||||||
|     PropTypes.func, |  | ||||||
|     PropTypes.shape({ current: PropTypes.instanceOf(Element) }), |  | ||||||
|   ]).isRequired, |  | ||||||
|   deletable: PropTypes.bool.isRequired, |   deletable: PropTypes.bool.isRequired, | ||||||
| }; | }; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -19,9 +19,7 @@ function FlowSubstep(props) { | |||||||
|     onCollapse, |     onCollapse, | ||||||
|     onSubmit, |     onSubmit, | ||||||
|     step, |     step, | ||||||
|     addAdditionalFieldsValidation, |  | ||||||
|   } = props; |   } = props; | ||||||
|  |  | ||||||
|   const { name, arguments: args } = substep; |   const { name, arguments: args } = substep; | ||||||
|   const editorContext = React.useContext(EditorContext); |   const editorContext = React.useContext(EditorContext); | ||||||
|   const formContext = useFormContext(); |   const formContext = useFormContext(); | ||||||
| @@ -56,7 +54,6 @@ function FlowSubstep(props) { | |||||||
|                   stepId={step.id} |                   stepId={step.id} | ||||||
|                   disabled={editorContext.readOnly} |                   disabled={editorContext.readOnly} | ||||||
|                   showOptionValue={true} |                   showOptionValue={true} | ||||||
|                   addAdditionalFieldsValidation={addAdditionalFieldsValidation} |  | ||||||
|                 /> |                 /> | ||||||
|               ))} |               ))} | ||||||
|             </Stack> |             </Stack> | ||||||
|   | |||||||
| @@ -1,8 +1,6 @@ | |||||||
| import * as React from 'react'; | import * as React from 'react'; | ||||||
| import { FormProvider, useForm, useWatch } from 'react-hook-form'; | import { FormProvider, useForm, useWatch } from 'react-hook-form'; | ||||||
|  |  | ||||||
| const noop = () => null; | const noop = () => null; | ||||||
|  |  | ||||||
| export default function Form(props) { | export default function Form(props) { | ||||||
|   const { |   const { | ||||||
|     children, |     children, | ||||||
| @@ -11,31 +9,24 @@ export default function Form(props) { | |||||||
|     resolver, |     resolver, | ||||||
|     render, |     render, | ||||||
|     mode = 'all', |     mode = 'all', | ||||||
|     context, |  | ||||||
|     ...formProps |     ...formProps | ||||||
|   } = props; |   } = props; | ||||||
|  |  | ||||||
|   const methods = useForm({ |   const methods = useForm({ | ||||||
|     defaultValues, |     defaultValues, | ||||||
|     reValidateMode: 'onBlur', |     reValidateMode: 'onBlur', | ||||||
|     resolver, |     resolver, | ||||||
|     mode, |     mode, | ||||||
|     context, |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const form = useWatch({ control: methods.control }); |   const form = useWatch({ control: methods.control }); | ||||||
|  |  | ||||||
|   /** |   /** | ||||||
|    * For fields having `dependsOn` fields, we need to re-validate the form. |    * For fields having `dependsOn` fields, we need to re-validate the form. | ||||||
|    */ |    */ | ||||||
|   React.useEffect(() => { |   React.useEffect(() => { | ||||||
|     methods.trigger(); |     methods.trigger(); | ||||||
|   }, [methods.trigger, form]); |   }, [methods.trigger, form]); | ||||||
|  |  | ||||||
|   React.useEffect(() => { |   React.useEffect(() => { | ||||||
|     methods.reset(defaultValues); |     methods.reset(defaultValues); | ||||||
|   }, [defaultValues]); |   }, [defaultValues]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <FormProvider {...methods}> |     <FormProvider {...methods}> | ||||||
|       <form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}> |       <form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}> | ||||||
|   | |||||||
| @@ -23,9 +23,7 @@ export default function InputCreator(props) { | |||||||
|     disabled, |     disabled, | ||||||
|     showOptionValue, |     showOptionValue, | ||||||
|     shouldUnregister, |     shouldUnregister, | ||||||
|     addAdditionalFieldsValidation, |  | ||||||
|   } = props; |   } = props; | ||||||
|  |  | ||||||
|   const { |   const { | ||||||
|     key: name, |     key: name, | ||||||
|     label, |     label, | ||||||
| @@ -35,7 +33,6 @@ export default function InputCreator(props) { | |||||||
|     description, |     description, | ||||||
|     type, |     type, | ||||||
|   } = schema; |   } = schema; | ||||||
|  |  | ||||||
|   const { data, loading } = useDynamicData(stepId, schema); |   const { data, loading } = useDynamicData(stepId, schema); | ||||||
|   const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } = |   const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } = | ||||||
|     useDynamicFields(stepId, schema); |     useDynamicFields(stepId, schema); | ||||||
| @@ -43,10 +40,6 @@ export default function InputCreator(props) { | |||||||
|  |  | ||||||
|   const computedName = namePrefix ? `${namePrefix}.${name}` : name; |   const computedName = namePrefix ? `${namePrefix}.${name}` : name; | ||||||
|  |  | ||||||
|   React.useEffect(() => { |  | ||||||
|     addAdditionalFieldsValidation?.({ [name]: additionalFields }); |  | ||||||
|   }, [additionalFields]); |  | ||||||
|  |  | ||||||
|   if (type === 'dynamic') { |   if (type === 'dynamic') { | ||||||
|     return ( |     return ( | ||||||
|       <DynamicField |       <DynamicField | ||||||
|   | |||||||
| @@ -5,10 +5,8 @@ import AddCircleIcon from '@mui/icons-material/AddCircle'; | |||||||
| import CardActionArea from '@mui/material/CardActionArea'; | import CardActionArea from '@mui/material/CardActionArea'; | ||||||
| import Typography from '@mui/material/Typography'; | import Typography from '@mui/material/Typography'; | ||||||
| import { CardContent } from './style'; | import { CardContent } from './style'; | ||||||
|  |  | ||||||
| export default function NoResultFound(props) { | export default function NoResultFound(props) { | ||||||
|   const { text, to } = props; |   const { text, to } = props; | ||||||
|  |  | ||||||
|   const ActionAreaLink = React.useMemo( |   const ActionAreaLink = React.useMemo( | ||||||
|     () => |     () => | ||||||
|       React.forwardRef(function InlineLink(linkProps, ref) { |       React.forwardRef(function InlineLink(linkProps, ref) { | ||||||
| @@ -17,12 +15,12 @@ export default function NoResultFound(props) { | |||||||
|       }), |       }), | ||||||
|     [to], |     [to], | ||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Card elevation={0}> |     <Card elevation={0}> | ||||||
|       <CardActionArea component={ActionAreaLink} {...props}> |       <CardActionArea component={ActionAreaLink} {...props}> | ||||||
|         <CardContent> |         <CardContent> | ||||||
|           {!!to && <AddCircleIcon color="primary" />} |           {!!to && <AddCircleIcon color="primary" />} | ||||||
|  |  | ||||||
|           <Typography variant="body1">{text}</Typography> |           <Typography variant="body1">{text}</Typography> | ||||||
|         </CardContent> |         </CardContent> | ||||||
|       </CardActionArea> |       </CardActionArea> | ||||||
|   | |||||||
| @@ -7,7 +7,6 @@ import { useQuery } from '@tanstack/react-query'; | |||||||
| import api from 'helpers/api'; | import api from 'helpers/api'; | ||||||
|  |  | ||||||
| const variableRegExp = /({.*?})/; | const variableRegExp = /({.*?})/; | ||||||
|  |  | ||||||
| // TODO: extract this function to a separate file | // TODO: extract this function to a separate file | ||||||
| function computeArguments(args, getValues) { | function computeArguments(args, getValues) { | ||||||
|   const initialValue = {}; |   const initialValue = {}; | ||||||
|   | |||||||
| @@ -1,6 +1,4 @@ | |||||||
| import { createRoot } from 'react-dom/client'; | import { createRoot } from 'react-dom/client'; | ||||||
| import { Settings } from 'luxon'; |  | ||||||
|  |  | ||||||
| import ThemeProvider from 'components/ThemeProvider'; | import ThemeProvider from 'components/ThemeProvider'; | ||||||
| import IntlProvider from 'components/IntlProvider'; | import IntlProvider from 'components/IntlProvider'; | ||||||
| import ApolloProvider from 'components/ApolloProvider'; | import ApolloProvider from 'components/ApolloProvider'; | ||||||
| @@ -12,9 +10,6 @@ import Router from 'components/Router'; | |||||||
| import routes from 'routes'; | import routes from 'routes'; | ||||||
| import reportWebVitals from './reportWebVitals'; | 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 container = document.getElementById('root'); | ||||||
| const root = createRoot(container); | const root = createRoot(container); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -30,7 +30,6 @@ import AppIcon from 'components/AppIcon'; | |||||||
| import Container from 'components/Container'; | import Container from 'components/Container'; | ||||||
| import PageTitle from 'components/PageTitle'; | import PageTitle from 'components/PageTitle'; | ||||||
| import useApp from 'hooks/useApp'; | import useApp from 'hooks/useApp'; | ||||||
| import Can from 'components/Can'; |  | ||||||
|  |  | ||||||
| const ReconnectConnection = (props) => { | const ReconnectConnection = (props) => { | ||||||
|   const { application, onClose } = props; |   const { application, onClose } = props; | ||||||
| @@ -93,7 +92,7 @@ export default function Application() { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     return options; |     return options; | ||||||
|   }, [appKey, appConfig?.data, currentUserAbility, formatMessage]); |   }, [appKey, appConfig?.data, currentUserAbility]); | ||||||
|  |  | ||||||
|   if (loading) return null; |   if (loading) return null; | ||||||
|  |  | ||||||
| @@ -119,46 +118,37 @@ export default function Application() { | |||||||
|                 <Route |                 <Route | ||||||
|                   path={`${URLS.FLOWS}/*`} |                   path={`${URLS.FLOWS}/*`} | ||||||
|                   element={ |                   element={ | ||||||
|                     <Can I="create" a="Flow" passThrough> |                     <ConditionalIconButton | ||||||
|                       {(allowed) => ( |                       type="submit" | ||||||
|                         <ConditionalIconButton |                       variant="contained" | ||||||
|                           type="submit" |                       color="primary" | ||||||
|                           variant="contained" |                       size="large" | ||||||
|                           color="primary" |                       component={Link} | ||||||
|                           size="large" |                       to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION( | ||||||
|                           component={Link} |                         appKey, | ||||||
|                           to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION( |                         connectionId, | ||||||
|                             appKey, |  | ||||||
|                             connectionId, |  | ||||||
|                           )} |  | ||||||
|                           fullWidth |  | ||||||
|                           icon={<AddIcon />} |  | ||||||
|                           disabled={!allowed} |  | ||||||
|                         > |  | ||||||
|                           {formatMessage('app.createFlow')} |  | ||||||
|                         </ConditionalIconButton> |  | ||||||
|                       )} |                       )} | ||||||
|                     </Can> |                       fullWidth | ||||||
|  |                       icon={<AddIcon />} | ||||||
|  |                       disabled={!currentUserAbility.can('create', 'Flow')} | ||||||
|  |                     > | ||||||
|  |                       {formatMessage('app.createFlow')} | ||||||
|  |                     </ConditionalIconButton> | ||||||
|                   } |                   } | ||||||
|                 /> |                 /> | ||||||
|  |  | ||||||
|                 <Route |                 <Route | ||||||
|                   path={`${URLS.CONNECTIONS}/*`} |                   path={`${URLS.CONNECTIONS}/*`} | ||||||
|                   element={ |                   element={ | ||||||
|                     <Can I="create" a="Connection" passThrough> |                     <SplitButton | ||||||
|                       {(allowed) => ( |                       disabled={ | ||||||
|                         <SplitButton |                         (appConfig?.data && | ||||||
|                           disabled={ |                           !appConfig?.data?.canConnect && | ||||||
|                             !allowed || |                           !appConfig?.data?.canCustomConnect) || | ||||||
|                             (appConfig?.data && |                         connectionOptions.every(({ disabled }) => disabled) | ||||||
|                               !appConfig?.data?.canConnect && |                       } | ||||||
|                               !appConfig?.data?.canCustomConnect) || |                       options={connectionOptions} | ||||||
|                             connectionOptions.every(({ disabled }) => disabled) |                     /> | ||||||
|                           } |  | ||||||
|                           options={connectionOptions} |  | ||||||
|                         /> |  | ||||||
|                       )} |  | ||||||
|                     </Can> |  | ||||||
|                   } |                   } | ||||||
|                 /> |                 /> | ||||||
|               </Routes> |               </Routes> | ||||||
| @@ -179,20 +169,17 @@ export default function Application() { | |||||||
|                     label={formatMessage('app.connections')} |                     label={formatMessage('app.connections')} | ||||||
|                     to={URLS.APP_CONNECTIONS(appKey)} |                     to={URLS.APP_CONNECTIONS(appKey)} | ||||||
|                     value={URLS.APP_CONNECTIONS_PATTERN} |                     value={URLS.APP_CONNECTIONS_PATTERN} | ||||||
|                     disabled={ |                     disabled={!app.supportsConnections} | ||||||
|                       !currentUserAbility.can('read', 'Connection') || |  | ||||||
|                       !app.supportsConnections |  | ||||||
|                     } |  | ||||||
|                     component={Link} |                     component={Link} | ||||||
|                     data-test="connections-tab" |                     data-test="connections-tab" | ||||||
|                   /> |                   /> | ||||||
|  |  | ||||||
|                   <Tab |                   <Tab | ||||||
|                     label={formatMessage('app.flows')} |                     label={formatMessage('app.flows')} | ||||||
|                     to={URLS.APP_FLOWS(appKey)} |                     to={URLS.APP_FLOWS(appKey)} | ||||||
|                     value={URLS.APP_FLOWS_PATTERN} |                     value={URLS.APP_FLOWS_PATTERN} | ||||||
|                     component={Link} |                     component={Link} | ||||||
|                     data-test="flows-tab" |                     data-test="flows-tab" | ||||||
|                     disabled={!currentUserAbility.can('read', 'Flow')} |  | ||||||
|                   /> |                   /> | ||||||
|                 </Tabs> |                 </Tabs> | ||||||
|               </Box> |               </Box> | ||||||
| @@ -200,20 +187,14 @@ export default function Application() { | |||||||
|               <Routes> |               <Routes> | ||||||
|                 <Route |                 <Route | ||||||
|                   path={`${URLS.FLOWS}/*`} |                   path={`${URLS.FLOWS}/*`} | ||||||
|                   element={ |                   element={<AppFlows appKey={appKey} />} | ||||||
|                     <Can I="read" a="Flow"> |  | ||||||
|                       <AppFlows appKey={appKey} /> |  | ||||||
|                     </Can> |  | ||||||
|                   } |  | ||||||
|                 /> |                 /> | ||||||
|  |  | ||||||
|                 <Route |                 <Route | ||||||
|                   path={`${URLS.CONNECTIONS}/*`} |                   path={`${URLS.CONNECTIONS}/*`} | ||||||
|                   element={ |                   element={<AppConnections appKey={appKey} />} | ||||||
|                     <Can I="read" a="Connection"> |  | ||||||
|                       <AppConnections appKey={appKey} /> |  | ||||||
|                     </Can> |  | ||||||
|                   } |  | ||||||
|                 /> |                 /> | ||||||
|  |  | ||||||
|                 <Route |                 <Route | ||||||
|                   path="/" |                   path="/" | ||||||
|                   element={ |                   element={ | ||||||
| @@ -237,24 +218,17 @@ export default function Application() { | |||||||
|         <Route |         <Route | ||||||
|           path="/connections/add" |           path="/connections/add" | ||||||
|           element={ |           element={ | ||||||
|             <Can I="create" a="Connection"> |             <AddAppConnection onClose={goToApplicationPage} application={app} /> | ||||||
|               <AddAppConnection |  | ||||||
|                 onClose={goToApplicationPage} |  | ||||||
|                 application={app} |  | ||||||
|               /> |  | ||||||
|             </Can> |  | ||||||
|           } |           } | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|         <Route |         <Route | ||||||
|           path="/connections/:connectionId/reconnect" |           path="/connections/:connectionId/reconnect" | ||||||
|           element={ |           element={ | ||||||
|             <Can I="create" a="Connection"> |             <ReconnectConnection | ||||||
|               <ReconnectConnection |               application={app} | ||||||
|                 application={app} |               onClose={goToApplicationPage} | ||||||
|                 onClose={goToApplicationPage} |             /> | ||||||
|               /> |  | ||||||
|             </Can> |  | ||||||
|           } |           } | ||||||
|         /> |         /> | ||||||
|       </Routes> |       </Routes> | ||||||
|   | |||||||
| @@ -84,14 +84,10 @@ export default function Applications() { | |||||||
|         )} |         )} | ||||||
|  |  | ||||||
|         {!isLoading && !hasApps && ( |         {!isLoading && !hasApps && ( | ||||||
|           <Can I="create" a="Connection" passThrough> |           <NoResultFound | ||||||
|             {(allowed) => ( |             text={formatMessage('apps.noConnections')} | ||||||
|               <NoResultFound |             to={URLS.NEW_APP_CONNECTION} | ||||||
|                 text={formatMessage('apps.noConnections')} |           /> | ||||||
|                 {...(allowed && { to: URLS.NEW_APP_CONNECTION })} |  | ||||||
|               /> |  | ||||||
|             )} |  | ||||||
|           </Can> |  | ||||||
|         )} |         )} | ||||||
|  |  | ||||||
|         {!isLoading && |         {!isLoading && | ||||||
|   | |||||||
| @@ -7,15 +7,13 @@ import * as URLS from 'config/urls'; | |||||||
| import useFormatMessage from 'hooks/useFormatMessage'; | import useFormatMessage from 'hooks/useFormatMessage'; | ||||||
| import { CREATE_FLOW } from 'graphql/mutations/create-flow'; | import { CREATE_FLOW } from 'graphql/mutations/create-flow'; | ||||||
| import Box from '@mui/material/Box'; | import Box from '@mui/material/Box'; | ||||||
|  |  | ||||||
| export default function CreateFlow() { | export default function CreateFlow() { | ||||||
|   const [searchParams] = useSearchParams(); |   const [searchParams] = useSearchParams(); | ||||||
|   const navigate = useNavigate(); |   const navigate = useNavigate(); | ||||||
|   const formatMessage = useFormatMessage(); |   const formatMessage = useFormatMessage(); | ||||||
|   const [createFlow, { error }] = useMutation(CREATE_FLOW); |   const [createFlow] = useMutation(CREATE_FLOW); | ||||||
|   const appKey = searchParams.get('appKey'); |   const appKey = searchParams.get('appKey'); | ||||||
|   const connectionId = searchParams.get('connectionId'); |   const connectionId = searchParams.get('connectionId'); | ||||||
|  |  | ||||||
|   React.useEffect(() => { |   React.useEffect(() => { | ||||||
|     async function initiate() { |     async function initiate() { | ||||||
|       const variables = {}; |       const variables = {}; | ||||||
| @@ -35,11 +33,6 @@ export default function CreateFlow() { | |||||||
|     } |     } | ||||||
|     initiate(); |     initiate(); | ||||||
|   }, [createFlow, navigate, appKey, connectionId]); |   }, [createFlow, navigate, appKey, connectionId]); | ||||||
|  |  | ||||||
|   if (error) { |  | ||||||
|     return null; |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Box |     <Box | ||||||
|       sx={{ |       sx={{ | ||||||
| @@ -52,6 +45,7 @@ export default function CreateFlow() { | |||||||
|       }} |       }} | ||||||
|     > |     > | ||||||
|       <CircularProgress size={16} thickness={7.5} /> |       <CircularProgress size={16} thickness={7.5} /> | ||||||
|  |  | ||||||
|       <Typography variant="body2"> |       <Typography variant="body2"> | ||||||
|         {formatMessage('createFlow.creating')} |         {formatMessage('createFlow.creating')} | ||||||
|       </Typography> |       </Typography> | ||||||
|   | |||||||
| @@ -17,7 +17,6 @@ import Container from 'components/Container'; | |||||||
| import PageTitle from 'components/PageTitle'; | import PageTitle from 'components/PageTitle'; | ||||||
| import SearchInput from 'components/SearchInput'; | import SearchInput from 'components/SearchInput'; | ||||||
| import useFormatMessage from 'hooks/useFormatMessage'; | import useFormatMessage from 'hooks/useFormatMessage'; | ||||||
| import useCurrentUserAbility from 'hooks/useCurrentUserAbility'; |  | ||||||
| import * as URLS from 'config/urls'; | import * as URLS from 'config/urls'; | ||||||
| import useLazyFlows from 'hooks/useLazyFlows'; | import useLazyFlows from 'hooks/useLazyFlows'; | ||||||
|  |  | ||||||
| @@ -27,7 +26,6 @@ export default function Flows() { | |||||||
|   const page = parseInt(searchParams.get('page') || '', 10) || 1; |   const page = parseInt(searchParams.get('page') || '', 10) || 1; | ||||||
|   const [flowName, setFlowName] = React.useState(''); |   const [flowName, setFlowName] = React.useState(''); | ||||||
|   const [isLoading, setIsLoading] = React.useState(false); |   const [isLoading, setIsLoading] = React.useState(false); | ||||||
|   const currentUserAbility = useCurrentUserAbility(); |  | ||||||
|  |  | ||||||
|   const { data, mutate: fetchFlows } = useLazyFlows( |   const { data, mutate: fetchFlows } = useLazyFlows( | ||||||
|     { flowName, page }, |     { flowName, page }, | ||||||
| @@ -126,9 +124,7 @@ export default function Flows() { | |||||||
|         {!isLoading && !hasFlows && ( |         {!isLoading && !hasFlows && ( | ||||||
|           <NoResultFound |           <NoResultFound | ||||||
|             text={formatMessage('flows.noFlows')} |             text={formatMessage('flows.noFlows')} | ||||||
|             {...(currentUserAbility.can('create', 'Flow') && { |             to={URLS.CREATE_FLOW} | ||||||
|               to: URLS.CREATE_FLOW, |  | ||||||
|             })} |  | ||||||
|           /> |           /> | ||||||
|         )} |         )} | ||||||
|         {!isLoading && pageInfo && pageInfo.totalPages > 1 && ( |         {!isLoading && pageInfo && pageInfo.totalPages > 1 && ( | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user