Compare commits

..

27 Commits

Author SHA1 Message Date
kasia.oczkowska
8f3ecb6d4d feat: include dynamic fields in the form validation 2024-07-10 14:56:07 +01:00
Kasia
47caa5aa37 feat: add dynamic fields to validation schema and debug problem with dependsOn field 2024-07-10 14:56:07 +01:00
Ömer Faruk Aydın
725b38c697 Merge pull request #1955 from automatisch/repository-structure
docs: Adjust repository to the new packages structure
2024-07-09 13:14:27 +02:00
Faruk AYDIN
402a0fdf3b docs: Adjust repository to the new packages structure 2024-07-08 21:11:25 +02:00
Ali BARIN
078364ffa1 Merge pull request #1951 from automatisch/AUT-1069
fix: set default locale for luxon's  DateTime
2024-07-08 15:19:14 +02:00
kasia.oczkowska
f64d5ec4fc fix: set default locale for luxon's DateTime 2024-07-04 15:14:28 +01:00
Ali BARIN
12194a50e1 Merge pull request #1948 from automatisch/AUT-1076
feat: block access to flow creation for users without permissions
2024-07-04 10:44:15 +02:00
kasia.oczkowska
82ee592699 feat: block access to flow creation for users without permissions 2024-07-04 08:57:03 +01:00
Ali BARIN
1b4fb2ce6e Merge pull request #1947 from automatisch/AUT-1070
feat: use try-catch block for updating flow status
2024-07-03 15:37:02 +02:00
kasia.oczkowska
ebea8d12d1 feat: use try-catch block for updating flow status 2024-07-03 14:04:11 +01:00
Ali BARIN
f842dd77df Merge pull request #1945 from automatisch/remove-access-tokens-in-perm-user-deletion
fix: delete access token before hard deleting user
2024-07-03 13:16:20 +02:00
Ali BARIN
a6ec7a6c99 Merge pull request #1944 from automatisch/fix-reset-password
fix(user): make resetPasswordToken relevant fields nullable
2024-07-03 13:16:12 +02:00
Ali BARIN
369c72282c fix: delete access token before hard deleting user 2024-07-01 11:41:11 +00:00
Ali BARIN
6f30c1a509 fix(user): make resetPasswordToken relevant fields nullable 2024-07-01 10:26:17 +00:00
Ali BARIN
abfd1116c7 Merge pull request #1941 from automatisch/AUT-1038
fix: persist value in ControlledCustomAutocomplete when it depends on other fields
2024-06-21 14:32:09 +02:00
kasia.oczkowska
017854955d fix: persist value in ControlledCustomAutocomplete when it depends on other fields 2024-06-21 10:37:39 +01:00
Ali BARIN
1405cddea1 Merge pull request #1940 from automatisch/AUT-1039
Revert "feat: persist parameters values in FlowSubstep (#1505)"
2024-06-20 15:57:47 +02:00
kasia.oczkowska
00dd3164c9 Revert "feat: persist parameters values in FlowSubstep (#1505)"
This reverts commit 017a881494.
2024-06-20 14:48:51 +01:00
Ali BARIN
d5cbc0f611 Merge pull request #1578 from automatisch/AUT-620
feat: improve UI display depending on user permissions
2024-06-20 12:04:31 +02:00
kasia.oczkowska
5d2e9ccc67 feat: improve UI display depending on user persmissions 2024-06-20 09:52:00 +00:00
kattoczko
017a881494 feat: persist parameters values in FlowSubstep (#1505)
* feat: persist parameters values in FlowSubstep

* feat: add missing import

* feat: use usePrevious hook
2024-06-20 11:13:45 +02:00
Alex Maslakov
52994970e6 fix(DynamicField): display long variables better
* Fix issue #1933

Problem with rendering in DynamicField component with long variables #1933

* Fix issue #1933 (2)

* Fix issue #1933 (3)
2024-06-20 09:55:08 +02:00
Ali BARIN
ebae629e5c Merge pull request #1931 from automatisch/AUT-1010
feat: improve nodes and edges state update
2024-06-19 16:01:24 +02:00
kasia.oczkowska
4d79220b0c refactor: fix spelling and wording errors 2024-06-14 12:39:42 +01:00
kasia.oczkowska
96fba7fbb8 feat: improve nodes and edges state update 2024-06-14 12:39:42 +01:00
Ali BARIN
e0d610071d Merge pull request #1917 from automatisch/AUT-1009
feat: hide react-flow attribution
2024-06-05 15:35:09 +02:00
kasia.oczkowska
ab0966c005 feat: hide react-flow attribution 2024-06-05 13:39:49 +01:00
43 changed files with 725 additions and 669 deletions

View File

@@ -1,8 +0,0 @@
<svg width="555" height="110" viewBox="0 0 555 110" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M541.011 83.513C541.347 87.339 544.407 92.209 549.709 92.209H552.811C554.014 92.209 555 91.2232 555 90.0197V21.7948H554.986C554.923 20.6454 553.974 19.7248 552.811 19.7248H543.199C542.036 19.7248 541.087 20.6454 541.023 21.7948H541.011V27.3385C535.122 20.0789 525.836 17.0657 516.525 17.0657C495.359 17.0657 478.202 34.2365 478.202 55.419C478.202 76.6027 495.359 93.7741 516.525 93.7741V93.7759C525.836 93.7759 535.983 90.1606 541.01 83.5042L541.011 83.513V83.513ZM516.562 80.3509C503.101 80.3509 492.188 69.1896 492.188 55.4192C492.188 41.6511 503.101 30.4892 516.562 30.4892C530.022 30.4892 540.934 41.6511 540.934 55.4192C540.934 69.1896 530.022 80.3509 516.562 80.3509V80.3509Z" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M466.05 85.8589L466.045 50.5554H466.046C466.046 30.655 453.501 17.2299 433.497 17.2299C423.947 17.2299 416.119 22.7561 413.355 27.5032C412.757 23.7913 410.788 19.8896 404.681 19.8896H401.569C400.365 19.8896 399.382 20.876 399.382 22.0795V83.6835C399.382 83.6853 399.382 83.69 399.382 83.6929V90.3102H399.394C399.457 91.4579 400.408 92.3796 401.57 92.3796H411.182C411.33 92.3796 411.474 92.3621 411.613 92.3347C411.677 92.3225 411.736 92.2975 411.798 92.28C411.869 92.2579 411.944 92.241 412.012 92.213C412.097 92.1775 412.175 92.1298 412.255 92.0855C412.294 92.0617 412.334 92.0448 412.372 92.0197C412.468 91.958 412.556 91.8835 412.641 91.8072C412.655 91.7932 412.672 91.7839 412.686 91.7711C412.781 91.6785 412.868 91.5766 412.946 91.4707C412.946 91.4689 412.946 91.4689 412.946 91.4689C413.187 91.1382 413.333 90.7399 413.357 90.3102H413.369V50.0081C413.369 39.3201 422.028 30.655 432.709 30.655C443.389 30.655 452.047 39.3201 452.047 50.0081L452.056 83.6952L452.058 83.6835C452.058 83.7132 452.063 83.7441 452.063 83.7761V90.3102H452.076C452.139 91.4579 453.089 92.3796 454.251 92.3796H463.864C464.012 92.3796 464.156 92.3621 464.295 92.3347C464.352 92.3243 464.404 92.3015 464.46 92.2858C464.538 92.2631 464.619 92.2433 464.695 92.213C464.773 92.1804 464.845 92.135 464.92 92.0931C464.965 92.0675 465.013 92.0489 465.056 92.0197C465.145 91.9615 465.226 91.8911 465.306 91.8212C465.326 91.8026 465.349 91.7886 465.368 91.7694C465.459 91.6814 465.54 91.5865 465.615 91.487C465.62 91.4794 465.626 91.4736 465.632 91.466C465.868 91.1382 466.013 90.7428 466.038 90.3161C466.038 90.3131 466.039 90.3102 466.039 90.3102H466.052V85.86L466.05 85.8589" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M365.94 83.5127C366.276 87.3387 369.336 92.2088 374.638 92.2088H377.74C378.943 92.2088 379.927 91.223 379.927 90.0195V21.7945H379.915C379.852 20.6452 378.901 19.7246 377.74 19.7246H368.128C366.965 19.7246 366.016 20.6452 365.951 21.7945H365.94V27.3382C360.05 20.0786 350.764 17.0654 341.453 17.0654C320.288 17.0654 303.131 34.2362 303.131 55.4188C303.131 76.6025 320.288 93.7739 341.453 93.7739V93.7756C350.764 93.7756 360.912 90.1604 365.939 83.504L365.94 83.5127V83.5127ZM341.49 80.3506C328.03 80.3506 317.117 69.1893 317.117 55.4189C317.117 41.6509 328.03 30.489 341.49 30.489C354.952 30.489 365.862 41.6509 365.862 55.4189C365.862 69.1893 354.952 80.3506 341.49 80.3506V80.3506Z" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M246.284 73.7415C252.702 78.1905 259.706 80.3513 266.437 80.3513C272.85 80.3513 279.479 77.0242 279.479 71.2337C279.479 63.5024 265.033 62.2995 255.957 59.2124C246.88 56.1252 239.061 49.7437 239.061 39.4092C239.061 23.5956 253.14 17.0645 266.281 17.0645C274.607 17.0645 283.198 19.8121 288.767 23.7482C290.686 25.2027 289.517 26.8726 289.517 26.8726L284.201 34.4716C283.603 35.3276 282.559 36.067 281.059 35.1407C279.559 34.2149 274.298 30.4884 266.281 30.4884C258.263 30.4884 253.434 34.1939 253.434 38.7868C253.434 44.2943 259.711 46.0266 267.063 47.9038C279.875 51.36 293.852 55.5144 293.852 71.2337C293.852 85.1665 280.829 93.777 266.437 93.777C255.53 93.777 246.244 90.6654 238.456 84.9459C236.834 83.3208 237.967 81.8121 237.967 81.8121L243.257 74.2515C244.334 72.8378 245.691 73.331 246.284 73.7415" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M209.331 83.5127C209.668 87.3387 212.728 92.2088 218.03 92.2088H221.132C222.334 92.2088 223.32 91.223 223.32 90.0195V21.7945H223.307C223.244 20.6452 222.294 19.7246 221.132 19.7246H211.519C210.357 19.7246 209.408 20.6452 209.343 21.7945H209.331V27.3382C203.442 20.0786 194.156 17.0654 184.845 17.0654C163.68 17.0654 146.522 34.2362 146.522 55.4188C146.522 76.6025 163.68 93.7739 184.845 93.7739V93.7756C194.156 93.7756 204.304 90.1604 209.33 83.504L209.331 83.5127V83.5127ZM184.883 80.3506C171.422 80.3506 160.509 69.1893 160.509 55.4189C160.509 41.6509 171.422 30.489 184.883 30.489C198.343 30.489 209.255 41.6509 209.255 55.4189C209.255 69.1893 198.343 80.3506 184.883 80.3506V80.3506Z" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.794 58.0274C78.5507 58.0274 67.0041 69.5741 67.0041 83.8185C67.0041 98.0618 78.5507 109.608 92.794 109.608C107.037 109.608 118.584 98.0618 118.584 83.8185C118.584 69.5741 107.037 58.0274 92.794 58.0274V58.0274ZM25.7899 58.0298C11.5466 58.0298 0 69.5741 0 83.8186C0 98.0618 11.5466 109.608 25.7899 109.608C40.0338 109.608 51.581 98.0618 51.581 83.8186C51.581 69.5741 40.0338 58.0298 25.7899 58.0298V58.0298ZM85.0815 25.7894C85.0815 40.0338 73.5354 51.5816 59.2921 51.5816C45.0483 51.5816 33.5022 40.0338 33.5022 25.7894C33.5022 11.5478 45.0483 0 59.2921 0C73.5354 0 85.0815 11.5478 85.0815 25.7894V25.7894Z" fill="#FF584A"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -1,25 +0,0 @@
import { URLSearchParams } from 'url';
import crypto from 'crypto';
export default async function generateAuthUrl($) {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value;
const state = crypto.randomBytes(100).toString('base64url');
const searchParams = new URLSearchParams({
client_id: $.auth.data.clientId,
redirect_uri: redirectUri,
response_type: 'code',
//scope: authScope.join(' '),
state,
});
const url = `https://app.asana.com/-/oauth_authorize?${searchParams.toString()}`;
await $.auth.set({
url,
originalState: state,
});
}

View File

@@ -1,48 +0,0 @@
import generateAuthUrl from './generate-auth-url.js';
import verifyCredentials from './verify-credentials.js';
import refreshToken from './refresh-token.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/asana/connections/add',
placeholder: null,
description:
'When asked to input a redirect URL in Asana, enter the URL above.',
clickToCopy: true,
},
{
key: 'clientId',
label: 'Client ID',
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,
refreshToken,
};

View File

@@ -1,8 +0,0 @@
import getCurrentUser from '../common/get-current-user.js';
const isStillVerified = async ($) => {
const currentUser = await getCurrentUser($);
return !!currentUser.data.email;
};
export default isStillVerified;

View File

@@ -1,37 +0,0 @@
import { URLSearchParams } from 'node:url';
const refreshToken = async ($) => {
const oauthRedirectUrlField = $.app.auth.fields.find(
(field) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value;
const params = new URLSearchParams({
client_id: $.auth.data.clientId,
client_secret: $.auth.data.clientSecret,
redirect_uri: redirectUri,
grant_type: 'refresh_token',
refresh_token: $.auth.data.refreshToken,
});
const { data } = await $.http.post(
'https://app.asana.com/-/oauth_token',
params.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
additionalProperties: {
skipAddingAuthHeader: true,
},
}
);
await $.auth.set({
accessToken: data.access_token,
expiresIn: data.expires_in,
tokenType: data.token_type,
});
};
export default refreshToken;

View File

@@ -1,39 +0,0 @@
const verifyCredentials = async ($) => {
if ($.auth.data.originalState !== $.auth.data.state) {
throw new Error("The 'state' parameter does not match.");
}
const oauthRedirectUrlField = $.app.auth.fields.find(
(field) => field.key == 'oAuthRedirectUrl'
);
const redirectUri = oauthRedirectUrlField.value;
const { data } = await $.http.post(
'https://app.asana.com/-/oauth_token',
{
client_id: $.auth.data.clientId,
client_secret: $.auth.data.clientSecret,
code: $.auth.data.code,
grant_type: 'authorization_code',
redirect_uri: redirectUri,
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
}
);
await $.auth.set({
accessToken: data.access_token,
tokenType: data.token_type,
clientId: $.auth.data.clientId,
clientSecret: $.auth.data.clientSecret,
scope: $.auth.data.scope,
id: data.data.id,
gid: data.data.gid,
expiresIn: data.expires_in,
refreshToken: data.refresh_token,
screenName: `${data.data.name} - ${data.data.email}`,
});
};
export default verifyCredentials;

View File

@@ -1,12 +0,0 @@
const addAuthHeader = ($, requestConfig) => {
if (requestConfig.additionalProperties?.skipAddingAuthHeader)
return requestConfig;
if ($.auth.data?.accessToken) {
requestConfig.headers.Authorization = `${$.auth.data.tokenType} ${$.auth.data.accessToken}`;
}
return requestConfig;
};
export default addAuthHeader;

View File

@@ -1,8 +0,0 @@
const getCurrentUser = async ($) => {
const { data: currentUser } = await $.http.get(
`/1.0/users/${$.auth.data.gid}`
);
return currentUser;
};
export default getCurrentUser;

View File

@@ -1,16 +0,0 @@
import defineApp from '../../helpers/define-app.js';
import addAuthHeader from './common/add-auth-header.js';
import auth from './auth/index.js';
export default defineApp({
name: 'Asana',
key: 'asana',
baseUrl: 'https://asana.com',
apiBaseUrl: 'https://app.asana.com/api',
iconUrl: '{BASE_URL}/apps/asana/assets/favicon.svg',
authDocUrl: '{DOCS_URL}/apps/asana/connection',
primaryColor: '690031',
supportsConnections: true,
beforeRequest: [addAuthHeader],
auth,
});

View File

@@ -33,8 +33,8 @@ class User extends Base {
fullName: { type: 'string', minLength: 1 },
email: { type: 'string', format: 'email', minLength: 1, maxLength: 255 },
password: { type: 'string' },
resetPasswordToken: { type: 'string' },
resetPasswordTokenSentAt: { type: 'string' },
resetPasswordToken: { type: ['string', 'null'] },
resetPasswordTokenSentAt: { type: ['string', 'null'], format: 'date-time' },
trialExpiryDate: { type: 'string' },
roleId: { type: 'string', format: 'uuid' },
deletedAt: { type: 'string' },

View File

@@ -40,6 +40,7 @@ export const worker = new Worker(
await user.$relatedQuery('usageData').withSoftDeleted().hardDelete();
}
await user.$relatedQuery('accessTokens').withSoftDeleted().hardDelete();
await user.$query().withSoftDeleted().hardDelete();
},
{ connection: redisConfig }

View File

@@ -50,12 +50,6 @@ export default defineConfig({
{ text: 'Connection', link: '/apps/appwrite/connection' },
],
},
{
text: 'Asana',
collapsible: true,
collapsed: true,
items: [{ text: 'Connection', link: '/apps/asana/connection' }],
},
{
text: 'Carbone',
collapsible: true,

View File

@@ -1,17 +0,0 @@
# Asana
:::info
This page explains the steps you need to follow to set up the Asana
connection in Automatisch. If any of the steps are outdated, please let us know!
:::
1. Go to the [Asana developer console](https://app.asana.com/0/my-apps) to create a project.
2. Click on the **Create new app** in **My Apps**, and click on the **New Project** button.
3. Fill the form for your project and click on the **Create app** button.
4. Go to **Manage distrubition** and select **Any workspace** option in **Choose a distribution method** and save it.
5. Go to **OAuth** tab from left panel and click on the **+Add redirect URL** button.
6. Copy **OAuth Redirect URL** from Automatisch to the redirect url field, and click on the **Add** button.
7. Copy the **Your Client ID** value on the same page to the `Client ID` field on Automatisch.
8. Copy the **Your Client secret** value on the same page to the `Client Secret` field on Automatisch.
9. Click **Submit** button on Automatisch.
10. Congrats! Start using your new Asana connection within the flows.

View File

@@ -6,16 +6,12 @@ We use `lerna` with `yarn workspaces` to manage the mono repository. We have the
.
├── packages
│   ├── backend
│   ├── cli
│   ├── docs
│   ├── e2e-tests
│   ├── types
│   └── web
```
- `backend` - The backend package contains the backend application and all integrations.
- `cli` - The cli package contains the CLI application of Automatisch.
- `docs` - The docs package contains the documentation website.
- `e2e-tests` - The e2e-tests package contains the end-to-end tests for the internal usage.
- `types` - The types package contains the shared types for both the backend and web packages.
- `web` - The web package contains the frontend application of Automatisch.

View File

@@ -1,8 +0,0 @@
<svg width="555" height="110" viewBox="0 0 555 110" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M541.011 83.513C541.347 87.339 544.407 92.209 549.709 92.209H552.811C554.014 92.209 555 91.2232 555 90.0197V21.7948H554.986C554.923 20.6454 553.974 19.7248 552.811 19.7248H543.199C542.036 19.7248 541.087 20.6454 541.023 21.7948H541.011V27.3385C535.122 20.0789 525.836 17.0657 516.525 17.0657C495.359 17.0657 478.202 34.2365 478.202 55.419C478.202 76.6027 495.359 93.7741 516.525 93.7741V93.7759C525.836 93.7759 535.983 90.1606 541.01 83.5042L541.011 83.513V83.513ZM516.562 80.3509C503.101 80.3509 492.188 69.1896 492.188 55.4192C492.188 41.6511 503.101 30.4892 516.562 30.4892C530.022 30.4892 540.934 41.6511 540.934 55.4192C540.934 69.1896 530.022 80.3509 516.562 80.3509V80.3509Z" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M466.05 85.8589L466.045 50.5554H466.046C466.046 30.655 453.501 17.2299 433.497 17.2299C423.947 17.2299 416.119 22.7561 413.355 27.5032C412.757 23.7913 410.788 19.8896 404.681 19.8896H401.569C400.365 19.8896 399.382 20.876 399.382 22.0795V83.6835C399.382 83.6853 399.382 83.69 399.382 83.6929V90.3102H399.394C399.457 91.4579 400.408 92.3796 401.57 92.3796H411.182C411.33 92.3796 411.474 92.3621 411.613 92.3347C411.677 92.3225 411.736 92.2975 411.798 92.28C411.869 92.2579 411.944 92.241 412.012 92.213C412.097 92.1775 412.175 92.1298 412.255 92.0855C412.294 92.0617 412.334 92.0448 412.372 92.0197C412.468 91.958 412.556 91.8835 412.641 91.8072C412.655 91.7932 412.672 91.7839 412.686 91.7711C412.781 91.6785 412.868 91.5766 412.946 91.4707C412.946 91.4689 412.946 91.4689 412.946 91.4689C413.187 91.1382 413.333 90.7399 413.357 90.3102H413.369V50.0081C413.369 39.3201 422.028 30.655 432.709 30.655C443.389 30.655 452.047 39.3201 452.047 50.0081L452.056 83.6952L452.058 83.6835C452.058 83.7132 452.063 83.7441 452.063 83.7761V90.3102H452.076C452.139 91.4579 453.089 92.3796 454.251 92.3796H463.864C464.012 92.3796 464.156 92.3621 464.295 92.3347C464.352 92.3243 464.404 92.3015 464.46 92.2858C464.538 92.2631 464.619 92.2433 464.695 92.213C464.773 92.1804 464.845 92.135 464.92 92.0931C464.965 92.0675 465.013 92.0489 465.056 92.0197C465.145 91.9615 465.226 91.8911 465.306 91.8212C465.326 91.8026 465.349 91.7886 465.368 91.7694C465.459 91.6814 465.54 91.5865 465.615 91.487C465.62 91.4794 465.626 91.4736 465.632 91.466C465.868 91.1382 466.013 90.7428 466.038 90.3161C466.038 90.3131 466.039 90.3102 466.039 90.3102H466.052V85.86L466.05 85.8589" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M365.94 83.5127C366.276 87.3387 369.336 92.2088 374.638 92.2088H377.74C378.943 92.2088 379.927 91.223 379.927 90.0195V21.7945H379.915C379.852 20.6452 378.901 19.7246 377.74 19.7246H368.128C366.965 19.7246 366.016 20.6452 365.951 21.7945H365.94V27.3382C360.05 20.0786 350.764 17.0654 341.453 17.0654C320.288 17.0654 303.131 34.2362 303.131 55.4188C303.131 76.6025 320.288 93.7739 341.453 93.7739V93.7756C350.764 93.7756 360.912 90.1604 365.939 83.504L365.94 83.5127V83.5127ZM341.49 80.3506C328.03 80.3506 317.117 69.1893 317.117 55.4189C317.117 41.6509 328.03 30.489 341.49 30.489C354.952 30.489 365.862 41.6509 365.862 55.4189C365.862 69.1893 354.952 80.3506 341.49 80.3506V80.3506Z" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M246.284 73.7415C252.702 78.1905 259.706 80.3513 266.437 80.3513C272.85 80.3513 279.479 77.0242 279.479 71.2337C279.479 63.5024 265.033 62.2995 255.957 59.2124C246.88 56.1252 239.061 49.7437 239.061 39.4092C239.061 23.5956 253.14 17.0645 266.281 17.0645C274.607 17.0645 283.198 19.8121 288.767 23.7482C290.686 25.2027 289.517 26.8726 289.517 26.8726L284.201 34.4716C283.603 35.3276 282.559 36.067 281.059 35.1407C279.559 34.2149 274.298 30.4884 266.281 30.4884C258.263 30.4884 253.434 34.1939 253.434 38.7868C253.434 44.2943 259.711 46.0266 267.063 47.9038C279.875 51.36 293.852 55.5144 293.852 71.2337C293.852 85.1665 280.829 93.777 266.437 93.777C255.53 93.777 246.244 90.6654 238.456 84.9459C236.834 83.3208 237.967 81.8121 237.967 81.8121L243.257 74.2515C244.334 72.8378 245.691 73.331 246.284 73.7415" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M209.331 83.5127C209.668 87.3387 212.728 92.2088 218.03 92.2088H221.132C222.334 92.2088 223.32 91.223 223.32 90.0195V21.7945H223.307C223.244 20.6452 222.294 19.7246 221.132 19.7246H211.519C210.357 19.7246 209.408 20.6452 209.343 21.7945H209.331V27.3382C203.442 20.0786 194.156 17.0654 184.845 17.0654C163.68 17.0654 146.522 34.2362 146.522 55.4188C146.522 76.6025 163.68 93.7739 184.845 93.7739V93.7756C194.156 93.7756 204.304 90.1604 209.33 83.504L209.331 83.5127V83.5127ZM184.883 80.3506C171.422 80.3506 160.509 69.1893 160.509 55.4189C160.509 41.6509 171.422 30.489 184.883 30.489C198.343 30.489 209.255 41.6509 209.255 55.4189C209.255 69.1893 198.343 80.3506 184.883 80.3506V80.3506Z" fill="#690031"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M92.794 58.0274C78.5507 58.0274 67.0041 69.5741 67.0041 83.8185C67.0041 98.0618 78.5507 109.608 92.794 109.608C107.037 109.608 118.584 98.0618 118.584 83.8185C118.584 69.5741 107.037 58.0274 92.794 58.0274V58.0274ZM25.7899 58.0298C11.5466 58.0298 0 69.5741 0 83.8186C0 98.0618 11.5466 109.608 25.7899 109.608C40.0338 109.608 51.581 98.0618 51.581 83.8186C51.581 69.5741 40.0338 58.0298 25.7899 58.0298V58.0298ZM85.0815 25.7894C85.0815 40.0338 73.5354 51.5816 59.2921 51.5816C45.0483 51.5816 33.5022 40.0338 33.5022 25.7894C33.5022 11.5478 45.0483 0 59.2921 0C73.5354 0 85.0815 11.5478 85.0815 25.7894V25.7894Z" fill="#FF584A"/>
</svg>

Before

Width:  |  Height:  |  Size: 5.5 KiB

View File

@@ -68,7 +68,10 @@ function AccountDropdownMenu(props) {
AccountDropdownMenu.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]),
anchorEl: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
id: PropTypes.string.isRequired,
};

View File

@@ -8,6 +8,7 @@ import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import { ConnectionPropType } from 'propTypes/propTypes';
import { useQueryClient } from '@tanstack/react-query';
import Can from 'components/Can';
function ContextMenu(props) {
const {
@@ -44,34 +45,57 @@ function ContextMenu(props) {
hideBackdrop={false}
anchorEl={anchorEl}
>
<MenuItem
component={Link}
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)}
onClick={createActionHandler({ type: 'viewFlows' })}
>
{formatMessage('connection.viewFlows')}
</MenuItem>
<MenuItem onClick={createActionHandler({ type: 'test' })}>
{formatMessage('connection.testConnection')}
</MenuItem>
<MenuItem
component={Link}
disabled={disableReconnection}
to={URLS.APP_RECONNECT_CONNECTION(
appKey,
connection.id,
connection.appAuthClientId,
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem
component={Link}
to={URLS.APP_FLOWS_FOR_CONNECTION(appKey, connection.id)}
onClick={createActionHandler({ type: 'viewFlows' })}
disabled={!allowed}
>
{formatMessage('connection.viewFlows')}
</MenuItem>
)}
onClick={createActionHandler({ type: 'reconnect' })}
>
{formatMessage('connection.reconnect')}
</MenuItem>
</Can>
<MenuItem onClick={createActionHandler({ type: 'delete' })}>
{formatMessage('connection.delete')}
</MenuItem>
<Can I="update" a="Connection" passThrough>
{(allowed) => (
<MenuItem
onClick={createActionHandler({ type: 'test' })}
disabled={!allowed}
>
{formatMessage('connection.testConnection')}
</MenuItem>
)}
</Can>
<Can I="create" a="Connection" passThrough>
{(allowed) => (
<MenuItem
component={Link}
disabled={!allowed || disableReconnection}
to={URLS.APP_RECONNECT_CONNECTION(
appKey,
connection.id,
connection.appAuthClientId,
)}
onClick={createActionHandler({ type: 'reconnect' })}
>
{formatMessage('connection.reconnect')}
</MenuItem>
)}
</Can>
<Can I="delete" a="Connection" passThrough>
{(allowed) => (
<MenuItem
onClick={createActionHandler({ type: 'delete' })}
disabled={!allowed}
>
{formatMessage('connection.delete')}
</MenuItem>
)}
</Can>
</Menu>
);
}

View File

@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import AppConnectionRow from 'components/AppConnectionRow';
import NoResultFound from 'components/NoResultFound';
import Can from 'components/Can';
import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls';
import useAppConnections from 'hooks/useAppConnections';
@@ -16,11 +17,15 @@ function AppConnections(props) {
if (!hasConnections) {
return (
<NoResultFound
to={URLS.APP_ADD_CONNECTION(appKey)}
text={formatMessage('app.noConnections')}
data-test="connections-no-results"
/>
<Can I="create" a="Connection" passThrough>
{(allowed) => (
<NoResultFound
text={formatMessage('app.noConnections')}
data-test="connections-no-results"
{...(allowed && { to: URLS.APP_ADD_CONNECTION(appKey) })}
/>
)}
</Can>
);
}

View File

@@ -5,6 +5,7 @@ import PaginationItem from '@mui/material/PaginationItem';
import * as URLS from 'config/urls';
import AppFlowRow from 'components/FlowRow';
import Can from 'components/Can';
import NoResultFound from 'components/NoResultFound';
import useFormatMessage from 'hooks/useFormatMessage';
import useConnectionFlows from 'hooks/useConnectionFlows';
@@ -36,11 +37,20 @@ function AppFlows(props) {
if (!hasFlows) {
return (
<NoResultFound
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(appKey, connectionId)}
text={formatMessage('app.noFlows')}
data-test="flows-no-results"
/>
<Can I="create" a="Flow" passThrough>
{(allowed) => (
<NoResultFound
text={formatMessage('app.noFlows')}
data-test="flows-no-results"
{...(allowed && {
to: URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
appKey,
connectionId
),
})}
/>
)}
</Can>
);
}

View File

@@ -21,7 +21,9 @@ const CustomOptions = (props) => {
label,
initialTabIndex,
} = props;
const [activeTabIndex, setActiveTabIndex] = React.useState(undefined);
React.useEffect(
function applyInitialActiveTabIndex() {
setActiveTabIndex((currentActiveTabIndex) => {
@@ -33,6 +35,7 @@ const CustomOptions = (props) => {
},
[initialTabIndex],
);
return (
<Popper
open={open}
@@ -76,7 +79,10 @@ const CustomOptions = (props) => {
CustomOptions.propTypes = {
open: PropTypes.bool.isRequired,
anchorEl: PropTypes.oneOfType([PropTypes.element, PropTypes.func]).isRequired,
anchorEl: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
data: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string.isRequired,

View File

@@ -61,6 +61,7 @@ function ControlledCustomAutocomplete(props) {
const [isSingleChoice, setSingleChoice] = React.useState(undefined);
const priorStepsWithExecutions = React.useContext(StepExecutionsContext);
const editorRef = React.useRef(null);
const mountedRef = React.useRef(false);
const renderElement = React.useCallback(
(props) => <Element {...props} disabled={disabled} />,
@@ -94,10 +95,14 @@ function ControlledCustomAutocomplete(props) {
}, []);
React.useEffect(() => {
const hasDependencies = dependsOnValues.length;
if (hasDependencies) {
// Reset the field when a dependent has been updated
resetEditor(editor);
if (mountedRef.current) {
const hasDependencies = dependsOnValues.length;
if (hasDependencies) {
// Reset the field when a dependent has been updated
resetEditor(editor);
}
} else {
mountedRef.current = true;
}
}, dependsOnValues);

View File

@@ -64,11 +64,19 @@ function DynamicField(props) {
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={{ xs: 2 }}
sx={{ display: 'flex', flex: 1 }}
sx={{
display: 'flex',
flex: 1,
minWidth: 0,
}}
>
{fields.map((fieldSchema, fieldSchemaIndex) => (
<Box
sx={{ display: 'flex', flex: '1 0 0px' }}
sx={{
display: 'flex',
flex: '1 0 0px',
minWidth: 0,
}}
key={`field-${field.__id}-${fieldSchemaIndex}`}
>
<InputCreator

View File

@@ -59,23 +59,25 @@ export default function EditorLayout() {
const onFlowStatusUpdate = React.useCallback(
async (active) => {
await updateFlowStatus({
variables: {
input: {
id: flowId,
active,
try {
await updateFlowStatus({
variables: {
input: {
id: flowId,
active,
},
},
},
optimisticResponse: {
updateFlowStatus: {
__typename: 'Flow',
id: flowId,
active,
optimisticResponse: {
updateFlowStatus: {
__typename: 'Flow',
id: flowId,
active,
},
},
},
});
});
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
} catch (err) {}
},
[flowId, queryClient],
);

View File

@@ -1,10 +1,10 @@
import { EdgeLabelRenderer, getStraightPath } from 'reactflow';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import { useMutation } from '@apollo/client';
import { CREATE_STEP } from 'graphql/mutations/create-step';
import { useQueryClient } from '@tanstack/react-query';
import PropTypes from 'prop-types';
import { useContext } from 'react';
import { EdgesContext } from '../EditorNew';
export default function Edge({
sourceX,
@@ -12,11 +12,11 @@ export default function Edge({
targetX,
targetY,
source,
data: { flowId, setCurrentStepId, flowActive, layouted },
data: { laidOut },
}) {
const [createStep, { loading: creationInProgress }] =
useMutation(CREATE_STEP);
const queryClient = useQueryClient();
const { stepCreationInProgress, flowActive, onAddStep } =
useContext(EdgesContext);
const [edgePath, labelX, labelY] = getStraightPath({
sourceX,
sourceY,
@@ -24,38 +24,19 @@ export default function Edge({
targetY,
});
const addStep = async (previousStepId) => {
const mutationInput = {
previousStep: {
id: previousStepId,
},
flow: {
id: flowId,
},
};
const createdStep = await createStep({
variables: { input: mutationInput },
});
const createdStepId = createdStep.data.createStep.id;
setCurrentStepId(createdStepId);
await queryClient.invalidateQueries({ queryKey: ['flows', flowId] });
};
return (
<>
<EdgeLabelRenderer>
<IconButton
onClick={() => addStep(source)}
onClick={() => onAddStep(source)}
color="primary"
sx={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'all',
visibility: layouted ? 'visible' : 'hidden',
visibility: laidOut ? 'visible' : 'hidden',
}}
disabled={creationInProgress || flowActive}
disabled={stepCreationInProgress || flowActive}
>
<AddIcon />
</IconButton>
@@ -71,9 +52,6 @@ Edge.propTypes = {
targetY: PropTypes.number.isRequired,
source: PropTypes.string.isRequired,
data: PropTypes.shape({
flowId: PropTypes.string.isRequired,
setCurrentStepId: PropTypes.func.isRequired,
flowActive: PropTypes.bool.isRequired,
layouted: PropTypes.bool,
laidOut: PropTypes.bool,
}).isRequired,
};

View File

@@ -1,51 +1,81 @@
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useCallback, createContext, useRef } from 'react';
import { useMutation } from '@apollo/client';
import { useQueryClient } from '@tanstack/react-query';
import { FlowPropType } from 'propTypes/propTypes';
import ReactFlow, { useNodesState, useEdgesState, addEdge } from 'reactflow';
import ReactFlow, { useNodesState, useEdgesState } from 'reactflow';
import 'reactflow/dist/style.css';
import { Stack } from '@mui/material';
import { UPDATE_STEP } from 'graphql/mutations/update-step';
import { CREATE_STEP } from 'graphql/mutations/create-step';
import { useAutoLayout } from './useAutoLayout';
import { useScrollBoundries } from './useScrollBoundries';
import { useScrollBoundaries } from './useScrollBoundaries';
import FlowStepNode from './FlowStepNode/FlowStepNode';
import Edge from './Edge/Edge';
import InvisibleNode from './InvisibleNode/InvisibleNode';
import { EditorWrapper } from './style';
import {
generateEdgeId,
generateInitialEdges,
generateInitialNodes,
updatedCollapsedNodes,
} from './utils';
import { EDGE_TYPES, INVISIBLE_NODE_ID, NODE_TYPES } from './constants';
const nodeTypes = { flowStep: FlowStepNode, invisible: InvisibleNode };
export const EdgesContext = createContext();
export const NodesContext = createContext();
const edgeTypes = {
addNodeEdge: Edge,
const nodeTypes = {
[NODE_TYPES.FLOW_STEP]: FlowStepNode,
[NODE_TYPES.INVISIBLE]: InvisibleNode,
};
const INVISIBLE_NODE_ID = 'invisible-node';
const generateEdgeId = (sourceId, targetId) => `${sourceId}-${targetId}`;
const edgeTypes = {
[EDGE_TYPES.ADD_NODE_EDGE]: Edge,
};
const EditorNew = ({ flow }) => {
const [triggerStep] = flow.steps;
const [currentStepId, setCurrentStepId] = useState(triggerStep.id);
const [updateStep] = useMutation(UPDATE_STEP);
const queryClient = useQueryClient();
const [createStep, { loading: stepCreationInProgress }] =
useMutation(CREATE_STEP);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
useAutoLayout();
useScrollBoundries();
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges],
const [nodes, setNodes, onNodesChange] = useNodesState(
generateInitialNodes(flow),
);
const [edges, setEdges, onEdgesChange] = useEdgesState(
generateInitialEdges(flow),
);
useAutoLayout();
useScrollBoundaries();
const createdStepIdRef = useRef(null);
const openNextStep = useCallback(
(nextStep) => () => {
setCurrentStepId(nextStep?.id);
(currentStepId) => {
setNodes((nodes) => {
const currentStepIndex = nodes.findIndex(
(node) => node.id === currentStepId,
);
if (currentStepIndex >= 0) {
const nextStep = nodes[currentStepIndex + 1];
return updatedCollapsedNodes(nodes, nextStep.id);
}
return nodes;
});
},
[],
[setNodes],
);
const onStepClose = useCallback(() => {
setNodes((nodes) => updatedCollapsedNodes(nodes));
}, [setNodes]);
const onStepOpen = useCallback(
(stepId) => {
setNodes((nodes) => updatedCollapsedNodes(nodes, stepId));
},
[setNodes],
);
const onStepChange = useCallback(
@@ -77,177 +107,166 @@ const EditorNew = ({ flow }) => {
[flow.id, updateStep, queryClient],
);
const generateEdges = useCallback((flow, prevEdges) => {
const newEdges =
flow.steps
.map((step, i) => {
const sourceId = step.id;
const targetId = flow.steps[i + 1]?.id;
const edge = prevEdges?.find(
(edge) => edge.id === generateEdgeId(sourceId, targetId),
);
if (targetId) {
return {
id: generateEdgeId(sourceId, targetId),
source: sourceId,
target: targetId,
type: 'addNodeEdge',
data: {
flowId: flow.id,
flowActive: flow.active,
setCurrentStepId,
layouted: !!edge,
},
};
}
})
.filter((edge) => !!edge) || [];
const onAddStep = useCallback(
async (previousStepId) => {
const mutationInput = {
previousStep: {
id: previousStepId,
},
flow: {
id: flow.id,
},
};
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: {
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,
},
};
const {
data: { createStep: createdStep },
} = await createStep({
variables: { input: mutationInput },
});
const prevInvisibleNode = nodes.find((node) => node.type === 'invisible');
return [
...newNodes,
{
id: INVISIBLE_NODE_ID,
type: 'invisible',
position: {
x: prevInvisibleNode ? prevInvisibleNode.position.x : 0,
y: prevInvisibleNode ? prevInvisibleNode.position.y : 0,
},
},
];
const createdStepId = createdStep.id;
await queryClient.invalidateQueries({ queryKey: ['flows', flow.id] });
createdStepIdRef.current = createdStepId;
},
[currentStepId, nodes, onStepChange, openNextStep],
[flow.id, createStep, queryClient],
);
const updateNodesData = useCallback(
(steps) => {
setNodes((nodes) =>
nodes.map((node) => {
const step = steps.find((step) => step.id === node.id);
if (step) {
return { ...node, data: { ...node.data, step: { ...step } } };
}
return node;
}),
);
},
[setNodes],
);
const updateEdgesData = useCallback(
(flow) => {
setEdges((edges) =>
edges.map((edge) => {
return {
...edge,
data: { ...edge.data, flowId: flow.id, flowActive: flow.active },
};
}),
);
},
[setEdges],
);
useEffect(() => {
setNodes(
nodes.map((node) => {
if (node.type === 'flowStep') {
const collapsed = currentStepId !== node.data.step.id;
return {
...node,
zIndex: collapsed ? 0 : 1,
data: {
...node.data,
collapsed,
},
};
}
return node;
}),
);
}, [currentStepId]);
useEffect(() => {
if (flow.steps.length + 1 !== nodes.length) {
const newNodes = generateNodes(flow, nodes);
const newEdges = generateEdges(flow, edges);
setNodes((nodes) => {
const newNodes = flow.steps.map((step) => {
const createdStepId = createdStepIdRef.current;
const prevNode = nodes.find(({ id }) => id === step.id);
if (prevNode) {
return {
...prevNode,
zIndex: createdStepId ? 0 : prevNode.zIndex,
data: {
...prevNode.data,
collapsed: createdStepId ? true : prevNode.data.collapsed,
},
};
} else {
return {
id: step.id,
type: NODE_TYPES.FLOW_STEP,
position: {
x: 0,
y: 0,
},
zIndex: 1,
data: {
collapsed: false,
laidOut: false,
},
};
}
});
setNodes(newNodes);
setEdges(newEdges);
} else {
updateNodesData(flow.steps);
updateEdgesData(flow);
const prevInvisible = nodes.find(({ id }) => id === INVISIBLE_NODE_ID);
return [
...newNodes,
{
id: INVISIBLE_NODE_ID,
type: NODE_TYPES.INVISIBLE,
position: {
x: prevInvisible?.position.x || 0,
y: prevInvisible?.position.y || 0,
},
},
];
});
setEdges((edges) => {
const newEdges = flow.steps
.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];
const lastEdge = edges[edges.length - 1];
return lastStep
? [
...newEdges,
{
id: generateEdgeId(lastStep.id, INVISIBLE_NODE_ID),
source: lastStep.id,
target: INVISIBLE_NODE_ID,
type: 'addNodeEdge',
data: {
laidOut:
lastEdge?.id ===
generateEdgeId(lastStep.id, INVISIBLE_NODE_ID)
? lastEdge?.data.laidOut
: false,
},
},
]
: newEdges;
});
if (createdStepIdRef.current) {
createdStepIdRef.current = null;
}
}
}, [flow]);
}, [flow.steps]);
return (
<EditorWrapper direction="column">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
panOnScroll
panOnScrollMode="vertical"
panOnDrag={false}
zoomOnScroll={false}
zoomOnPinch={false}
zoomOnDoubleClick={false}
panActivationKeyCode={null}
/>
</EditorWrapper>
<NodesContext.Provider
value={{
openNextStep,
onStepOpen,
onStepClose,
onStepChange,
flowId: flow.id,
steps: flow.steps,
}}
>
<EdgesContext.Provider
value={{
stepCreationInProgress,
onAddStep,
flowActive: flow.active,
}}
>
<EditorWrapper direction="column">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
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>
);
};

View File

@@ -1,30 +1,23 @@
import { Handle, Position } from 'reactflow';
import { Box } from '@mui/material';
import PropTypes from 'prop-types';
import FlowStep from 'components/FlowStep';
import { StepPropType } from 'propTypes/propTypes';
import { NodeWrapper, NodeInnerWrapper } from './style.js';
import { useContext } from 'react';
import { NodesContext } from '../EditorNew.jsx';
function FlowStepNode({ data: { collapsed, laidOut }, id }) {
const { openNextStep, onStepOpen, onStepClose, onStepChange, flowId, steps } =
useContext(NodesContext);
const step = steps.find(({ id: stepId }) => stepId === id);
function FlowStepNode({
data: {
step,
index,
flowId,
collapsed,
openNextStep,
onOpen,
onClose,
onChange,
layouted,
},
}) {
return (
<NodeWrapper
className="nodrag"
sx={{
visibility: layouted ? 'visible' : 'hidden',
visibility: laidOut ? 'visible' : 'hidden',
}}
>
<NodeInnerWrapper>
@@ -34,16 +27,17 @@ function FlowStepNode({
isConnectable={false}
style={{ visibility: 'hidden' }}
/>
<FlowStep
step={step}
index={index + 1}
collapsed={collapsed}
onOpen={onOpen}
onClose={onClose}
onChange={onChange}
flowId={flowId}
onContinue={openNextStep}
/>
{step && (
<FlowStep
step={step}
collapsed={collapsed}
onOpen={() => onStepOpen(step.id)}
onClose={onStepClose}
onChange={onStepChange}
flowId={flowId}
onContinue={() => openNextStep(step.id)}
/>
)}
<Handle
type="source"
position={Position.Bottom}
@@ -56,16 +50,10 @@ function FlowStepNode({
}
FlowStepNode.propTypes = {
id: PropTypes.string,
data: PropTypes.shape({
step: StepPropType.isRequired,
index: PropTypes.number.isRequired,
flowId: PropTypes.string.isRequired,
collapsed: PropTypes.bool.isRequired,
openNextStep: PropTypes.func.isRequired,
onOpen: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
layouted: PropTypes.bool.isRequired,
laidOut: PropTypes.bool.isRequired,
}).isRequired,
};

View File

@@ -0,0 +1,10 @@
export const INVISIBLE_NODE_ID = 'invisible-node';
export const NODE_TYPES = {
FLOW_STEP: 'flowStep',
INVISIBLE: 'invisible',
};
export const EDGE_TYPES = {
ADD_NODE_EDGE: 'addNodeEdge',
};

View File

@@ -4,7 +4,7 @@ import { usePrevious } from 'hooks/usePrevious';
import { isEqual } from 'lodash';
import { useNodesInitialized, useNodes, useReactFlow } from 'reactflow';
const getLayoutedElements = (nodes, edges) => {
const getLaidOutElements = (nodes, edges) => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({
rankdir: 'TB',
@@ -36,18 +36,18 @@ export const useAutoLayout = () => {
const onLayout = useCallback(
(nodes, edges) => {
const layoutedElements = getLayoutedElements(nodes, edges);
const laidOutElements = getLaidOutElements(nodes, edges);
setNodes([
...layoutedElements.nodes.map((node) => ({
...laidOutElements.nodes.map((node) => ({
...node,
data: { ...node.data, layouted: true },
data: { ...node.data, laidOut: true },
})),
]);
setEdges([
...layoutedElements.edges.map((edge) => ({
...laidOutElements.edges.map((edge) => ({
...edge,
data: { ...edge.data, layouted: true },
data: { ...edge.data, laidOut: true },
})),
]);
},

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useViewport, useReactFlow } from 'reactflow';
export const useScrollBoundries = () => {
export const useScrollBoundaries = () => {
const { setViewport } = useReactFlow();
const { x, y, zoom } = useViewport();

View File

@@ -0,0 +1,88 @@
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;
};

View File

@@ -11,9 +11,6 @@ import IconButton from '@mui/material/IconButton';
import ErrorIcon from '@mui/icons-material/Error';
import CircularProgress from '@mui/material/CircularProgress';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { EditorContext } from 'contexts/Editor';
import { StepExecutionsProvider } from 'contexts/StepExecutions';
import TestSubstep from 'components/TestSubstep';
@@ -33,77 +30,18 @@ import {
Header,
Wrapper,
} from './style';
import isEmpty from 'helpers/isEmpty';
import { StepPropType } from 'propTypes/propTypes';
import useTriggers from 'hooks/useTriggers';
import useActions from 'hooks/useActions';
import useTriggerSubsteps from 'hooks/useTriggerSubsteps';
import useActionSubsteps from 'hooks/useActionSubsteps';
import useStepWithTestExecutions from 'hooks/useStepWithTestExecutions';
import { validationSchemaResolver } from './validation';
import { isEqual } from 'lodash';
const validIcon = <CheckCircleIcon color="success" />;
const errorIcon = <ErrorIcon color="error" />;
function generateValidationSchema(substeps) {
const fieldValidations = substeps?.reduce(
(allValidations, { arguments: args }) => {
if (!args || !Array.isArray(args)) return allValidations;
const substepArgumentValidations = {};
for (const arg of args) {
const { key, required } = arg;
// base validation for the field if not exists
if (!substepArgumentValidations[key]) {
substepArgumentValidations[key] = yup.mixed();
}
if (
typeof substepArgumentValidations[key] === 'object' &&
(arg.type === 'string' || arg.type === 'dropdown')
) {
// if the field is required, add the required validation
if (required) {
substepArgumentValidations[key] = substepArgumentValidations[key]
.required(`${key} is required.`)
.test(
'empty-check',
`${key} must be not empty`,
(value) => !isEmpty(value),
);
}
// if the field depends on another field, add the dependsOn required validation
if (Array.isArray(arg.dependsOn) && arg.dependsOn.length > 0) {
for (const dependsOnKey of arg.dependsOn) {
const missingDependencyValueMessage = `We're having trouble loading '${key}' data as required field '${dependsOnKey}' is missing.`;
// TODO: make `dependsOnKey` agnostic to the field. However, nested validation schema is not supported.
// So the fields under the `parameters` key are subject to their siblings only and thus, `parameters.` is removed.
substepArgumentValidations[key] = substepArgumentValidations[
key
].when(`${dependsOnKey.replace('parameters.', '')}`, {
is: (value) => Boolean(value) === false,
then: (schema) =>
schema
.notOneOf([''], missingDependencyValueMessage)
.required(missingDependencyValueMessage),
});
}
}
}
}
return {
...allValidations,
...substepArgumentValidations,
};
},
{},
);
const validationSchema = yup.object({
parameters: yup.object(fieldValidations),
});
return yupResolver(validationSchema);
}
function FlowStep(props) {
const { collapsed, onChange, onContinue, flowId } = props;
const editorContext = React.useContext(EditorContext);
@@ -114,6 +52,10 @@ function FlowStep(props) {
const isAction = step.type === 'action';
const formatMessage = useFormatMessage();
const [currentSubstep, setCurrentSubstep] = React.useState(0);
const [formResolverContext, setFormResolverContext] = React.useState({
substeps: [],
additionalFields: {},
});
const useAppsOptions = {};
if (isTrigger) {
@@ -168,6 +110,12 @@ function FlowStep(props) {
? triggerSubstepsData
: actionSubstepsData || [];
React.useEffect(() => {
if (!isEqual(substeps, formResolverContext.substeps)) {
setFormResolverContext({ substeps, additionalFields: {} });
}
}, [substeps]);
const handleChange = React.useCallback(({ step }) => {
onChange(step);
}, []);
@@ -180,11 +128,6 @@ function FlowStep(props) {
handleChange({ step: val });
};
const stepValidationSchema = React.useMemo(
() => generateValidationSchema(substeps),
[substeps],
);
if (!apps?.data) {
return (
<CircularProgress
@@ -213,6 +156,15 @@ function FlowStep(props) {
value !== substepIndex ? substepIndex : null,
);
const addAdditionalFieldsValidation = (additionalFields) => {
if (additionalFields) {
setFormResolverContext((prev) => ({
...prev,
additionalFields: { ...prev.additionalFields, ...additionalFields },
}));
}
};
const validationStatusIcon =
step.status === 'completed' ? validIcon : errorIcon;
@@ -266,7 +218,8 @@ function FlowStep(props) {
<Form
defaultValues={step}
onSubmit={handleSubmit}
resolver={stepValidationSchema}
resolver={validationSchemaResolver}
context={formResolverContext}
>
<ChooseAppAndEventSubstep
expanded={currentSubstep === 0}
@@ -330,6 +283,9 @@ function FlowStep(props) {
onSubmit={expandNextStep}
onChange={handleChange}
step={step}
addAdditionalFieldsValidation={
addAdditionalFieldsValidation
}
/>
)}
</React.Fragment>
@@ -360,7 +316,6 @@ function FlowStep(props) {
FlowStep.propTypes = {
collapsed: PropTypes.bool,
step: StepPropType.isRequired,
index: PropTypes.number,
onOpen: PropTypes.func,
onClose: PropTypes.func,
onChange: PropTypes.func.isRequired,

View File

@@ -0,0 +1,120 @@
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);
}

View File

@@ -43,7 +43,10 @@ function FlowStepContextMenu(props) {
FlowStepContextMenu.propTypes = {
stepId: PropTypes.string.isRequired,
onClose: PropTypes.func.isRequired,
anchorEl: PropTypes.element.isRequired,
anchorEl: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
deletable: PropTypes.bool.isRequired,
};

View File

@@ -19,7 +19,9 @@ function FlowSubstep(props) {
onCollapse,
onSubmit,
step,
addAdditionalFieldsValidation,
} = props;
const { name, arguments: args } = substep;
const editorContext = React.useContext(EditorContext);
const formContext = useFormContext();
@@ -54,6 +56,7 @@ function FlowSubstep(props) {
stepId={step.id}
disabled={editorContext.readOnly}
showOptionValue={true}
addAdditionalFieldsValidation={addAdditionalFieldsValidation}
/>
))}
</Stack>

View File

@@ -1,6 +1,8 @@
import * as React from 'react';
import { FormProvider, useForm, useWatch } from 'react-hook-form';
const noop = () => null;
export default function Form(props) {
const {
children,
@@ -9,24 +11,31 @@ export default function Form(props) {
resolver,
render,
mode = 'all',
context,
...formProps
} = props;
const methods = useForm({
defaultValues,
reValidateMode: 'onBlur',
resolver,
mode,
context,
});
const form = useWatch({ control: methods.control });
/**
* For fields having `dependsOn` fields, we need to re-validate the form.
*/
React.useEffect(() => {
methods.trigger();
}, [methods.trigger, form]);
React.useEffect(() => {
methods.reset(defaultValues);
}, [defaultValues]);
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)} {...formProps}>

View File

@@ -23,7 +23,9 @@ export default function InputCreator(props) {
disabled,
showOptionValue,
shouldUnregister,
addAdditionalFieldsValidation,
} = props;
const {
key: name,
label,
@@ -33,6 +35,7 @@ export default function InputCreator(props) {
description,
type,
} = schema;
const { data, loading } = useDynamicData(stepId, schema);
const { data: additionalFieldsData, isLoading: isDynamicFieldsLoading } =
useDynamicFields(stepId, schema);
@@ -40,6 +43,10 @@ export default function InputCreator(props) {
const computedName = namePrefix ? `${namePrefix}.${name}` : name;
React.useEffect(() => {
addAdditionalFieldsValidation?.({ [name]: additionalFields });
}, [additionalFields]);
if (type === 'dynamic') {
return (
<DynamicField

View File

@@ -5,8 +5,10 @@ import AddCircleIcon from '@mui/icons-material/AddCircle';
import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography';
import { CardContent } from './style';
export default function NoResultFound(props) {
const { text, to } = props;
const ActionAreaLink = React.useMemo(
() =>
React.forwardRef(function InlineLink(linkProps, ref) {
@@ -15,12 +17,12 @@ export default function NoResultFound(props) {
}),
[to],
);
return (
<Card elevation={0}>
<CardActionArea component={ActionAreaLink} {...props}>
<CardContent>
{!!to && <AddCircleIcon color="primary" />}
<Typography variant="body1">{text}</Typography>
</CardContent>
</CardActionArea>

View File

@@ -7,6 +7,7 @@ import { useQuery } from '@tanstack/react-query';
import api from 'helpers/api';
const variableRegExp = /({.*?})/;
// TODO: extract this function to a separate file
function computeArguments(args, getValues) {
const initialValue = {};

View File

@@ -1,4 +1,6 @@
import { createRoot } from 'react-dom/client';
import { Settings } from 'luxon';
import ThemeProvider from 'components/ThemeProvider';
import IntlProvider from 'components/IntlProvider';
import ApolloProvider from 'components/ApolloProvider';
@@ -10,6 +12,9 @@ import Router from 'components/Router';
import routes from 'routes';
import reportWebVitals from './reportWebVitals';
// Sets the default locale to English for all luxon DateTime instances created afterwards.
Settings.defaultLocale = 'en';
const container = document.getElementById('root');
const root = createRoot(container);

View File

@@ -30,6 +30,7 @@ import AppIcon from 'components/AppIcon';
import Container from 'components/Container';
import PageTitle from 'components/PageTitle';
import useApp from 'hooks/useApp';
import Can from 'components/Can';
const ReconnectConnection = (props) => {
const { application, onClose } = props;
@@ -92,7 +93,7 @@ export default function Application() {
}
return options;
}, [appKey, appConfig?.data, currentUserAbility]);
}, [appKey, appConfig?.data, currentUserAbility, formatMessage]);
if (loading) return null;
@@ -118,37 +119,46 @@ export default function Application() {
<Route
path={`${URLS.FLOWS}/*`}
element={
<ConditionalIconButton
type="submit"
variant="contained"
color="primary"
size="large"
component={Link}
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
appKey,
connectionId,
<Can I="create" a="Flow" passThrough>
{(allowed) => (
<ConditionalIconButton
type="submit"
variant="contained"
color="primary"
size="large"
component={Link}
to={URLS.CREATE_FLOW_WITH_APP_AND_CONNECTION(
appKey,
connectionId,
)}
fullWidth
icon={<AddIcon />}
disabled={!allowed}
>
{formatMessage('app.createFlow')}
</ConditionalIconButton>
)}
fullWidth
icon={<AddIcon />}
disabled={!currentUserAbility.can('create', 'Flow')}
>
{formatMessage('app.createFlow')}
</ConditionalIconButton>
</Can>
}
/>
<Route
path={`${URLS.CONNECTIONS}/*`}
element={
<SplitButton
disabled={
(appConfig?.data &&
!appConfig?.data?.canConnect &&
!appConfig?.data?.canCustomConnect) ||
connectionOptions.every(({ disabled }) => disabled)
}
options={connectionOptions}
/>
<Can I="create" a="Connection" passThrough>
{(allowed) => (
<SplitButton
disabled={
!allowed ||
(appConfig?.data &&
!appConfig?.data?.canConnect &&
!appConfig?.data?.canCustomConnect) ||
connectionOptions.every(({ disabled }) => disabled)
}
options={connectionOptions}
/>
)}
</Can>
}
/>
</Routes>
@@ -169,17 +179,20 @@ export default function Application() {
label={formatMessage('app.connections')}
to={URLS.APP_CONNECTIONS(appKey)}
value={URLS.APP_CONNECTIONS_PATTERN}
disabled={!app.supportsConnections}
disabled={
!currentUserAbility.can('read', 'Connection') ||
!app.supportsConnections
}
component={Link}
data-test="connections-tab"
/>
<Tab
label={formatMessage('app.flows')}
to={URLS.APP_FLOWS(appKey)}
value={URLS.APP_FLOWS_PATTERN}
component={Link}
data-test="flows-tab"
disabled={!currentUserAbility.can('read', 'Flow')}
/>
</Tabs>
</Box>
@@ -187,14 +200,20 @@ export default function Application() {
<Routes>
<Route
path={`${URLS.FLOWS}/*`}
element={<AppFlows appKey={appKey} />}
element={
<Can I="read" a="Flow">
<AppFlows appKey={appKey} />
</Can>
}
/>
<Route
path={`${URLS.CONNECTIONS}/*`}
element={<AppConnections appKey={appKey} />}
element={
<Can I="read" a="Connection">
<AppConnections appKey={appKey} />
</Can>
}
/>
<Route
path="/"
element={
@@ -218,17 +237,24 @@ export default function Application() {
<Route
path="/connections/add"
element={
<AddAppConnection onClose={goToApplicationPage} application={app} />
<Can I="create" a="Connection">
<AddAppConnection
onClose={goToApplicationPage}
application={app}
/>
</Can>
}
/>
<Route
path="/connections/:connectionId/reconnect"
element={
<ReconnectConnection
application={app}
onClose={goToApplicationPage}
/>
<Can I="create" a="Connection">
<ReconnectConnection
application={app}
onClose={goToApplicationPage}
/>
</Can>
}
/>
</Routes>

View File

@@ -84,10 +84,14 @@ export default function Applications() {
)}
{!isLoading && !hasApps && (
<NoResultFound
text={formatMessage('apps.noConnections')}
to={URLS.NEW_APP_CONNECTION}
/>
<Can I="create" a="Connection" passThrough>
{(allowed) => (
<NoResultFound
text={formatMessage('apps.noConnections')}
{...(allowed && { to: URLS.NEW_APP_CONNECTION })}
/>
)}
</Can>
)}
{!isLoading &&

View File

@@ -7,13 +7,15 @@ import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import { CREATE_FLOW } from 'graphql/mutations/create-flow';
import Box from '@mui/material/Box';
export default function CreateFlow() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const formatMessage = useFormatMessage();
const [createFlow] = useMutation(CREATE_FLOW);
const [createFlow, { error }] = useMutation(CREATE_FLOW);
const appKey = searchParams.get('appKey');
const connectionId = searchParams.get('connectionId');
React.useEffect(() => {
async function initiate() {
const variables = {};
@@ -33,6 +35,11 @@ export default function CreateFlow() {
}
initiate();
}, [createFlow, navigate, appKey, connectionId]);
if (error) {
return null;
}
return (
<Box
sx={{
@@ -45,7 +52,6 @@ export default function CreateFlow() {
}}
>
<CircularProgress size={16} thickness={7.5} />
<Typography variant="body2">
{formatMessage('createFlow.creating')}
</Typography>

View File

@@ -17,6 +17,7 @@ import Container from 'components/Container';
import PageTitle from 'components/PageTitle';
import SearchInput from 'components/SearchInput';
import useFormatMessage from 'hooks/useFormatMessage';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
import * as URLS from 'config/urls';
import useLazyFlows from 'hooks/useLazyFlows';
@@ -26,6 +27,7 @@ export default function Flows() {
const page = parseInt(searchParams.get('page') || '', 10) || 1;
const [flowName, setFlowName] = React.useState('');
const [isLoading, setIsLoading] = React.useState(false);
const currentUserAbility = useCurrentUserAbility();
const { data, mutate: fetchFlows } = useLazyFlows(
{ flowName, page },
@@ -124,7 +126,9 @@ export default function Flows() {
{!isLoading && !hasFlows && (
<NoResultFound
text={formatMessage('flows.noFlows')}
to={URLS.CREATE_FLOW}
{...(currentUserAbility.can('create', 'Flow') && {
to: URLS.CREATE_FLOW,
})}
/>
)}
{!isLoading && pageInfo && pageInfo.totalPages > 1 && (