Merge branch 'main' into AUT-1380

This commit is contained in:
Jakub P.
2025-01-14 17:03:39 +01:00
34 changed files with 994 additions and 570 deletions

View File

@@ -35,9 +35,6 @@ export default defineTrigger({
}, },
], ],
useSingletonWebhook: true,
singletonWebhookRefValueParameter: 'phoneNumberSid',
async run($) { async run($) {
const dataItem = { const dataItem = {
raw: $.request.body, raw: $.request.body,

View File

@@ -0,0 +1,11 @@
import { renderObject } from '../../../../helpers/renderer.js';
export default async (request, response) => {
const flow = await request.currentUser.authorizedFlows
.findById(request.params.flowId)
.throwIfNotFound();
const exportedFlow = await flow.export();
return renderObject(response, exportedFlow, { status: 201 });
};

View File

@@ -0,0 +1,202 @@
import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import Crypto from 'crypto';
import app from '../../../../app.js';
import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js';
import { createUser } from '../../../../../test/factories/user.js';
import { createFlow } from '../../../../../test/factories/flow.js';
import { createStep } from '../../../../../test/factories/step.js';
import { createPermission } from '../../../../../test/factories/permission.js';
import exportFlowMock from '../../../../../test/mocks/rest/api/v1/flows/export-flow.js';
describe('POST /api/v1/flows/:flowId/export', () => {
let currentUser, currentUserRole, token;
beforeEach(async () => {
currentUser = await createUser();
currentUserRole = await currentUser.$relatedQuery('role');
token = await createAuthTokenByUserId(currentUser.id);
});
it('should export the flow data of the current user', async () => {
const currentUserFlow = await createFlow({ userId: currentUser.id });
const triggerStep = await createStep({
flowId: currentUserFlow.id,
type: 'trigger',
appKey: 'webhook',
key: 'catchRawWebhook',
name: 'Catch raw webhook',
parameters: {
workSynchronously: true,
},
position: 1,
webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`,
});
const actionStep = await createStep({
flowId: currentUserFlow.id,
type: 'action',
appKey: 'formatter',
key: 'text',
name: 'Text',
parameters: {
input: `hello {{step.${triggerStep.id}.query.sample}} deneme`,
transform: 'capitalize',
},
position: 2,
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const response = await request(app)
.post(`/api/v1/flows/${currentUserFlow.id}/export`)
.set('Authorization', token)
.expect(201);
const expectedPayload = await exportFlowMock(currentUserFlow, [
triggerStep,
actionStep,
]);
expect(response.body).toStrictEqual(expectedPayload);
});
it('should export the flow data of another user', async () => {
const anotherUser = await createUser();
const anotherUserFlow = await createFlow({ userId: anotherUser.id });
const triggerStep = await createStep({
flowId: anotherUserFlow.id,
type: 'trigger',
appKey: 'webhook',
key: 'catchRawWebhook',
name: 'Catch raw webhook',
parameters: {
workSynchronously: true,
},
position: 1,
webhookPath: `/webhooks/flows/${anotherUserFlow.id}/sync`,
});
const actionStep = await createStep({
flowId: anotherUserFlow.id,
type: 'action',
appKey: 'formatter',
key: 'text',
name: 'Text',
parameters: {
input: `hello {{step.${triggerStep.id}.query.sample}} deneme`,
transform: 'capitalize',
},
position: 2,
});
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: [],
});
const response = await request(app)
.post(`/api/v1/flows/${anotherUserFlow.id}/export`)
.set('Authorization', token)
.expect(201);
const expectedPayload = await exportFlowMock(anotherUserFlow, [
triggerStep,
actionStep,
]);
expect(response.body).toStrictEqual(expectedPayload);
});
it('should return not found response for not existing flow UUID', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
const notExistingFlowUUID = Crypto.randomUUID();
await request(app)
.post(`/api/v1/flows/${notExistingFlowUUID}/export`)
.set('Authorization', token)
.expect(404);
});
it('should return not found response for unauthorized flow', async () => {
const anotherUser = await createUser();
const anotherUserFlow = await createFlow({ userId: anotherUser.id });
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await request(app)
.post(`/api/v1/flows/${anotherUserFlow.id}/export`)
.set('Authorization', token)
.expect(404);
});
it('should return bad request response for invalid UUID', async () => {
await createPermission({
action: 'read',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await createPermission({
action: 'update',
subject: 'Flow',
roleId: currentUserRole.id,
conditions: ['isCreator'],
});
await request(app)
.post('/api/v1/flows/invalidFlowUUID/export')
.set('Authorization', token)
.expect(400);
});
});

View File

@@ -1,38 +0,0 @@
import path from 'node:path';
import Connection from '../../models/connection.js';
import logger from '../../helpers/logger.js';
import handler from '../../helpers/webhook-handler.js';
export default async (request, response) => {
const computedRequestPayload = {
headers: request.headers,
body: request.body,
query: request.query,
params: request.params,
};
logger.debug(`Handling incoming webhook request at ${request.originalUrl}.`);
logger.debug(JSON.stringify(computedRequestPayload, null, 2));
const { connectionId } = request.params;
const connection = await Connection.query()
.findById(connectionId)
.throwIfNotFound();
if (!(await connection.verifyWebhook(request))) {
return response.sendStatus(401);
}
const triggerSteps = await connection
.$relatedQuery('triggerSteps')
.where('webhook_path', path.join(request.baseUrl, request.path));
if (triggerSteps.length === 0) return response.sendStatus(404);
for (const triggerStep of triggerSteps) {
await handler(triggerStep.flowId, request, response);
}
response.sendStatus(204);
};

View File

@@ -113,6 +113,10 @@ const authorizationList = {
action: 'create', action: 'create',
subject: 'Flow', subject: 'Flow',
}, },
'POST /api/v1/flows/:flowId/export': {
action: 'update',
subject: 'Flow',
},
'POST /api/v1/flows/:flowId/steps': { 'POST /api/v1/flows/:flowId/steps': {
action: 'update', action: 'update',
subject: 'Flow', subject: 'Flow',

View File

@@ -0,0 +1,45 @@
import Crypto from 'crypto';
const exportFlow = async (flow) => {
const steps = await flow.$relatedQuery('steps');
const newFlowId = Crypto.randomUUID();
const stepIdMap = Object.fromEntries(
steps.map((step) => [step.id, Crypto.randomUUID()])
);
const exportedFlow = {
id: newFlowId,
name: flow.name,
steps: steps.map((step) => ({
id: stepIdMap[step.id],
key: step.key,
name: step.name,
appKey: step.appKey,
type: step.type,
parameters: updateParameters(step.parameters, stepIdMap),
position: step.position,
webhookPath: step.webhookPath?.replace(flow.id, newFlowId),
})),
};
return exportedFlow;
};
const updateParameters = (parameters, stepIdMap) => {
if (!parameters) return parameters;
const stringifiedParameters = JSON.stringify(parameters);
let updatedParameters = stringifiedParameters;
Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => {
updatedParameters = updatedParameters.replace(
`{{step.${oldStepId}.`,
`{{step.${newStepId}.`
);
});
return JSON.parse(updatedParameters);
};
export default exportFlow;

View File

@@ -7,6 +7,7 @@ import ExecutionStep from './execution-step.js';
import globalVariable from '../helpers/global-variable.js'; import globalVariable from '../helpers/global-variable.js';
import logger from '../helpers/logger.js'; import logger from '../helpers/logger.js';
import Telemetry from '../helpers/telemetry/index.js'; import Telemetry from '../helpers/telemetry/index.js';
import exportFlow from '../helpers/export-flow.js';
import flowQueue from '../queues/flow.js'; import flowQueue from '../queues/flow.js';
import { import {
REMOVE_AFTER_30_DAYS_OR_150_JOBS, REMOVE_AFTER_30_DAYS_OR_150_JOBS,
@@ -426,6 +427,10 @@ class Flow extends Base {
} }
} }
async export() {
return await exportFlow(this);
}
async $beforeUpdate(opt, queryContext) { async $beforeUpdate(opt, queryContext) {
await super.$beforeUpdate(opt, queryContext); await super.$beforeUpdate(opt, queryContext);

View File

@@ -10,6 +10,7 @@ import { createFlow } from '../../test/factories/flow.js';
import { createStep } from '../../test/factories/step.js'; import { createStep } from '../../test/factories/step.js';
import { createExecution } from '../../test/factories/execution.js'; import { createExecution } from '../../test/factories/execution.js';
import { createExecutionStep } from '../../test/factories/execution-step.js'; import { createExecutionStep } from '../../test/factories/execution-step.js';
import * as exportFlow from '../helpers/export-flow.js';
describe('Flow model', () => { describe('Flow model', () => {
it('tableName should return correct name', () => { it('tableName should return correct name', () => {
@@ -506,6 +507,22 @@ describe('Flow model', () => {
}); });
}); });
describe('export', () => {
it('should return exportedFlow', async () => {
const flow = await createFlow();
const exportedFlowAsString = {
name: 'My Flow Name',
};
vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString);
expect(await flow.export()).toStrictEqual({
name: 'My Flow Name',
});
});
});
describe('throwIfHavingLessThanTwoSteps', () => { describe('throwIfHavingLessThanTwoSteps', () => {
it('should throw validation error with less than two steps', async () => { it('should throw validation error with less than two steps', async () => {
const flow = await createFlow(); const flow = await createFlow();

View File

@@ -1,5 +1,4 @@
import { URL } from 'node:url'; import { URL } from 'node:url';
import get from 'lodash.get';
import Base from './base.js'; import Base from './base.js';
import App from './app.js'; import App from './app.js';
import Flow from './flow.js'; import Flow from './flow.js';
@@ -109,25 +108,10 @@ class Step extends Base {
if (!triggerCommand) return null; if (!triggerCommand) return null;
const { useSingletonWebhook, singletonWebhookRefValueParameter, type } = const isWebhook = triggerCommand.type === 'webhook';
triggerCommand;
const isWebhook = type === 'webhook';
if (!isWebhook) return null; if (!isWebhook) return null;
if (singletonWebhookRefValueParameter) {
const parameterValue = get(
this.parameters,
singletonWebhookRefValueParameter
);
return `/webhooks/connections/${this.connectionId}/${parameterValue}`;
}
if (useSingletonWebhook) {
return `/webhooks/connections/${this.connectionId}`;
}
if (this.parameters.workSynchronously) { if (this.parameters.workSynchronously) {
return `/webhooks/flows/${this.flowId}/sync`; return `/webhooks/flows/${this.flowId}/sync`;
} }

View File

@@ -9,6 +9,7 @@ import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js';
import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; import createStepAction from '../../../controllers/api/v1/flows/create-step.js';
import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js';
import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js';
import exportFlowAction from '../../../controllers/api/v1/flows/export-flow.js';
const router = Router(); const router = Router();
@@ -17,6 +18,13 @@ router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction);
router.post('/', authenticateUser, authorizeUser, createFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction);
router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction);
router.post(
'/:flowId/export',
authenticateUser,
authorizeUser,
exportFlowAction
);
router.patch( router.patch(
'/:flowId/status', '/:flowId/status',
authenticateUser, authenticateUser,

View File

@@ -4,7 +4,6 @@ import multer from 'multer';
import appConfig from '../config/app.js'; import appConfig from '../config/app.js';
import webhookHandlerByFlowId from '../controllers/webhooks/handler-by-flow-id.js'; import webhookHandlerByFlowId from '../controllers/webhooks/handler-by-flow-id.js';
import webhookHandlerSyncByFlowId from '../controllers/webhooks/handler-sync-by-flow-id.js'; import webhookHandlerSyncByFlowId from '../controllers/webhooks/handler-sync-by-flow-id.js';
import webhookHandlerByConnectionIdAndRefValue from '../controllers/webhooks/handler-by-connection-id-and-ref-value.js';
const router = Router(); const router = Router();
const upload = multer(); const upload = multer();
@@ -39,14 +38,6 @@ function createRouteHandler(path, handler) {
.post(wrappedHandler); .post(wrappedHandler);
} }
createRouteHandler(
'/connections/:connectionId/:refValue',
webhookHandlerByConnectionIdAndRefValue
);
createRouteHandler(
'/connections/:connectionId',
webhookHandlerByConnectionIdAndRefValue
);
createRouteHandler('/flows/:flowId/sync', webhookHandlerSyncByFlowId); createRouteHandler('/flows/:flowId/sync', webhookHandlerSyncByFlowId);
createRouteHandler('/flows/:flowId', webhookHandlerByFlowId); createRouteHandler('/flows/:flowId', webhookHandlerByFlowId);
createRouteHandler('/:flowId', webhookHandlerByFlowId); createRouteHandler('/:flowId', webhookHandlerByFlowId);

View File

@@ -0,0 +1,41 @@
import { expect } from 'vitest';
const exportFlowMock = async (flow, steps = []) => {
const data = {
id: expect.any(String),
name: flow.name,
};
if (steps.length) {
data.steps = steps.map((step) => {
const computedStep = {
id: expect.any(String),
key: step.key,
name: step.name,
appKey: step.appKey,
type: step.type,
parameters: expect.any(Object),
position: step.position,
};
if (step.type === 'trigger') {
computedStep.webhookPath = expect.stringContaining('/webhooks/flows/');
}
return computedStep;
});
}
return {
data: data,
meta: {
count: 1,
currentPage: null,
isArray: false,
totalPages: null,
type: 'Object',
},
};
};
export default exportFlowMock;

View File

@@ -13,17 +13,25 @@ export default defineConfig({
reportsDirectory: './coverage', reportsDirectory: './coverage',
reporter: ['text', 'lcov'], reporter: ['text', 'lcov'],
all: true, all: true,
include: ['**/src/models/**', '**/src/controllers/**'], include: [
'**/src/controllers/**',
'**/src/helpers/authentication.test.js',
'**/src/helpers/axios-with-proxy.test.js',
'**/src/helpers/compute-parameters.test.js',
'**/src/helpers/user-ability.test.js',
'**/src/models/**',
'**/src/serializers/**',
],
exclude: [ exclude: [
'**/src/controllers/webhooks/**', '**/src/controllers/webhooks/**',
'**/src/controllers/paddle/**', '**/src/controllers/paddle/**',
], ],
thresholds: { thresholds: {
autoUpdate: true, autoUpdate: true,
statements: 99.44, statements: 99.4,
branches: 97.78, branches: 97.77,
functions: 99.1, functions: 99.16,
lines: 99.44, lines: 99.4,
}, },
}, },
}, },

View File

@@ -4261,16 +4261,7 @@ streamsearch@^1.1.0:
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
"string-width-cjs@npm:string-width@^4.2.0": "string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -4302,14 +4293,7 @@ string_decoder@~1.1.1:
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1": "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==

View File

@@ -35,6 +35,7 @@ Please be careful with the `ENCRYPTION_KEY` and `WEBHOOK_SECRET_KEY` environment
| `APP_SECRET_KEY` | string | | Secret Key to authenticate the user | | `APP_SECRET_KEY` | string | | Secret Key to authenticate the user |
| `REDIS_HOST` | string | `redis` | Redis Host | | `REDIS_HOST` | string | `redis` | Redis Host |
| `REDIS_PORT` | number | `6379` | Redis Port | | `REDIS_PORT` | number | `6379` | Redis Port |
| `REDIS_DB` | number | | Redis Database |
| `REDIS_USERNAME` | string | | Redis Username | | `REDIS_USERNAME` | string | | Redis Username |
| `REDIS_PASSWORD` | string | | Redis Password | | `REDIS_PASSWORD` | string | | Redis Password |
| `REDIS_TLS` | boolean | `false` | Redis TLS | | `REDIS_TLS` | boolean | `false` | Redis TLS |

View File

@@ -1,3 +1,5 @@
const { expect } = require('@playwright/test');
const { faker } = require('@faker-js/faker'); const { faker } = require('@faker-js/faker');
const { AuthenticatedPage } = require('../authenticated-page'); const { AuthenticatedPage } = require('../authenticated-page');
@@ -11,7 +13,7 @@ export class AdminCreateUserPage extends AuthenticatedPage {
super(page); super(page);
this.fullNameInput = page.getByTestId('full-name-input'); this.fullNameInput = page.getByTestId('full-name-input');
this.emailInput = page.getByTestId('email-input'); this.emailInput = page.getByTestId('email-input');
this.roleInput = page.getByTestId('role.id-autocomplete'); this.roleInput = page.getByTestId('roleId-autocomplete');
this.createButton = page.getByTestId('create-button'); this.createButton = page.getByTestId('create-button');
this.pageTitle = page.getByTestId('create-user-title'); this.pageTitle = page.getByTestId('create-user-title');
this.invitationEmailInfoAlert = page.getByTestId( this.invitationEmailInfoAlert = page.getByTestId(
@@ -20,6 +22,8 @@ export class AdminCreateUserPage extends AuthenticatedPage {
this.acceptInvitationLink = page this.acceptInvitationLink = page
.getByTestId('invitation-email-info-alert') .getByTestId('invitation-email-info-alert')
.getByRole('link'); .getByRole('link');
this.createUserSuccessAlert = page.getByTestId('create-user-success-alert');
this.fieldError = page.locator('p[id$="-helper-text"]');
} }
seed(seed) { seed(seed) {
@@ -32,4 +36,8 @@ export class AdminCreateUserPage extends AuthenticatedPage {
email: faker.internet.email().toLowerCase(), email: faker.internet.email().toLowerCase(),
}; };
} }
async expectCreateUserSuccessAlertToBeVisible() {
await expect(this.createUserSuccessAlert).toBeVisible();
}
} }

View File

@@ -35,9 +35,8 @@ test.describe('Role management page', () => {
await adminCreateRolePage.closeSnackbar(); await adminCreateRolePage.closeSnackbar();
}); });
let roleRow = await test.step( let roleRow =
'Make sure role data is correct', await test.step('Make sure role data is correct', async () => {
async () => {
const roleRow = await adminRolesPage.getRoleRowByName( const roleRow = await adminRolesPage.getRoleRowByName(
'Create Edit Test' 'Create Edit Test'
); );
@@ -48,8 +47,7 @@ test.describe('Role management page', () => {
await expect(roleData.canEdit).toBe(true); await expect(roleData.canEdit).toBe(true);
await expect(roleData.canDelete).toBe(true); await expect(roleData.canDelete).toBe(true);
return roleRow; return roleRow;
} });
);
await test.step('Edit the role', async () => { await test.step('Edit the role', async () => {
await adminRolesPage.clickEditRole(roleRow); await adminRolesPage.clickEditRole(roleRow);
@@ -67,9 +65,8 @@ test.describe('Role management page', () => {
await adminEditRolePage.closeSnackbar(); await adminEditRolePage.closeSnackbar();
}); });
roleRow = await test.step( roleRow =
'Make sure changes reflected on roles page', await test.step('Make sure changes reflected on roles page', async () => {
async () => {
await adminRolesPage.isMounted(); await adminRolesPage.isMounted();
const roleRow = await adminRolesPage.getRoleRowByName( const roleRow = await adminRolesPage.getRoleRowByName(
'Create Update Test' 'Create Update Test'
@@ -81,8 +78,7 @@ test.describe('Role management page', () => {
await expect(roleData.canEdit).toBe(true); await expect(roleData.canEdit).toBe(true);
await expect(roleData.canDelete).toBe(true); await expect(roleData.canDelete).toBe(true);
return roleRow; return roleRow;
} });
);
await test.step('Delete the role', async () => { await test.step('Delete the role', async () => {
await adminRolesPage.clickDeleteRole(roleRow); await adminRolesPage.clickDeleteRole(roleRow);
@@ -184,49 +180,39 @@ test.describe('Role management page', () => {
await expect(snackbar.variant).toBe('success'); await expect(snackbar.variant).toBe('success');
await adminCreateRolePage.closeSnackbar(); await adminCreateRolePage.closeSnackbar();
}); });
await test.step( await test.step('Create a new user with the "Delete Role" role', async () => {
'Create a new user with the "Delete Role" role', await adminUsersPage.navigateTo();
async () => { await adminUsersPage.createUserButton.click();
await adminUsersPage.navigateTo(); await adminCreateUserPage.fullNameInput.fill('User Role Test');
await adminUsersPage.createUserButton.click(); await adminCreateUserPage.emailInput.fill(
await adminCreateUserPage.fullNameInput.fill('User Role Test'); 'user-role-test@automatisch.io'
await adminCreateUserPage.emailInput.fill( );
'user-role-test@automatisch.io' await adminCreateUserPage.roleInput.click();
); await adminCreateUserPage.page
await adminCreateUserPage.roleInput.click(); .getByRole('option', { name: 'Delete Role', exact: true })
await adminCreateUserPage.page .click();
.getByRole('option', { name: 'Delete Role', exact: true }) await adminCreateUserPage.createButton.click();
.click(); await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
await adminCreateUserPage.createButton.click(); state: 'attached',
await adminCreateUserPage.snackbar.waitFor({ });
state: 'attached', await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
}); });
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
state: 'attached', await test.step('Try to delete "Delete Role" role when new user has it', async () => {
}); await adminRolesPage.navigateTo();
const snackbar = await adminUsersPage.getSnackbarData( const row = await adminRolesPage.getRoleRowByName('Delete Role');
'snackbar-create-user-success' const modal = await adminRolesPage.clickDeleteRole(row);
); await modal.deleteButton.click();
await expect(snackbar.variant).toBe('success'); await adminRolesPage.snackbar.waitFor({
await adminUsersPage.closeSnackbar(); state: 'attached',
} });
); const snackbar = await adminRolesPage.getSnackbarData(
await test.step( 'snackbar-delete-role-error'
'Try to delete "Delete Role" role when new user has it', );
async () => { await expect(snackbar.variant).toBe('error');
await adminRolesPage.navigateTo(); await adminRolesPage.closeSnackbar();
const row = await adminRolesPage.getRoleRowByName('Delete Role'); await modal.close();
const modal = await adminRolesPage.clickDeleteRole(row); });
await modal.deleteButton.click();
await adminRolesPage.snackbar.waitFor({
state: 'attached',
});
const snackbar = await adminRolesPage.getSnackbarData('snackbar-delete-role-error');
await expect(snackbar.variant).toBe('error');
await adminRolesPage.closeSnackbar();
await modal.close();
}
);
await test.step('Change the role the user has', async () => { await test.step('Change the role the user has', async () => {
await adminUsersPage.navigateTo(); await adminUsersPage.navigateTo();
await adminUsersPage.usersLoader.waitFor({ await adminUsersPage.usersLoader.waitFor({
@@ -301,24 +287,16 @@ test.describe('Role management page', () => {
.getByRole('option', { name: 'Cannot Delete Role' }) .getByRole('option', { name: 'Cannot Delete Role' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
await adminCreateUserPage.snackbar.waitFor({
state: 'attached',
});
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
state: 'attached', state: 'attached',
}); });
const snackbar = await adminCreateUserPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateUserPage.closeSnackbar();
}); });
await test.step('Delete this user', async () => { await test.step('Delete this user', async () => {
await adminUsersPage.navigateTo(); await adminUsersPage.navigateTo();
const row = await adminUsersPage.findUserPageWithEmail( const row = await adminUsersPage.findUserPageWithEmail(
'user-delete-role-test@automatisch.io' 'user-delete-role-test@automatisch.io'
); );
// await test.waitForTimeout(10000);
const modal = await adminUsersPage.clickDeleteUser(row); const modal = await adminUsersPage.clickDeleteUser(row);
await modal.deleteButton.click(); await modal.deleteButton.click();
await adminUsersPage.snackbar.waitFor({ await adminUsersPage.snackbar.waitFor({
@@ -385,17 +363,10 @@ test('Accessibility of role management page', async ({
.getByRole('option', { name: 'Basic Test' }) .getByRole('option', { name: 'Basic Test' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
await adminCreateUserPage.snackbar.waitFor({
state: 'attached',
});
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({ await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
state: 'attached', state: 'attached',
}); });
const snackbar = await adminCreateUserPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminCreateUserPage.closeSnackbar();
}); });
await test.step('Logout and login to the basic role user', async () => { await test.step('Logout and login to the basic role user', async () => {
@@ -409,42 +380,35 @@ test('Accessibility of role management page', async ({
await page.getByTestId('logout-item').click(); await page.getByTestId('logout-item').click();
const acceptInvitationPage = new AcceptInvitation(page); const acceptInvitationPage = new AcceptInvitation(page);
await acceptInvitationPage.open(acceptInvitatonToken); await acceptInvitationPage.open(acceptInvitatonToken);
await acceptInvitationPage.acceptInvitation('sample'); await acceptInvitationPage.acceptInvitation('sample');
const loginPage = new LoginPage(page); const loginPage = new LoginPage(page);
// await loginPage.isMounted();
await loginPage.login('basic-role-test@automatisch.io', 'sample'); await loginPage.login('basic-role-test@automatisch.io', 'sample');
await expect(loginPage.loginButton).not.toBeVisible(); await expect(loginPage.loginButton).not.toBeVisible();
await expect(page).toHaveURL('/flows'); await expect(page).toHaveURL('/flows');
}); });
await test.step( await test.step('Navigate to the admin settings page and make sure it is blank', async () => {
'Navigate to the admin settings page and make sure it is blank', const pageUrl = new URL(page.url());
async () => { const url = `${pageUrl.origin}/admin-settings/users`;
const pageUrl = new URL(page.url()); await page.goto(url);
const url = `${pageUrl.origin}/admin-settings/users`; await page.waitForTimeout(750);
await page.goto(url); const isUnmounted = await page.evaluate(() => {
await page.waitForTimeout(750); // eslint-disable-next-line no-undef
const isUnmounted = await page.evaluate(() => { const root = document.querySelector('#root');
// eslint-disable-next-line no-undef
const root = document.querySelector('#root');
if (root) { if (root) {
// We have react query devtools only in dev env. // We have react query devtools only in dev env.
// In production, there is nothing in root. // In production, there is nothing in root.
// That's why `<= 1`. // That's why `<= 1`.
return root.children.length <= 1; return root.children.length <= 1;
} }
return false; return false;
}); });
await expect(isUnmounted).toBe(true); await expect(isUnmounted).toBe(true);
} });
);
await test.step('Log back into the admin account', async () => { await test.step('Log back into the admin account', async () => {
await page.goto('/'); await page.goto('/');

View File

@@ -37,12 +37,8 @@ test.describe('User management page', () => {
state: 'attached', state: 'attached',
}); });
const snackbar = await adminUsersPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.navigateTo(); await adminUsersPage.navigateTo();
await adminUsersPage.closeSnackbar();
}); });
await test.step('Check the user exists with the expected properties', async () => { await test.step('Check the user exists with the expected properties', async () => {
await adminUsersPage.findUserPageWithEmail(user.email); await adminUsersPage.findUserPageWithEmail(user.email);
@@ -106,11 +102,7 @@ test.describe('User management page', () => {
.getByRole('option', { name: 'Admin' }) .getByRole('option', { name: 'Admin' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}); });
await test.step('Delete the created user', async () => { await test.step('Delete the created user', async () => {
@@ -138,9 +130,7 @@ test.describe('User management page', () => {
.getByRole('option', { name: 'Admin' }) .getByRole('option', { name: 'Admin' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); await expect(adminCreateUserPage.fieldError).toHaveCount(1);
await expect(snackbar.variant).toBe('error');
await adminUsersPage.closeSnackbar();
}); });
}); });
@@ -161,11 +151,7 @@ test.describe('User management page', () => {
.getByRole('option', { name: 'Admin' }) .getByRole('option', { name: 'Admin' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}); });
await test.step('Create the user again', async () => { await test.step('Create the user again', async () => {
@@ -181,9 +167,7 @@ test.describe('User management page', () => {
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
await expect(page.url()).toBe(createUserPageUrl); await expect(page.url()).toBe(createUserPageUrl);
const snackbar = await adminUsersPage.getSnackbarData('snackbar-error'); await expect(adminCreateUserPage.fieldError).toHaveCount(1);
await expect(snackbar.variant).toBe('error');
await adminUsersPage.closeSnackbar();
}); });
}); });
@@ -206,11 +190,7 @@ test.describe('User management page', () => {
.getByRole('option', { name: 'Admin' }) .getByRole('option', { name: 'Admin' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}); });
await test.step('Create the second user', async () => { await test.step('Create the second user', async () => {
@@ -223,11 +203,7 @@ test.describe('User management page', () => {
.getByRole('option', { name: 'Admin' }) .getByRole('option', { name: 'Admin' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
await adminUsersPage.closeSnackbar();
}); });
await test.step('Try editing the second user to have the email of the first user', async () => { await test.step('Try editing the second user to have the email of the first user', async () => {

View File

@@ -7,198 +7,191 @@ test('Ensure creating a new flow works', async ({ page }) => {
); );
}); });
test( test('Create a new flow with a Scheduler step then an Ntfy step', async ({
'Create a new flow with a Scheduler step then an Ntfy step', flowEditorPage,
async ({ flowEditorPage, page }) => { page,
await test.step('create flow', async () => { }) => {
await test.step('navigate to new flow page', async () => { await test.step('create flow', async () => {
await page.getByTestId('create-flow-button').click(); await test.step('navigate to new flow page', async () => {
await page.waitForURL( await page.getByTestId('create-flow-button').click();
/\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/ await page.waitForURL(
); /\/editor\/[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/
);
});
await test.step('has two steps by default', async () => {
await expect(page.getByTestId('flow-step')).toHaveCount(2);
});
});
await test.step('setup Scheduler trigger', async () => {
await test.step('choose app and event substep', async () => {
await test.step('choose application', async () => {
await flowEditorPage.appAutocomplete.click();
await page.getByRole('option', { name: 'Scheduler' }).click();
}); });
await test.step('has two steps by default', async () => { await test.step('choose and event', async () => {
await expect(page.getByTestId('flow-step')).toHaveCount(2); await expect(flowEditorPage.eventAutocomplete).toBeVisible();
await flowEditorPage.eventAutocomplete.click();
await page.getByRole('option', { name: 'Every hour' }).click();
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
}); });
}); });
await test.step('setup Scheduler trigger', async () => { await test.step('set up a trigger', async () => {
await test.step('choose app and event substep', async () => { await test.step('choose "yes" in "trigger on weekends?"', async () => {
await test.step('choose application', async () => { await expect(flowEditorPage.trigger).toBeVisible();
await flowEditorPage.appAutocomplete.click(); await flowEditorPage.trigger.click();
await page await page.getByRole('option', { name: 'Yes' }).click();
.getByRole('option', { name: 'Scheduler' })
.click();
});
await test.step('choose and event', async () => {
await expect(flowEditorPage.eventAutocomplete).toBeVisible();
await flowEditorPage.eventAutocomplete.click();
await page
.getByRole('option', { name: 'Every hour' })
.click();
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
});
}); });
await test.step('set up a trigger', async () => { await test.step('continue to next step', async () => {
await test.step('choose "yes" in "trigger on weekends?"', async () => { await flowEditorPage.continueButton.click();
await expect(flowEditorPage.trigger).toBeVisible();
await flowEditorPage.trigger.click();
await page.getByRole('option', { name: 'Yes' }).click();
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.trigger).not.toBeVisible();
});
}); });
await test.step('test trigger', async () => { await test.step('collapses the substep', async () => {
await test.step('show sample output', async () => { await expect(flowEditorPage.trigger).not.toBeVisible();
await expect(flowEditorPage.testOutput).not.toBeVisible();
await flowEditorPage.continueButton.click();
await expect(flowEditorPage.testOutput).toBeVisible();
await flowEditorPage.screenshot({
path: 'Scheduler trigger test output.png',
});
await flowEditorPage.continueButton.click();
});
}); });
}); });
await test.step('arrange Ntfy action', async () => { await test.step('test trigger', async () => {
await test.step('choose app and event substep', async () => { await test.step('show sample output', async () => {
await test.step('choose application', async () => { await expect(flowEditorPage.testOutput).not.toBeVisible();
await flowEditorPage.appAutocomplete.click(); await flowEditorPage.continueButton.click();
await page.getByRole('option', { name: 'Ntfy' }).click(); await expect(flowEditorPage.testOutput).toBeVisible();
});
await test.step('choose an event', async () => {
await expect(flowEditorPage.eventAutocomplete).toBeVisible();
await flowEditorPage.eventAutocomplete.click();
await page
.getByRole('option', { name: 'Send message' })
.click();
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
});
});
await test.step('choose connection substep', async () => {
await test.step('choose connection list item', async () => {
await flowEditorPage.connectionAutocomplete.click();
await page.getByRole('option').first().click();
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
});
});
await test.step('set up action substep', async () => {
await test.step('fill topic and message body', async () => {
await page
.getByTestId('parameters.topic-power-input')
.locator('[contenteditable]')
.fill('Topic');
await page
.getByTestId('parameters.message-power-input')
.locator('[contenteditable]')
.fill('Message body');
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
});
});
await test.step('test trigger substep', async () => {
await test.step('show sample output', async () => {
await expect(flowEditorPage.testOutput).not.toBeVisible();
await page
.getByTestId('flow-substep-continue-button')
.first()
.click();
await expect(flowEditorPage.testOutput).toBeVisible();
await flowEditorPage.screenshot({
path: 'Ntfy action test output.png',
});
await flowEditorPage.continueButton.click();
});
});
});
await test.step('publish and unpublish', async () => {
await test.step('publish flow', async () => {
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
await expect(flowEditorPage.publishFlowButton).toBeVisible();
await flowEditorPage.publishFlowButton.click();
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
});
await test.step('shows read-only sticky snackbar', async () => {
await expect(flowEditorPage.infoSnackbar).toBeVisible();
await flowEditorPage.screenshot({ await flowEditorPage.screenshot({
path: 'Published flow.png', path: 'Scheduler trigger test output.png',
}); });
await flowEditorPage.continueButton.click();
}); });
});
await test.step('unpublish from snackbar', async () => { });
await test.step('arrange Ntfy action', async () => {
await test.step('choose app and event substep', async () => {
await test.step('choose application', async () => {
await flowEditorPage.appAutocomplete.click();
await page.getByRole('option', { name: 'Ntfy' }).click();
});
await test.step('choose an event', async () => {
await expect(flowEditorPage.eventAutocomplete).toBeVisible();
await flowEditorPage.eventAutocomplete.click();
await page.getByRole('option', { name: 'Send message' }).click();
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.appAutocomplete).not.toBeVisible();
await expect(flowEditorPage.eventAutocomplete).not.toBeVisible();
});
});
await test.step('choose connection substep', async () => {
await test.step('choose connection list item', async () => {
await flowEditorPage.connectionAutocomplete.click();
await page await page
.getByTestId('unpublish-flow-from-snackbar') .getByRole('option')
.filter({ hasText: 'Add new connection' })
.click(); .click();
await expect(flowEditorPage.infoSnackbar).not.toBeVisible();
}); });
await test.step('publish once again', async () => { await test.step('continue to next step', async () => {
await expect(flowEditorPage.publishFlowButton).toBeVisible(); await page.getByTestId('create-connection-button').click();
await flowEditorPage.publishFlowButton.click();
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
}); });
await test.step('unpublish from layout top bar', async () => { await test.step('collapses the substep', async () => {
await expect(flowEditorPage.unpublishFlowButton).toBeVisible(); await flowEditorPage.continueButton.click();
await flowEditorPage.unpublishFlowButton.click(); await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible(); });
});
await test.step('set up action substep', async () => {
await test.step('fill topic and message body', async () => {
await page
.getByTestId('parameters.topic-power-input')
.locator('[contenteditable]')
.fill('Topic');
await page
.getByTestId('parameters.message-power-input')
.locator('[contenteditable]')
.fill('Message body');
});
await test.step('continue to next step', async () => {
await flowEditorPage.continueButton.click();
});
await test.step('collapses the substep', async () => {
await expect(flowEditorPage.connectionAutocomplete).not.toBeVisible();
});
});
await test.step('test trigger substep', async () => {
await test.step('show sample output', async () => {
await expect(flowEditorPage.testOutput).not.toBeVisible();
await page.getByTestId('flow-substep-continue-button').first().click();
await expect(flowEditorPage.testOutput).toBeVisible();
await flowEditorPage.screenshot({ await flowEditorPage.screenshot({
path: 'Unpublished flow.png', path: 'Ntfy action test output.png',
}); });
await flowEditorPage.continueButton.click();
}); });
}); });
});
await test.step('in layout', async () => {
await test.step('can go back to flows page', async () => { await test.step('publish and unpublish', async () => {
await page.getByTestId('editor-go-back-button').click(); await test.step('publish flow', async () => {
await expect(page).toHaveURL('/flows'); await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
await expect(flowEditorPage.publishFlowButton).toBeVisible();
await flowEditorPage.publishFlowButton.click();
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
});
await test.step('shows read-only sticky snackbar', async () => {
await expect(flowEditorPage.infoSnackbar).toBeVisible();
await flowEditorPage.screenshot({
path: 'Published flow.png',
}); });
}); });
}
); await test.step('unpublish from snackbar', async () => {
await page.getByTestId('unpublish-flow-from-snackbar').click();
await expect(flowEditorPage.infoSnackbar).not.toBeVisible();
});
await test.step('publish once again', async () => {
await expect(flowEditorPage.publishFlowButton).toBeVisible();
await flowEditorPage.publishFlowButton.click();
await expect(flowEditorPage.publishFlowButton).not.toBeVisible();
});
await test.step('unpublish from layout top bar', async () => {
await expect(flowEditorPage.unpublishFlowButton).toBeVisible();
await flowEditorPage.unpublishFlowButton.click();
await expect(flowEditorPage.unpublishFlowButton).not.toBeVisible();
await flowEditorPage.screenshot({
path: 'Unpublished flow.png',
});
});
});
await test.step('in layout', async () => {
await test.step('can go back to flows page', async () => {
await page.getByTestId('editor-go-back-button').click();
await expect(page).toHaveURL('/flows');
});
});
});

View File

@@ -33,10 +33,7 @@ publicTest.describe('My Profile', () => {
.getByRole('option', { name: 'Admin' }) .getByRole('option', { name: 'Admin' })
.click(); .click();
await adminCreateUserPage.createButton.click(); await adminCreateUserPage.createButton.click();
const snackbar = await adminUsersPage.getSnackbarData( await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
'snackbar-create-user-success'
);
await expect(snackbar.variant).toBe('success');
}); });
await publicTest.step('copy invitation link', async () => { await publicTest.step('copy invitation link', async () => {

View File

@@ -37,6 +37,7 @@
"slate": "^0.94.1", "slate": "^0.94.1",
"slate-history": "^0.93.0", "slate-history": "^0.93.0",
"slate-react": "^0.94.2", "slate-react": "^0.94.2",
"slugify": "^1.6.6",
"uuid": "^9.0.0", "uuid": "^9.0.0",
"web-vitals": "^1.0.1", "web-vitals": "^1.0.1",
"yup": "^0.32.11" "yup": "^0.32.11"
@@ -83,7 +84,6 @@
"access": "public" "access": "public"
}, },
"devDependencies": { "devDependencies": {
"@simbathesailor/use-what-changed": "^2.0.0",
"@tanstack/eslint-plugin-query": "^5.20.1", "@tanstack/eslint-plugin-query": "^5.20.1",
"@tanstack/react-query-devtools": "^5.24.1", "@tanstack/react-query-devtools": "^5.24.1",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",

View File

@@ -1,10 +1,19 @@
import * as React from 'react'; import * as React from 'react';
import PropTypes from 'prop-types';
import MuiContainer from '@mui/material/Container'; import MuiContainer from '@mui/material/Container';
export default function Container(props) { export default function Container({ maxWidth = 'lg', ...props }) {
return <MuiContainer {...props} />; return <MuiContainer maxWidth={maxWidth} {...props} />;
} }
Container.defaultProps = { Container.propTypes = {
maxWidth: 'lg', maxWidth: PropTypes.oneOf([
'xs',
'sm',
'md',
'lg',
'xl',
false,
PropTypes.string,
]),
}; };

View File

@@ -10,9 +10,8 @@ function EditableTypography(props) {
const { const {
children, children,
onConfirm = noop, onConfirm = noop,
iconPosition = 'start',
iconSize = 'large',
sx, sx,
iconColor = 'inherit',
disabled = false, disabled = false,
prefixValue = '', prefixValue = '',
...typographyProps ...typographyProps
@@ -86,14 +85,10 @@ function EditableTypography(props) {
return ( return (
<Box sx={sx} onClick={handleClick} editing={editing} disabled={disabled}> <Box sx={sx} onClick={handleClick} editing={editing} disabled={disabled}>
{!disabled && iconPosition === 'start' && editing === false && (
<EditIcon fontSize={iconSize} sx={{ mr: 1 }} />
)}
{component} {component}
{!disabled && iconPosition === 'end' && editing === false && ( {!disabled && editing === false && (
<EditIcon fontSize={iconSize} sx={{ ml: 1 }} /> <EditIcon fontSize="small" color={iconColor} sx={{ ml: 1 }} />
)} )}
</Box> </Box>
); );
@@ -102,8 +97,7 @@ function EditableTypography(props) {
EditableTypography.propTypes = { EditableTypography.propTypes = {
children: PropTypes.string.isRequired, children: PropTypes.string.isRequired,
disabled: PropTypes.bool, disabled: PropTypes.bool,
iconPosition: PropTypes.oneOf(['start', 'end']), iconColor: PropTypes.oneOf(['action', 'inherit']),
iconSize: PropTypes.oneOf(['small', 'large']),
onConfirm: PropTypes.func, onConfirm: PropTypes.func,
prefixValue: PropTypes.string, prefixValue: PropTypes.string,
sx: PropTypes.object, sx: PropTypes.object,

View File

@@ -6,29 +6,36 @@ import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import DownloadIcon from '@mui/icons-material/Download';
import Snackbar from '@mui/material/Snackbar'; import Snackbar from '@mui/material/Snackbar';
import { ReactFlowProvider } from 'reactflow'; import { ReactFlowProvider } from 'reactflow';
import { EditorProvider } from 'contexts/Editor'; import { EditorProvider } from 'contexts/Editor';
import EditableTypography from 'components/EditableTypography';
import Container from 'components/Container';
import Editor from 'components/Editor';
import Can from 'components/Can';
import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls';
import { TopBar } from './style'; import { TopBar } from './style';
import * as URLS from 'config/urls';
import Can from 'components/Can';
import Container from 'components/Container';
import EditableTypography from 'components/EditableTypography';
import Editor from 'components/Editor';
import EditorNew from 'components/EditorNew/EditorNew';
import useFlow from 'hooks/useFlow'; import useFlow from 'hooks/useFlow';
import useFormatMessage from 'hooks/useFormatMessage';
import useUpdateFlow from 'hooks/useUpdateFlow'; import useUpdateFlow from 'hooks/useUpdateFlow';
import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus'; import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus';
import EditorNew from 'components/EditorNew/EditorNew'; import useExportFlow from 'hooks/useExportFlow';
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true';
export default function EditorLayout() { export default function EditorLayout() {
const { flowId } = useParams(); const { flowId } = useParams();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const { mutateAsync: updateFlow } = useUpdateFlow(flowId); const { mutateAsync: updateFlow } = useUpdateFlow(flowId);
const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId); const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId);
const { mutateAsync: exportFlow } = useExportFlow(flowId);
const downloadJsonAsFile = useDownloadJsonAsFile();
const { data, isLoading: isFlowLoading } = useFlow(flowId); const { data, isLoading: isFlowLoading } = useFlow(flowId);
const flow = data?.data; const flow = data?.data;
@@ -38,6 +45,19 @@ export default function EditorLayout() {
}); });
}; };
const onExportFlow = async (name) => {
const flowExport = await exportFlow();
downloadJsonAsFile({
contents: flowExport.data,
name: flowExport.data.name,
});
enqueueSnackbar(formatMessage('flowEditor.flowSuccessfullyExported'), {
variant: 'success',
});
};
return ( return (
<> <>
<TopBar <TopBar
@@ -72,6 +92,7 @@ export default function EditorLayout() {
variant="body1" variant="body1"
onConfirm={onFlowNameUpdate} onConfirm={onFlowNameUpdate}
noWrap noWrap
iconColor="action"
sx={{ display: 'flex', flex: 1, maxWidth: '50vw', ml: 2 }} sx={{ display: 'flex', flex: 1, maxWidth: '50vw', ml: 2 }}
> >
{flow?.name} {flow?.name}
@@ -79,7 +100,23 @@ export default function EditorLayout() {
)} )}
</Box> </Box>
<Box pr={1}> <Box pr={1} display="flex" gap={1}>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<Button
disabled={!allowed || !flow}
variant="outlined"
color="info"
size="small"
onClick={onExportFlow}
data-test="export-flow-button"
startIcon={<DownloadIcon />}
>
{formatMessage('flowEditor.export')}
</Button>
)}
</Can>
<Can I="publish" a="Flow" passThrough> <Can I="publish" a="Flow" passThrough>
{(allowed) => ( {(allowed) => (
<Button <Button

View File

@@ -12,6 +12,8 @@ import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useDuplicateFlow from 'hooks/useDuplicateFlow'; import useDuplicateFlow from 'hooks/useDuplicateFlow';
import useDeleteFlow from 'hooks/useDeleteFlow'; import useDeleteFlow from 'hooks/useDeleteFlow';
import useExportFlow from 'hooks/useExportFlow';
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
function ContextMenu(props) { function ContextMenu(props) {
const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } = const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } =
@@ -20,7 +22,9 @@ function ContextMenu(props) {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutateAsync: duplicateFlow } = useDuplicateFlow(flowId); const { mutateAsync: duplicateFlow } = useDuplicateFlow(flowId);
const { mutateAsync: deleteFlow } = useDeleteFlow(); const { mutateAsync: deleteFlow } = useDeleteFlow(flowId);
const { mutateAsync: exportFlow } = useExportFlow(flowId);
const downloadJsonAsFile = useDownloadJsonAsFile();
const onFlowDuplicate = React.useCallback(async () => { const onFlowDuplicate = React.useCallback(async () => {
await duplicateFlow(); await duplicateFlow();
@@ -51,7 +55,7 @@ function ContextMenu(props) {
]); ]);
const onFlowDelete = React.useCallback(async () => { const onFlowDelete = React.useCallback(async () => {
await deleteFlow(flowId); await deleteFlow();
if (appKey) { if (appKey) {
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
@@ -65,7 +69,30 @@ function ContextMenu(props) {
onDeleteFlow?.(); onDeleteFlow?.();
onClose(); onClose();
}, [flowId, onClose, deleteFlow, queryClient, onDeleteFlow]); }, [
deleteFlow,
appKey,
enqueueSnackbar,
formatMessage,
onDeleteFlow,
onClose,
queryClient,
]);
const onFlowExport = React.useCallback(async () => {
const flowExport = await exportFlow();
downloadJsonAsFile({
contents: flowExport.data,
name: flowExport.data.name,
});
enqueueSnackbar(formatMessage('flow.successfullyExported'), {
variant: 'success',
});
onClose();
}, [exportFlow, downloadJsonAsFile, enqueueSnackbar, formatMessage, onClose]);
return ( return (
<Menu <Menu
@@ -90,6 +117,14 @@ function ContextMenu(props) {
)} )}
</Can> </Can>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowExport}>
{formatMessage('flow.export')}
</MenuItem>
)}
</Can>
<Can I="delete" a="Flow" passThrough> <Can I="delete" a="Flow" passThrough>
{(allowed) => ( {(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowDelete}> <MenuItem disabled={!allowed} onClick={onFlowDelete}>

View File

@@ -13,7 +13,6 @@ import useCreateConnectionAuthUrl from './useCreateConnectionAuthUrl';
import useUpdateConnection from './useUpdateConnection'; import useUpdateConnection from './useUpdateConnection';
import useResetConnection from './useResetConnection'; import useResetConnection from './useResetConnection';
import useVerifyConnection from './useVerifyConnection'; import useVerifyConnection from './useVerifyConnection';
import { useWhatChanged } from '@simbathesailor/use-what-changed';
function getSteps(auth, hasConnection, useShared) { function getSteps(auth, hasConnection, useShared) {
if (hasConnection) { if (hasConnection) {
@@ -143,24 +142,6 @@ export default function useAuthenticateApp(payload) {
verifyConnection, verifyConnection,
]); ]);
useWhatChanged(
[
steps,
appKey,
oauthClientId,
connectionId,
queryClient,
createConnection,
createConnectionAuthUrl,
updateConnection,
resetConnection,
verifyConnection,
],
'steps, appKey, oauthClientId, connectionId, queryClient, createConnection, createConnectionAuthUrl, updateConnection, resetConnection, verifyConnection',
'',
'useAuthenticate',
);
return { return {
authenticate, authenticate,
inProgress: authenticationInProgress, inProgress: authenticationInProgress,

View File

@@ -2,11 +2,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api'; import api from 'helpers/api';
export default function useDeleteFlow() { export default function useDeleteFlow(flowId) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useMutation({ const query = useMutation({
mutationFn: async (flowId) => { mutationFn: async () => {
const { data } = await api.delete(`/v1/flows/${flowId}`); const { data } = await api.delete(`/v1/flows/${flowId}`);
return data; return data;

View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import slugify from 'slugify';
export default function useDownloadJsonAsFile() {
const handleDownloadJsonAsFile = React.useCallback(
function handleDownloadJsonAsFile({ contents, name }) {
const stringifiedContents = JSON.stringify(contents, null, 2);
const slugifiedName = slugify(name, {
lower: true,
strict: true,
replacement: '-',
});
const fileBlob = new Blob([stringifiedContents], {
type: 'application/json',
});
const fileObjectUrl = URL.createObjectURL(fileBlob);
const temporaryDownloadLink = document.createElement('a');
temporaryDownloadLink.href = fileObjectUrl;
temporaryDownloadLink.download = slugifiedName;
temporaryDownloadLink.click();
},
[],
);
return handleDownloadJsonAsFile;
}

View File

@@ -0,0 +1,15 @@
import { useMutation } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useExportFlow(flowId) {
const mutation = useMutation({
mutationFn: async () => {
const { data } = await api.post(`/v1/flows/${flowId}/export`);
return data;
},
});
return mutation;
}

View File

@@ -56,9 +56,11 @@
"flow.draft": "Draft", "flow.draft": "Draft",
"flow.successfullyDeleted": "The flow and associated executions have been deleted.", "flow.successfullyDeleted": "The flow and associated executions have been deleted.",
"flow.successfullyDuplicated": "The flow has been successfully duplicated.", "flow.successfullyDuplicated": "The flow has been successfully duplicated.",
"flow.successfullyExported": "The flow export has been successfully generated.",
"flowEditor.publish": "PUBLISH", "flowEditor.publish": "PUBLISH",
"flowEditor.unpublish": "UNPUBLISH", "flowEditor.unpublish": "UNPUBLISH",
"flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.", "flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.",
"flowEditor.export": "EXPORT",
"flowEditor.noTestDataTitle": "We couldn't find matching data", "flowEditor.noTestDataTitle": "We couldn't find matching data",
"flowEditor.noTestDataMessage": "Create a sample in the associated service and test the step again.", "flowEditor.noTestDataMessage": "Create a sample in the associated service and test the step again.",
"flowEditor.testAndContinue": "Test & Continue", "flowEditor.testAndContinue": "Test & Continue",
@@ -70,6 +72,7 @@
"flowEditor.triggerEvent": "Trigger event", "flowEditor.triggerEvent": "Trigger event",
"flowEditor.actionEvent": "Action event", "flowEditor.actionEvent": "Action event",
"flowEditor.instantTriggerType": "Instant", "flowEditor.instantTriggerType": "Instant",
"flowEditor.flowSuccessfullyExported": "The flow export has been successfully generated.",
"filterConditions.onlyContinueIf": "Only continue if…", "filterConditions.onlyContinueIf": "Only continue if…",
"filterConditions.orContinueIf": "OR continue if…", "filterConditions.orContinueIf": "OR continue if…",
"chooseConnectionSubstep.continue": "Continue", "chooseConnectionSubstep.continue": "Continue",
@@ -81,6 +84,7 @@
"flow.view": "View", "flow.view": "View",
"flow.duplicate": "Duplicate", "flow.duplicate": "Duplicate",
"flow.delete": "Delete", "flow.delete": "Delete",
"flow.export": "Export",
"flowStep.triggerType": "Trigger", "flowStep.triggerType": "Trigger",
"flowStep.actionType": "Action", "flowStep.actionType": "Action",
"flows.create": "Create flow", "flows.create": "Create flow",
@@ -231,7 +235,6 @@
"createUser.submit": "Create", "createUser.submit": "Create",
"createUser.successfullyCreated": "The user has been created.", "createUser.successfullyCreated": "The user has been created.",
"createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: <link></link>", "createUser.invitationEmailInfo": "Invitation email will be sent if SMTP credentials are valid. Otherwise, you can share the invitation link manually: <link></link>",
"createUser.error": "Error while creating the user.",
"editUserPage.title": "Edit user", "editUserPage.title": "Edit user",
"editUser.status": "Status", "editUser.status": "Status",
"editUser.submit": "Update", "editUser.submit": "Update",
@@ -251,8 +254,10 @@
"createRolePage.title": "Create role", "createRolePage.title": "Create role",
"roleForm.name": "Name", "roleForm.name": "Name",
"roleForm.description": "Description", "roleForm.description": "Description",
"roleForm.mandatoryInput": "{inputName} is required.",
"createRole.submit": "Create", "createRole.submit": "Create",
"createRole.successfullyCreated": "The role has been created.", "createRole.successfullyCreated": "The role has been created.",
"createRole.permissionsError": "Permissions are invalid.",
"editRole.submit": "Update", "editRole.submit": "Update",
"editRole.successfullyUpdated": "The role has been updated.", "editRole.successfullyUpdated": "The role has been updated.",
"roleList.name": "Name", "roleList.name": "Name",

View File

@@ -1,10 +1,14 @@
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import Grid from '@mui/material/Grid'; import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert';
import AlertTitle from '@mui/material/AlertTitle';
import PermissionCatalogField from 'components/PermissionCatalogField/index.ee'; import PermissionCatalogField from 'components/PermissionCatalogField/index.ee';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import Container from 'components/Container'; import Container from 'components/Container';
import Form from 'components/Form'; import Form from 'components/Form';
@@ -19,6 +23,40 @@ import useFormatMessage from 'hooks/useFormatMessage';
import useAdminCreateRole from 'hooks/useAdminCreateRole'; import useAdminCreateRole from 'hooks/useAdminCreateRole';
import usePermissionCatalog from 'hooks/usePermissionCatalog.ee'; import usePermissionCatalog from 'hooks/usePermissionCatalog.ee';
const getValidationSchema = (formatMessage) => {
const getMandatoryFieldMessage = (fieldTranslationId) =>
formatMessage('roleForm.mandatoryInput', {
inputName: formatMessage(fieldTranslationId),
});
return yup.object().shape({
name: yup
.string()
.trim()
.required(getMandatoryFieldMessage('roleForm.name')),
description: yup.string().trim(),
});
};
const getPermissionsErrorMessage = (error) => {
const errors = error?.response?.data?.errors;
if (errors) {
const permissionsErrors = Object.keys(errors)
.filter((key) => key.startsWith('permissions'))
.reduce((obj, key) => {
obj[key] = errors[key];
return obj;
}, {});
if (Object.keys(permissionsErrors).length > 0) {
return JSON.stringify(permissionsErrors, null, 2);
}
}
return null;
};
export default function CreateRole() { export default function CreateRole() {
const navigate = useNavigate(); const navigate = useNavigate();
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
@@ -27,6 +65,7 @@ export default function CreateRole() {
useAdminCreateRole(); useAdminCreateRole();
const { data: permissionCatalogData, isLoading: isPermissionCatalogLoading } = const { data: permissionCatalogData, isLoading: isPermissionCatalogLoading } =
usePermissionCatalog(); usePermissionCatalog();
const [permissionError, setPermissionError] = React.useState(null);
const defaultValues = React.useMemo( const defaultValues = React.useMemo(
() => ({ () => ({
@@ -44,6 +83,7 @@ export default function CreateRole() {
const handleRoleCreation = async (roleData) => { const handleRoleCreation = async (roleData) => {
try { try {
setPermissionError(null);
const permissions = getPermissions(roleData.computedPermissions); const permissions = getPermissions(roleData.computedPermissions);
await createRole({ await createRole({
@@ -61,16 +101,13 @@ export default function CreateRole() {
navigate(URLS.ROLES); navigate(URLS.ROLES);
} catch (error) { } catch (error) {
const errors = Object.values(error.response.data.errors); const permissionError = getPermissionsErrorMessage(error);
if (permissionError) {
for (const [errorMessage] of errors) { setPermissionError(permissionError);
enqueueSnackbar(errorMessage, {
variant: 'error',
SnackbarProps: {
'data-test': 'snackbar-error',
},
});
} }
const errors = error?.response?.data?.errors;
throw errors || error;
} }
}; };
@@ -84,39 +121,67 @@ export default function CreateRole() {
</Grid> </Grid>
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}> <Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
<Form onSubmit={handleRoleCreation} defaultValues={defaultValues}> <Form
<Stack direction="column" gap={2}> onSubmit={handleRoleCreation}
<TextField defaultValues={defaultValues}
required={true} noValidate
name="name" resolver={yupResolver(getValidationSchema(formatMessage))}
label={formatMessage('roleForm.name')} automaticValidation={false}
fullWidth render={({ formState: { errors } }) => (
data-test="name-input" <Stack direction="column" gap={2}>
disabled={isPermissionCatalogLoading} <TextField
/> required={true}
name="name"
label={formatMessage('roleForm.name')}
fullWidth
data-test="name-input"
error={!!errors?.name}
helperText={errors?.name?.message}
disabled={isPermissionCatalogLoading}
/>
<TextField <TextField
name="description" name="description"
label={formatMessage('roleForm.description')} label={formatMessage('roleForm.description')}
fullWidth fullWidth
data-test="description-input" data-test="description-input"
disabled={isPermissionCatalogLoading} error={!!errors?.description}
/> helperText={errors?.description?.message}
disabled={isPermissionCatalogLoading}
/>
<PermissionCatalogField name="computedPermissions" /> <PermissionCatalogField name="computedPermissions" />
<LoadingButton {permissionError && (
type="submit" <Alert severity="error" data-test="create-role-error-alert">
variant="contained" <AlertTitle>
color="primary" {formatMessage('createRole.permissionsError')}
sx={{ boxShadow: 2 }} </AlertTitle>
loading={isCreateRolePending} <pre>
data-test="create-button" <code>{permissionError}</code>
> </pre>
{formatMessage('createRole.submit')} </Alert>
</LoadingButton> )}
</Stack>
</Form> {errors?.root?.general && !permissionError && (
<Alert severity="error" data-test="create-role-error-alert">
{errors?.root?.general?.message}
</Alert>
)}
<LoadingButton
type="submit"
variant="contained"
color="primary"
sx={{ boxShadow: 2 }}
loading={isCreateRolePending}
data-test="create-button"
>
{formatMessage('createRole.submit')}
</LoadingButton>
</Stack>
)}
/>
</Grid> </Grid>
</Grid> </Grid>
</Container> </Container>

View File

@@ -3,9 +3,10 @@ import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import MuiTextField from '@mui/material/TextField'; import MuiTextField from '@mui/material/TextField';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
import * as React from 'react'; import * as React from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import * as yup from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import Can from 'components/Can'; import Can from 'components/Can';
import Container from 'components/Container'; import Container from 'components/Container';
@@ -16,50 +17,70 @@ import TextField from 'components/TextField';
import useFormatMessage from 'hooks/useFormatMessage'; import useFormatMessage from 'hooks/useFormatMessage';
import useRoles from 'hooks/useRoles.ee'; import useRoles from 'hooks/useRoles.ee';
import useAdminCreateUser from 'hooks/useAdminCreateUser'; import useAdminCreateUser from 'hooks/useAdminCreateUser';
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
function generateRoleOptions(roles) { function generateRoleOptions(roles) {
return roles?.map(({ name: label, id: value }) => ({ label, value })); return roles?.map(({ name: label, id: value }) => ({ label, value }));
} }
const getValidationSchema = (formatMessage, canUpdateRole) => {
const getMandatoryFieldMessage = (fieldTranslationId) =>
formatMessage('userForm.mandatoryInput', {
inputName: formatMessage(fieldTranslationId),
});
return yup.object().shape({
fullName: yup
.string()
.trim()
.required(getMandatoryFieldMessage('userForm.fullName')),
email: yup
.string()
.trim()
.email(formatMessage('userForm.validateEmail'))
.required(getMandatoryFieldMessage('userForm.email')),
...(canUpdateRole
? {
roleId: yup
.string()
.required(getMandatoryFieldMessage('userForm.role')),
}
: {}),
});
};
const defaultValues = {
fullName: '',
email: '',
roleId: '',
};
export default function CreateUser() { export default function CreateUser() {
const formatMessage = useFormatMessage(); const formatMessage = useFormatMessage();
const { const {
mutateAsync: createUser, mutateAsync: createUser,
isPending: isCreateUserPending, isPending: isCreateUserPending,
data: createdUser, data: createdUser,
isSuccess: createUserSuccess,
} = useAdminCreateUser(); } = useAdminCreateUser();
const { data: rolesData, loading: isRolesLoading } = useRoles(); const { data: rolesData, loading: isRolesLoading } = useRoles();
const roles = rolesData?.data; const roles = rolesData?.data;
const enqueueSnackbar = useEnqueueSnackbar();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const currentUserAbility = useCurrentUserAbility();
const canUpdateRole = currentUserAbility.can('update', 'Role');
const handleUserCreation = async (userData) => { const handleUserCreation = async (userData) => {
try { try {
await createUser({ await createUser({
fullName: userData.fullName, fullName: userData.fullName,
email: userData.email, email: userData.email,
roleId: userData.role?.id, roleId: userData.roleId,
}); });
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] }); queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
enqueueSnackbar(formatMessage('createUser.successfullyCreated'), {
variant: 'success',
persist: true,
SnackbarProps: {
'data-test': 'snackbar-create-user-success',
},
});
} catch (error) { } catch (error) {
enqueueSnackbar(formatMessage('createUser.error'), { const errors = error?.response?.data?.errors;
variant: 'error', throw errors || error;
persist: true,
SnackbarProps: {
'data-test': 'snackbar-error',
},
});
throw new Error('Failed while creating!');
} }
}; };
@@ -73,74 +94,111 @@ export default function CreateUser() {
</Grid> </Grid>
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}> <Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
<Form onSubmit={handleUserCreation}> <Form
<Stack direction="column" gap={2}> noValidate
<TextField onSubmit={handleUserCreation}
required={true} mode="onSubmit"
name="fullName" defaultValues={defaultValues}
label={formatMessage('userForm.fullName')} resolver={yupResolver(
data-test="full-name-input" getValidationSchema(formatMessage, canUpdateRole),
fullWidth )}
/> automaticValidation={false}
render={({ formState: { errors } }) => (
<TextField <Stack direction="column" gap={2}>
required={true} <TextField
name="email" required={true}
label={formatMessage('userForm.email')} name="fullName"
data-test="email-input" label={formatMessage('userForm.fullName')}
fullWidth data-test="full-name-input"
/>
<Can I="update" a="Role">
<ControlledAutocomplete
name="role.id"
fullWidth fullWidth
disablePortal error={!!errors?.fullName}
disableClearable={true} helperText={errors?.fullName?.message}
options={generateRoleOptions(roles)}
renderInput={(params) => (
<MuiTextField
{...params}
required
label={formatMessage('userForm.role')}
/>
)}
loading={isRolesLoading}
/> />
</Can>
<LoadingButton <TextField
type="submit" required={true}
variant="contained" name="email"
color="primary" label={formatMessage('userForm.email')}
sx={{ boxShadow: 2 }} data-test="email-input"
loading={isCreateUserPending} fullWidth
data-test="create-button" error={!!errors?.email}
> helperText={errors?.email?.message}
{formatMessage('createUser.submit')} />
</LoadingButton>
{createdUser && ( <Can I="update" a="Role">
<Alert <ControlledAutocomplete
severity="info" name="roleId"
fullWidth
disablePortal
disableClearable={true}
options={generateRoleOptions(roles)}
renderInput={(params) => (
<MuiTextField
{...params}
required
label={formatMessage('userForm.role')}
error={!!errors?.roleId}
helperText={errors?.roleId?.message}
/>
)}
loading={isRolesLoading}
showHelperText={false}
/>
</Can>
{errors?.root?.general && (
<Alert data-test="create-user-error-alert" severity="error">
{errors?.root?.general?.message}
</Alert>
)}
{createUserSuccess && (
<Alert
severity="success"
data-test="create-user-success-alert"
>
{formatMessage('createUser.successfullyCreated')}
</Alert>
)}
{createdUser && (
<Alert
severity="info"
color="primary"
data-test="invitation-email-info-alert"
sx={{
a: {
wordBreak: 'break-all',
},
}}
>
{formatMessage('createUser.invitationEmailInfo', {
link: () => (
<a
href={createdUser.data.acceptInvitationUrl}
target="_blank"
rel="noreferrer"
>
{createdUser.data.acceptInvitationUrl}
</a>
),
})}
</Alert>
)}
<LoadingButton
type="submit"
variant="contained"
color="primary" color="primary"
data-test="invitation-email-info-alert" sx={{ boxShadow: 2 }}
loading={isCreateUserPending}
data-test="create-button"
> >
{formatMessage('createUser.invitationEmailInfo', { {formatMessage('createUser.submit')}
link: () => ( </LoadingButton>
<a </Stack>
href={createdUser.data.acceptInvitationUrl} )}
target="_blank" />
rel="noreferrer"
>
{createdUser.data.acceptInvitationUrl}
</a>
),
})}
</Alert>
)}
</Stack>
</Form>
</Grid> </Grid>
</Grid> </Grid>
</Container> </Container>

View File

@@ -123,8 +123,6 @@ export const RawTriggerPropType = PropTypes.shape({
showWebhookUrl: PropTypes.bool, showWebhookUrl: PropTypes.bool,
pollInterval: PropTypes.number, pollInterval: PropTypes.number,
description: PropTypes.string, description: PropTypes.string,
useSingletonWebhook: PropTypes.bool,
singletonWebhookRefValueParameter: PropTypes.string,
getInterval: PropTypes.func, getInterval: PropTypes.func,
run: PropTypes.func, run: PropTypes.func,
testRun: PropTypes.func, testRun: PropTypes.func,
@@ -140,8 +138,6 @@ export const TriggerPropType = PropTypes.shape({
showWebhookUrl: PropTypes.bool, showWebhookUrl: PropTypes.bool,
pollInterval: PropTypes.number, pollInterval: PropTypes.number,
description: PropTypes.string, description: PropTypes.string,
useSingletonWebhook: PropTypes.bool,
singletonWebhookRefValueParameter: PropTypes.string,
getInterval: PropTypes.func, getInterval: PropTypes.func,
run: PropTypes.func, run: PropTypes.func,
testRun: PropTypes.func, testRun: PropTypes.func,

View File

@@ -2126,11 +2126,6 @@
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1" resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1"
integrity sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA== integrity sha512-WJgX9nzTqknM393q1QJDJmoW28kUfEnybeTfVNcNAPnIx210RXm2DiXiHzfNPJNIUUb1tJnz/l4QGtJ30PgWmA==
"@simbathesailor/use-what-changed@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@simbathesailor/use-what-changed/-/use-what-changed-2.0.0.tgz#7f82d78f92c8588b5fadd702065dde93bd781403"
integrity sha512-ulBNrPSvfho9UN6zS2fii3AsdEcp2fMaKeqUZZeCNPaZbB6aXyTUhpEN9atjMAbu/eyK3AY8L4SYJUG62Ekocw==
"@sinclair/typebox@^0.24.1": "@sinclair/typebox@^0.24.1":
version "0.24.51" version "0.24.51"
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f"
@@ -9638,6 +9633,11 @@ slate@^0.94.1:
is-plain-object "^5.0.0" is-plain-object "^5.0.0"
tiny-warning "^1.0.3" tiny-warning "^1.0.3"
slugify@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
sockjs@^0.3.24: sockjs@^0.3.24:
version "0.3.24" version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"