Merge branch 'main' into AUT-1380
This commit is contained in:
@@ -35,9 +35,6 @@ export default defineTrigger({
|
||||
},
|
||||
],
|
||||
|
||||
useSingletonWebhook: true,
|
||||
singletonWebhookRefValueParameter: 'phoneNumberSid',
|
||||
|
||||
async run($) {
|
||||
const dataItem = {
|
||||
raw: $.request.body,
|
||||
|
||||
11
packages/backend/src/controllers/api/v1/flows/export-flow.js
Normal file
11
packages/backend/src/controllers/api/v1/flows/export-flow.js
Normal 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 });
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -113,6 +113,10 @@ const authorizationList = {
|
||||
action: 'create',
|
||||
subject: 'Flow',
|
||||
},
|
||||
'POST /api/v1/flows/:flowId/export': {
|
||||
action: 'update',
|
||||
subject: 'Flow',
|
||||
},
|
||||
'POST /api/v1/flows/:flowId/steps': {
|
||||
action: 'update',
|
||||
subject: 'Flow',
|
||||
|
||||
45
packages/backend/src/helpers/export-flow.js
Normal file
45
packages/backend/src/helpers/export-flow.js
Normal 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;
|
||||
@@ -7,6 +7,7 @@ import ExecutionStep from './execution-step.js';
|
||||
import globalVariable from '../helpers/global-variable.js';
|
||||
import logger from '../helpers/logger.js';
|
||||
import Telemetry from '../helpers/telemetry/index.js';
|
||||
import exportFlow from '../helpers/export-flow.js';
|
||||
import flowQueue from '../queues/flow.js';
|
||||
import {
|
||||
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) {
|
||||
await super.$beforeUpdate(opt, queryContext);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { createFlow } from '../../test/factories/flow.js';
|
||||
import { createStep } from '../../test/factories/step.js';
|
||||
import { createExecution } from '../../test/factories/execution.js';
|
||||
import { createExecutionStep } from '../../test/factories/execution-step.js';
|
||||
import * as exportFlow from '../helpers/export-flow.js';
|
||||
|
||||
describe('Flow model', () => {
|
||||
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', () => {
|
||||
it('should throw validation error with less than two steps', async () => {
|
||||
const flow = await createFlow();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { URL } from 'node:url';
|
||||
import get from 'lodash.get';
|
||||
import Base from './base.js';
|
||||
import App from './app.js';
|
||||
import Flow from './flow.js';
|
||||
@@ -109,25 +108,10 @@ class Step extends Base {
|
||||
|
||||
if (!triggerCommand) return null;
|
||||
|
||||
const { useSingletonWebhook, singletonWebhookRefValueParameter, type } =
|
||||
triggerCommand;
|
||||
|
||||
const isWebhook = type === 'webhook';
|
||||
const isWebhook = triggerCommand.type === 'webhook';
|
||||
|
||||
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) {
|
||||
return `/webhooks/flows/${this.flowId}/sync`;
|
||||
}
|
||||
|
||||
@@ -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 deleteFlowAction from '../../../controllers/api/v1/flows/delete-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();
|
||||
|
||||
@@ -17,6 +18,13 @@ router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction);
|
||||
router.post('/', authenticateUser, authorizeUser, createFlowAction);
|
||||
router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction);
|
||||
|
||||
router.post(
|
||||
'/:flowId/export',
|
||||
authenticateUser,
|
||||
authorizeUser,
|
||||
exportFlowAction
|
||||
);
|
||||
|
||||
router.patch(
|
||||
'/:flowId/status',
|
||||
authenticateUser,
|
||||
|
||||
@@ -4,7 +4,6 @@ import multer from 'multer';
|
||||
import appConfig from '../config/app.js';
|
||||
import webhookHandlerByFlowId from '../controllers/webhooks/handler-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 upload = multer();
|
||||
@@ -39,14 +38,6 @@ function createRouteHandler(path, handler) {
|
||||
.post(wrappedHandler);
|
||||
}
|
||||
|
||||
createRouteHandler(
|
||||
'/connections/:connectionId/:refValue',
|
||||
webhookHandlerByConnectionIdAndRefValue
|
||||
);
|
||||
createRouteHandler(
|
||||
'/connections/:connectionId',
|
||||
webhookHandlerByConnectionIdAndRefValue
|
||||
);
|
||||
createRouteHandler('/flows/:flowId/sync', webhookHandlerSyncByFlowId);
|
||||
createRouteHandler('/flows/:flowId', webhookHandlerByFlowId);
|
||||
createRouteHandler('/:flowId', webhookHandlerByFlowId);
|
||||
|
||||
41
packages/backend/test/mocks/rest/api/v1/flows/export-flow.js
Normal file
41
packages/backend/test/mocks/rest/api/v1/flows/export-flow.js
Normal 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;
|
||||
@@ -13,17 +13,25 @@ export default defineConfig({
|
||||
reportsDirectory: './coverage',
|
||||
reporter: ['text', 'lcov'],
|
||||
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: [
|
||||
'**/src/controllers/webhooks/**',
|
||||
'**/src/controllers/paddle/**',
|
||||
],
|
||||
thresholds: {
|
||||
autoUpdate: true,
|
||||
statements: 99.44,
|
||||
branches: 97.78,
|
||||
functions: 99.1,
|
||||
lines: 99.44,
|
||||
statements: 99.4,
|
||||
branches: 97.77,
|
||||
functions: 99.16,
|
||||
lines: 99.4,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4261,16 +4261,7 @@ streamsearch@^1.1.0:
|
||||
resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764"
|
||||
integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==
|
||||
|
||||
"string-width-cjs@npm:string-width@^4.2.0":
|
||||
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:
|
||||
"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==
|
||||
@@ -4302,14 +4293,7 @@ string_decoder@~1.1.1:
|
||||
dependencies:
|
||||
safe-buffer "~5.1.0"
|
||||
|
||||
"strip-ansi-cjs@npm: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:
|
||||
"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==
|
||||
|
||||
@@ -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 |
|
||||
| `REDIS_HOST` | string | `redis` | Redis Host |
|
||||
| `REDIS_PORT` | number | `6379` | Redis Port |
|
||||
| `REDIS_DB` | number | | Redis Database |
|
||||
| `REDIS_USERNAME` | string | | Redis Username |
|
||||
| `REDIS_PASSWORD` | string | | Redis Password |
|
||||
| `REDIS_TLS` | boolean | `false` | Redis TLS |
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const { expect } = require('@playwright/test');
|
||||
|
||||
const { faker } = require('@faker-js/faker');
|
||||
const { AuthenticatedPage } = require('../authenticated-page');
|
||||
|
||||
@@ -11,7 +13,7 @@ export class AdminCreateUserPage extends AuthenticatedPage {
|
||||
super(page);
|
||||
this.fullNameInput = page.getByTestId('full-name-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.pageTitle = page.getByTestId('create-user-title');
|
||||
this.invitationEmailInfoAlert = page.getByTestId(
|
||||
@@ -20,6 +22,8 @@ export class AdminCreateUserPage extends AuthenticatedPage {
|
||||
this.acceptInvitationLink = page
|
||||
.getByTestId('invitation-email-info-alert')
|
||||
.getByRole('link');
|
||||
this.createUserSuccessAlert = page.getByTestId('create-user-success-alert');
|
||||
this.fieldError = page.locator('p[id$="-helper-text"]');
|
||||
}
|
||||
|
||||
seed(seed) {
|
||||
@@ -32,4 +36,8 @@ export class AdminCreateUserPage extends AuthenticatedPage {
|
||||
email: faker.internet.email().toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
async expectCreateUserSuccessAlertToBeVisible() {
|
||||
await expect(this.createUserSuccessAlert).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,9 +35,8 @@ test.describe('Role management page', () => {
|
||||
await adminCreateRolePage.closeSnackbar();
|
||||
});
|
||||
|
||||
let roleRow = await test.step(
|
||||
'Make sure role data is correct',
|
||||
async () => {
|
||||
let roleRow =
|
||||
await test.step('Make sure role data is correct', async () => {
|
||||
const roleRow = await adminRolesPage.getRoleRowByName(
|
||||
'Create Edit Test'
|
||||
);
|
||||
@@ -48,8 +47,7 @@ test.describe('Role management page', () => {
|
||||
await expect(roleData.canEdit).toBe(true);
|
||||
await expect(roleData.canDelete).toBe(true);
|
||||
return roleRow;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('Edit the role', async () => {
|
||||
await adminRolesPage.clickEditRole(roleRow);
|
||||
@@ -67,9 +65,8 @@ test.describe('Role management page', () => {
|
||||
await adminEditRolePage.closeSnackbar();
|
||||
});
|
||||
|
||||
roleRow = await test.step(
|
||||
'Make sure changes reflected on roles page',
|
||||
async () => {
|
||||
roleRow =
|
||||
await test.step('Make sure changes reflected on roles page', async () => {
|
||||
await adminRolesPage.isMounted();
|
||||
const roleRow = await adminRolesPage.getRoleRowByName(
|
||||
'Create Update Test'
|
||||
@@ -81,8 +78,7 @@ test.describe('Role management page', () => {
|
||||
await expect(roleData.canEdit).toBe(true);
|
||||
await expect(roleData.canDelete).toBe(true);
|
||||
return roleRow;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
await test.step('Delete the role', async () => {
|
||||
await adminRolesPage.clickDeleteRole(roleRow);
|
||||
@@ -184,49 +180,39 @@ test.describe('Role management page', () => {
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminCreateRolePage.closeSnackbar();
|
||||
});
|
||||
await test.step(
|
||||
'Create a new user with the "Delete Role" role',
|
||||
async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
await adminUsersPage.createUserButton.click();
|
||||
await adminCreateUserPage.fullNameInput.fill('User Role Test');
|
||||
await adminCreateUserPage.emailInput.fill(
|
||||
'user-role-test@automatisch.io'
|
||||
);
|
||||
await adminCreateUserPage.roleInput.click();
|
||||
await adminCreateUserPage.page
|
||||
.getByRole('option', { name: 'Delete Role', exact: true })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
await adminCreateUserPage.snackbar.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminUsersPage.closeSnackbar();
|
||||
}
|
||||
);
|
||||
await test.step(
|
||||
'Try to delete "Delete Role" role when new user has it',
|
||||
async () => {
|
||||
await adminRolesPage.navigateTo();
|
||||
const row = await adminRolesPage.getRoleRowByName('Delete Role');
|
||||
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('Create a new user with the "Delete Role" role', async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
await adminUsersPage.createUserButton.click();
|
||||
await adminCreateUserPage.fullNameInput.fill('User Role Test');
|
||||
await adminCreateUserPage.emailInput.fill(
|
||||
'user-role-test@automatisch.io'
|
||||
);
|
||||
await adminCreateUserPage.roleInput.click();
|
||||
await adminCreateUserPage.page
|
||||
.getByRole('option', { name: 'Delete Role', exact: true })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Try to delete "Delete Role" role when new user has it', async () => {
|
||||
await adminRolesPage.navigateTo();
|
||||
const row = await adminRolesPage.getRoleRowByName('Delete Role');
|
||||
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 adminUsersPage.navigateTo();
|
||||
await adminUsersPage.usersLoader.waitFor({
|
||||
@@ -301,24 +287,16 @@ test.describe('Role management page', () => {
|
||||
.getByRole('option', { name: 'Cannot Delete Role' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
await adminCreateUserPage.snackbar.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
const snackbar = await adminCreateUserPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminCreateUserPage.closeSnackbar();
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
await test.step('Delete this user', async () => {
|
||||
await adminUsersPage.navigateTo();
|
||||
const row = await adminUsersPage.findUserPageWithEmail(
|
||||
'user-delete-role-test@automatisch.io'
|
||||
);
|
||||
// await test.waitForTimeout(10000);
|
||||
const modal = await adminUsersPage.clickDeleteUser(row);
|
||||
await modal.deleteButton.click();
|
||||
await adminUsersPage.snackbar.waitFor({
|
||||
@@ -385,17 +363,10 @@ test('Accessibility of role management page', async ({
|
||||
.getByRole('option', { name: 'Basic Test' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
await adminCreateUserPage.snackbar.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
await adminCreateUserPage.invitationEmailInfoAlert.waitFor({
|
||||
state: 'attached',
|
||||
});
|
||||
const snackbar = await adminCreateUserPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminCreateUserPage.closeSnackbar();
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
const acceptInvitationPage = new AcceptInvitation(page);
|
||||
|
||||
await acceptInvitationPage.open(acceptInvitatonToken);
|
||||
|
||||
await acceptInvitationPage.acceptInvitation('sample');
|
||||
|
||||
const loginPage = new LoginPage(page);
|
||||
|
||||
// await loginPage.isMounted();
|
||||
await loginPage.login('basic-role-test@automatisch.io', 'sample');
|
||||
await expect(loginPage.loginButton).not.toBeVisible();
|
||||
await expect(page).toHaveURL('/flows');
|
||||
});
|
||||
|
||||
await test.step(
|
||||
'Navigate to the admin settings page and make sure it is blank',
|
||||
async () => {
|
||||
const pageUrl = new URL(page.url());
|
||||
const url = `${pageUrl.origin}/admin-settings/users`;
|
||||
await page.goto(url);
|
||||
await page.waitForTimeout(750);
|
||||
const isUnmounted = await page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const root = document.querySelector('#root');
|
||||
await test.step('Navigate to the admin settings page and make sure it is blank', async () => {
|
||||
const pageUrl = new URL(page.url());
|
||||
const url = `${pageUrl.origin}/admin-settings/users`;
|
||||
await page.goto(url);
|
||||
await page.waitForTimeout(750);
|
||||
const isUnmounted = await page.evaluate(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
const root = document.querySelector('#root');
|
||||
|
||||
if (root) {
|
||||
// We have react query devtools only in dev env.
|
||||
// In production, there is nothing in root.
|
||||
// That's why `<= 1`.
|
||||
return root.children.length <= 1;
|
||||
}
|
||||
if (root) {
|
||||
// We have react query devtools only in dev env.
|
||||
// In production, there is nothing in root.
|
||||
// That's why `<= 1`.
|
||||
return root.children.length <= 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
await expect(isUnmounted).toBe(true);
|
||||
}
|
||||
);
|
||||
return false;
|
||||
});
|
||||
await expect(isUnmounted).toBe(true);
|
||||
});
|
||||
|
||||
await test.step('Log back into the admin account', async () => {
|
||||
await page.goto('/');
|
||||
|
||||
@@ -37,12 +37,8 @@ test.describe('User management page', () => {
|
||||
state: 'attached',
|
||||
});
|
||||
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
await adminUsersPage.navigateTo();
|
||||
await adminUsersPage.closeSnackbar();
|
||||
});
|
||||
await test.step('Check the user exists with the expected properties', async () => {
|
||||
await adminUsersPage.findUserPageWithEmail(user.email);
|
||||
@@ -106,11 +102,7 @@ test.describe('User management page', () => {
|
||||
.getByRole('option', { name: 'Admin' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminUsersPage.closeSnackbar();
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Delete the created user', async () => {
|
||||
@@ -138,9 +130,7 @@ test.describe('User management page', () => {
|
||||
.getByRole('option', { name: 'Admin' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
const snackbar = await adminUsersPage.getSnackbarData('snackbar-error');
|
||||
await expect(snackbar.variant).toBe('error');
|
||||
await adminUsersPage.closeSnackbar();
|
||||
await expect(adminCreateUserPage.fieldError).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,11 +151,7 @@ test.describe('User management page', () => {
|
||||
.getByRole('option', { name: 'Admin' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminUsersPage.closeSnackbar();
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Create the user again', async () => {
|
||||
@@ -181,9 +167,7 @@ test.describe('User management page', () => {
|
||||
await adminCreateUserPage.createButton.click();
|
||||
|
||||
await expect(page.url()).toBe(createUserPageUrl);
|
||||
const snackbar = await adminUsersPage.getSnackbarData('snackbar-error');
|
||||
await expect(snackbar.variant).toBe('error');
|
||||
await adminUsersPage.closeSnackbar();
|
||||
await expect(adminCreateUserPage.fieldError).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -206,11 +190,7 @@ test.describe('User management page', () => {
|
||||
.getByRole('option', { name: 'Admin' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminUsersPage.closeSnackbar();
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Create the second user', async () => {
|
||||
@@ -223,11 +203,7 @@ test.describe('User management page', () => {
|
||||
.getByRole('option', { name: 'Admin' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminUsersPage.closeSnackbar();
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
|
||||
await test.step('Try editing the second user to have the email of the first user', async () => {
|
||||
|
||||
@@ -7,198 +7,191 @@ test('Ensure creating a new flow works', async ({ page }) => {
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Create a new flow with a Scheduler step then an Ntfy step',
|
||||
async ({ flowEditorPage, page }) => {
|
||||
await test.step('create flow', async () => {
|
||||
await test.step('navigate to new flow page', async () => {
|
||||
await page.getByTestId('create-flow-button').click();
|
||||
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}/
|
||||
);
|
||||
test('Create a new flow with a Scheduler step then an Ntfy step', async ({
|
||||
flowEditorPage,
|
||||
page,
|
||||
}) => {
|
||||
await test.step('create flow', async () => {
|
||||
await test.step('navigate to new flow page', async () => {
|
||||
await page.getByTestId('create-flow-button').click();
|
||||
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 expect(page.getByTestId('flow-step')).toHaveCount(2);
|
||||
|
||||
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('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('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('choose "yes" in "trigger on weekends?"', async () => {
|
||||
await expect(flowEditorPage.trigger).toBeVisible();
|
||||
await flowEditorPage.trigger.click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
});
|
||||
|
||||
await test.step('set up a trigger', async () => {
|
||||
await test.step('choose "yes" in "trigger on weekends?"', async () => {
|
||||
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('continue to next step', async () => {
|
||||
await flowEditorPage.continueButton.click();
|
||||
});
|
||||
|
||||
await test.step('test trigger', async () => {
|
||||
await test.step('show sample output', async () => {
|
||||
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('collapses the substep', async () => {
|
||||
await expect(flowEditorPage.trigger).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
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.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 test.step('test trigger', async () => {
|
||||
await test.step('show sample output', async () => {
|
||||
await expect(flowEditorPage.testOutput).not.toBeVisible();
|
||||
await flowEditorPage.continueButton.click();
|
||||
await expect(flowEditorPage.testOutput).toBeVisible();
|
||||
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
|
||||
.getByTestId('unpublish-flow-from-snackbar')
|
||||
.getByRole('option')
|
||||
.filter({ hasText: 'Add new connection' })
|
||||
.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('continue to next step', async () => {
|
||||
await page.getByTestId('create-connection-button').click();
|
||||
});
|
||||
|
||||
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 test.step('collapses the substep', async () => {
|
||||
await flowEditorPage.continueButton.click();
|
||||
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: '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 page.getByTestId('editor-go-back-button').click();
|
||||
await expect(page).toHaveURL('/flows');
|
||||
});
|
||||
|
||||
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({
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,10 +33,7 @@ publicTest.describe('My Profile', () => {
|
||||
.getByRole('option', { name: 'Admin' })
|
||||
.click();
|
||||
await adminCreateUserPage.createButton.click();
|
||||
const snackbar = await adminUsersPage.getSnackbarData(
|
||||
'snackbar-create-user-success'
|
||||
);
|
||||
await expect(snackbar.variant).toBe('success');
|
||||
await adminCreateUserPage.expectCreateUserSuccessAlertToBeVisible();
|
||||
});
|
||||
|
||||
await publicTest.step('copy invitation link', async () => {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"slate": "^0.94.1",
|
||||
"slate-history": "^0.93.0",
|
||||
"slate-react": "^0.94.2",
|
||||
"slugify": "^1.6.6",
|
||||
"uuid": "^9.0.0",
|
||||
"web-vitals": "^1.0.1",
|
||||
"yup": "^0.32.11"
|
||||
@@ -83,7 +84,6 @@
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@simbathesailor/use-what-changed": "^2.0.0",
|
||||
"@tanstack/eslint-plugin-query": "^5.20.1",
|
||||
"@tanstack/react-query-devtools": "^5.24.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import MuiContainer from '@mui/material/Container';
|
||||
|
||||
export default function Container(props) {
|
||||
return <MuiContainer {...props} />;
|
||||
export default function Container({ maxWidth = 'lg', ...props }) {
|
||||
return <MuiContainer maxWidth={maxWidth} {...props} />;
|
||||
}
|
||||
|
||||
Container.defaultProps = {
|
||||
maxWidth: 'lg',
|
||||
Container.propTypes = {
|
||||
maxWidth: PropTypes.oneOf([
|
||||
'xs',
|
||||
'sm',
|
||||
'md',
|
||||
'lg',
|
||||
'xl',
|
||||
false,
|
||||
PropTypes.string,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -10,9 +10,8 @@ function EditableTypography(props) {
|
||||
const {
|
||||
children,
|
||||
onConfirm = noop,
|
||||
iconPosition = 'start',
|
||||
iconSize = 'large',
|
||||
sx,
|
||||
iconColor = 'inherit',
|
||||
disabled = false,
|
||||
prefixValue = '',
|
||||
...typographyProps
|
||||
@@ -86,14 +85,10 @@ function EditableTypography(props) {
|
||||
|
||||
return (
|
||||
<Box sx={sx} onClick={handleClick} editing={editing} disabled={disabled}>
|
||||
{!disabled && iconPosition === 'start' && editing === false && (
|
||||
<EditIcon fontSize={iconSize} sx={{ mr: 1 }} />
|
||||
)}
|
||||
|
||||
{component}
|
||||
|
||||
{!disabled && iconPosition === 'end' && editing === false && (
|
||||
<EditIcon fontSize={iconSize} sx={{ ml: 1 }} />
|
||||
{!disabled && editing === false && (
|
||||
<EditIcon fontSize="small" color={iconColor} sx={{ ml: 1 }} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
@@ -102,8 +97,7 @@ function EditableTypography(props) {
|
||||
EditableTypography.propTypes = {
|
||||
children: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
iconPosition: PropTypes.oneOf(['start', 'end']),
|
||||
iconSize: PropTypes.oneOf(['small', 'large']),
|
||||
iconColor: PropTypes.oneOf(['action', 'inherit']),
|
||||
onConfirm: PropTypes.func,
|
||||
prefixValue: PropTypes.string,
|
||||
sx: PropTypes.object,
|
||||
|
||||
@@ -6,29 +6,36 @@ import Button from '@mui/material/Button';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import Snackbar from '@mui/material/Snackbar';
|
||||
import { ReactFlowProvider } from 'reactflow';
|
||||
|
||||
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 * 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 useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useUpdateFlow from 'hooks/useUpdateFlow';
|
||||
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';
|
||||
|
||||
export default function EditorLayout() {
|
||||
const { flowId } = useParams();
|
||||
const formatMessage = useFormatMessage();
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const { mutateAsync: updateFlow } = useUpdateFlow(flowId);
|
||||
const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId);
|
||||
const { mutateAsync: exportFlow } = useExportFlow(flowId);
|
||||
const downloadJsonAsFile = useDownloadJsonAsFile();
|
||||
const { data, isLoading: isFlowLoading } = useFlow(flowId);
|
||||
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 (
|
||||
<>
|
||||
<TopBar
|
||||
@@ -72,6 +92,7 @@ export default function EditorLayout() {
|
||||
variant="body1"
|
||||
onConfirm={onFlowNameUpdate}
|
||||
noWrap
|
||||
iconColor="action"
|
||||
sx={{ display: 'flex', flex: 1, maxWidth: '50vw', ml: 2 }}
|
||||
>
|
||||
{flow?.name}
|
||||
@@ -79,7 +100,23 @@ export default function EditorLayout() {
|
||||
)}
|
||||
</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>
|
||||
{(allowed) => (
|
||||
<Button
|
||||
|
||||
@@ -12,6 +12,8 @@ import * as URLS from 'config/urls';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useDuplicateFlow from 'hooks/useDuplicateFlow';
|
||||
import useDeleteFlow from 'hooks/useDeleteFlow';
|
||||
import useExportFlow from 'hooks/useExportFlow';
|
||||
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
|
||||
|
||||
function ContextMenu(props) {
|
||||
const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } =
|
||||
@@ -20,7 +22,9 @@ function ContextMenu(props) {
|
||||
const formatMessage = useFormatMessage();
|
||||
const queryClient = useQueryClient();
|
||||
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 () => {
|
||||
await duplicateFlow();
|
||||
@@ -51,7 +55,7 @@ function ContextMenu(props) {
|
||||
]);
|
||||
|
||||
const onFlowDelete = React.useCallback(async () => {
|
||||
await deleteFlow(flowId);
|
||||
await deleteFlow();
|
||||
|
||||
if (appKey) {
|
||||
await queryClient.invalidateQueries({
|
||||
@@ -65,7 +69,30 @@ function ContextMenu(props) {
|
||||
|
||||
onDeleteFlow?.();
|
||||
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 (
|
||||
<Menu
|
||||
@@ -90,6 +117,14 @@ function ContextMenu(props) {
|
||||
)}
|
||||
</Can>
|
||||
|
||||
<Can I="read" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<MenuItem disabled={!allowed} onClick={onFlowExport}>
|
||||
{formatMessage('flow.export')}
|
||||
</MenuItem>
|
||||
)}
|
||||
</Can>
|
||||
|
||||
<Can I="delete" a="Flow" passThrough>
|
||||
{(allowed) => (
|
||||
<MenuItem disabled={!allowed} onClick={onFlowDelete}>
|
||||
|
||||
@@ -13,7 +13,6 @@ import useCreateConnectionAuthUrl from './useCreateConnectionAuthUrl';
|
||||
import useUpdateConnection from './useUpdateConnection';
|
||||
import useResetConnection from './useResetConnection';
|
||||
import useVerifyConnection from './useVerifyConnection';
|
||||
import { useWhatChanged } from '@simbathesailor/use-what-changed';
|
||||
|
||||
function getSteps(auth, hasConnection, useShared) {
|
||||
if (hasConnection) {
|
||||
@@ -143,24 +142,6 @@ export default function useAuthenticateApp(payload) {
|
||||
verifyConnection,
|
||||
]);
|
||||
|
||||
useWhatChanged(
|
||||
[
|
||||
steps,
|
||||
appKey,
|
||||
oauthClientId,
|
||||
connectionId,
|
||||
queryClient,
|
||||
createConnection,
|
||||
createConnectionAuthUrl,
|
||||
updateConnection,
|
||||
resetConnection,
|
||||
verifyConnection,
|
||||
],
|
||||
'steps, appKey, oauthClientId, connectionId, queryClient, createConnection, createConnectionAuthUrl, updateConnection, resetConnection, verifyConnection',
|
||||
'',
|
||||
'useAuthenticate',
|
||||
);
|
||||
|
||||
return {
|
||||
authenticate,
|
||||
inProgress: authenticationInProgress,
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import api from 'helpers/api';
|
||||
|
||||
export default function useDeleteFlow() {
|
||||
export default function useDeleteFlow(flowId) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const query = useMutation({
|
||||
mutationFn: async (flowId) => {
|
||||
mutationFn: async () => {
|
||||
const { data } = await api.delete(`/v1/flows/${flowId}`);
|
||||
|
||||
return data;
|
||||
|
||||
31
packages/web/src/hooks/useDownloadJsonAsFile.js
Normal file
31
packages/web/src/hooks/useDownloadJsonAsFile.js
Normal 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;
|
||||
}
|
||||
15
packages/web/src/hooks/useExportFlow.js
Normal file
15
packages/web/src/hooks/useExportFlow.js
Normal 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;
|
||||
}
|
||||
@@ -56,9 +56,11 @@
|
||||
"flow.draft": "Draft",
|
||||
"flow.successfullyDeleted": "The flow and associated executions have been deleted.",
|
||||
"flow.successfullyDuplicated": "The flow has been successfully duplicated.",
|
||||
"flow.successfullyExported": "The flow export has been successfully generated.",
|
||||
"flowEditor.publish": "PUBLISH",
|
||||
"flowEditor.unpublish": "UNPUBLISH",
|
||||
"flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.",
|
||||
"flowEditor.export": "EXPORT",
|
||||
"flowEditor.noTestDataTitle": "We couldn't find matching data",
|
||||
"flowEditor.noTestDataMessage": "Create a sample in the associated service and test the step again.",
|
||||
"flowEditor.testAndContinue": "Test & Continue",
|
||||
@@ -70,6 +72,7 @@
|
||||
"flowEditor.triggerEvent": "Trigger event",
|
||||
"flowEditor.actionEvent": "Action event",
|
||||
"flowEditor.instantTriggerType": "Instant",
|
||||
"flowEditor.flowSuccessfullyExported": "The flow export has been successfully generated.",
|
||||
"filterConditions.onlyContinueIf": "Only continue if…",
|
||||
"filterConditions.orContinueIf": "OR continue if…",
|
||||
"chooseConnectionSubstep.continue": "Continue",
|
||||
@@ -81,6 +84,7 @@
|
||||
"flow.view": "View",
|
||||
"flow.duplicate": "Duplicate",
|
||||
"flow.delete": "Delete",
|
||||
"flow.export": "Export",
|
||||
"flowStep.triggerType": "Trigger",
|
||||
"flowStep.actionType": "Action",
|
||||
"flows.create": "Create flow",
|
||||
@@ -231,7 +235,6 @@
|
||||
"createUser.submit": "Create",
|
||||
"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.error": "Error while creating the user.",
|
||||
"editUserPage.title": "Edit user",
|
||||
"editUser.status": "Status",
|
||||
"editUser.submit": "Update",
|
||||
@@ -251,8 +254,10 @@
|
||||
"createRolePage.title": "Create role",
|
||||
"roleForm.name": "Name",
|
||||
"roleForm.description": "Description",
|
||||
"roleForm.mandatoryInput": "{inputName} is required.",
|
||||
"createRole.submit": "Create",
|
||||
"createRole.successfullyCreated": "The role has been created.",
|
||||
"createRole.permissionsError": "Permissions are invalid.",
|
||||
"editRole.submit": "Update",
|
||||
"editRole.successfullyUpdated": "The role has been updated.",
|
||||
"roleList.name": "Name",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import LoadingButton from '@mui/lab/LoadingButton';
|
||||
import Grid from '@mui/material/Grid';
|
||||
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 useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
|
||||
import Container from 'components/Container';
|
||||
import Form from 'components/Form';
|
||||
@@ -19,6 +23,40 @@ import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useAdminCreateRole from 'hooks/useAdminCreateRole';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const formatMessage = useFormatMessage();
|
||||
@@ -27,6 +65,7 @@ export default function CreateRole() {
|
||||
useAdminCreateRole();
|
||||
const { data: permissionCatalogData, isLoading: isPermissionCatalogLoading } =
|
||||
usePermissionCatalog();
|
||||
const [permissionError, setPermissionError] = React.useState(null);
|
||||
|
||||
const defaultValues = React.useMemo(
|
||||
() => ({
|
||||
@@ -44,6 +83,7 @@ export default function CreateRole() {
|
||||
|
||||
const handleRoleCreation = async (roleData) => {
|
||||
try {
|
||||
setPermissionError(null);
|
||||
const permissions = getPermissions(roleData.computedPermissions);
|
||||
|
||||
await createRole({
|
||||
@@ -61,16 +101,13 @@ export default function CreateRole() {
|
||||
|
||||
navigate(URLS.ROLES);
|
||||
} catch (error) {
|
||||
const errors = Object.values(error.response.data.errors);
|
||||
|
||||
for (const [errorMessage] of errors) {
|
||||
enqueueSnackbar(errorMessage, {
|
||||
variant: 'error',
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-error',
|
||||
},
|
||||
});
|
||||
const permissionError = getPermissionsErrorMessage(error);
|
||||
if (permissionError) {
|
||||
setPermissionError(permissionError);
|
||||
}
|
||||
|
||||
const errors = error?.response?.data?.errors;
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,39 +121,67 @@ export default function CreateRole() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
<Form onSubmit={handleRoleCreation} defaultValues={defaultValues}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="name"
|
||||
label={formatMessage('roleForm.name')}
|
||||
fullWidth
|
||||
data-test="name-input"
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
<Form
|
||||
onSubmit={handleRoleCreation}
|
||||
defaultValues={defaultValues}
|
||||
noValidate
|
||||
resolver={yupResolver(getValidationSchema(formatMessage))}
|
||||
automaticValidation={false}
|
||||
render={({ formState: { errors } }) => (
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="name"
|
||||
label={formatMessage('roleForm.name')}
|
||||
fullWidth
|
||||
data-test="name-input"
|
||||
error={!!errors?.name}
|
||||
helperText={errors?.name?.message}
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
name="description"
|
||||
label={formatMessage('roleForm.description')}
|
||||
fullWidth
|
||||
data-test="description-input"
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
<TextField
|
||||
name="description"
|
||||
label={formatMessage('roleForm.description')}
|
||||
fullWidth
|
||||
data-test="description-input"
|
||||
error={!!errors?.description}
|
||||
helperText={errors?.description?.message}
|
||||
disabled={isPermissionCatalogLoading}
|
||||
/>
|
||||
|
||||
<PermissionCatalogField name="computedPermissions" />
|
||||
<PermissionCatalogField name="computedPermissions" />
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isCreateRolePending}
|
||||
data-test="create-button"
|
||||
>
|
||||
{formatMessage('createRole.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
</Form>
|
||||
{permissionError && (
|
||||
<Alert severity="error" data-test="create-role-error-alert">
|
||||
<AlertTitle>
|
||||
{formatMessage('createRole.permissionsError')}
|
||||
</AlertTitle>
|
||||
<pre>
|
||||
<code>{permissionError}</code>
|
||||
</pre>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{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>
|
||||
</Container>
|
||||
|
||||
@@ -3,9 +3,10 @@ import Grid from '@mui/material/Grid';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import MuiTextField from '@mui/material/TextField';
|
||||
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
|
||||
import * as React from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import * as yup from 'yup';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
import Can from 'components/Can';
|
||||
import Container from 'components/Container';
|
||||
@@ -16,50 +17,70 @@ import TextField from 'components/TextField';
|
||||
import useFormatMessage from 'hooks/useFormatMessage';
|
||||
import useRoles from 'hooks/useRoles.ee';
|
||||
import useAdminCreateUser from 'hooks/useAdminCreateUser';
|
||||
import useCurrentUserAbility from 'hooks/useCurrentUserAbility';
|
||||
|
||||
function generateRoleOptions(roles) {
|
||||
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() {
|
||||
const formatMessage = useFormatMessage();
|
||||
const {
|
||||
mutateAsync: createUser,
|
||||
isPending: isCreateUserPending,
|
||||
data: createdUser,
|
||||
isSuccess: createUserSuccess,
|
||||
} = useAdminCreateUser();
|
||||
const { data: rolesData, loading: isRolesLoading } = useRoles();
|
||||
const roles = rolesData?.data;
|
||||
const enqueueSnackbar = useEnqueueSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
const currentUserAbility = useCurrentUserAbility();
|
||||
const canUpdateRole = currentUserAbility.can('update', 'Role');
|
||||
|
||||
const handleUserCreation = async (userData) => {
|
||||
try {
|
||||
await createUser({
|
||||
fullName: userData.fullName,
|
||||
email: userData.email,
|
||||
roleId: userData.role?.id,
|
||||
roleId: userData.roleId,
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'users'] });
|
||||
|
||||
enqueueSnackbar(formatMessage('createUser.successfullyCreated'), {
|
||||
variant: 'success',
|
||||
persist: true,
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-create-user-success',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
enqueueSnackbar(formatMessage('createUser.error'), {
|
||||
variant: 'error',
|
||||
persist: true,
|
||||
SnackbarProps: {
|
||||
'data-test': 'snackbar-error',
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error('Failed while creating!');
|
||||
const errors = error?.response?.data?.errors;
|
||||
throw errors || error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,74 +94,111 @@ export default function CreateUser() {
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} justifyContent="flex-end" sx={{ pt: 5 }}>
|
||||
<Form onSubmit={handleUserCreation}>
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="fullName"
|
||||
label={formatMessage('userForm.fullName')}
|
||||
data-test="full-name-input"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
required={true}
|
||||
name="email"
|
||||
label={formatMessage('userForm.email')}
|
||||
data-test="email-input"
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Can I="update" a="Role">
|
||||
<ControlledAutocomplete
|
||||
name="role.id"
|
||||
<Form
|
||||
noValidate
|
||||
onSubmit={handleUserCreation}
|
||||
mode="onSubmit"
|
||||
defaultValues={defaultValues}
|
||||
resolver={yupResolver(
|
||||
getValidationSchema(formatMessage, canUpdateRole),
|
||||
)}
|
||||
automaticValidation={false}
|
||||
render={({ formState: { errors } }) => (
|
||||
<Stack direction="column" gap={2}>
|
||||
<TextField
|
||||
required={true}
|
||||
name="fullName"
|
||||
label={formatMessage('userForm.fullName')}
|
||||
data-test="full-name-input"
|
||||
fullWidth
|
||||
disablePortal
|
||||
disableClearable={true}
|
||||
options={generateRoleOptions(roles)}
|
||||
renderInput={(params) => (
|
||||
<MuiTextField
|
||||
{...params}
|
||||
required
|
||||
label={formatMessage('userForm.role')}
|
||||
/>
|
||||
)}
|
||||
loading={isRolesLoading}
|
||||
error={!!errors?.fullName}
|
||||
helperText={errors?.fullName?.message}
|
||||
/>
|
||||
</Can>
|
||||
|
||||
<LoadingButton
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isCreateUserPending}
|
||||
data-test="create-button"
|
||||
>
|
||||
{formatMessage('createUser.submit')}
|
||||
</LoadingButton>
|
||||
<TextField
|
||||
required={true}
|
||||
name="email"
|
||||
label={formatMessage('userForm.email')}
|
||||
data-test="email-input"
|
||||
fullWidth
|
||||
error={!!errors?.email}
|
||||
helperText={errors?.email?.message}
|
||||
/>
|
||||
|
||||
{createdUser && (
|
||||
<Alert
|
||||
severity="info"
|
||||
<Can I="update" a="Role">
|
||||
<ControlledAutocomplete
|
||||
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"
|
||||
data-test="invitation-email-info-alert"
|
||||
sx={{ boxShadow: 2 }}
|
||||
loading={isCreateUserPending}
|
||||
data-test="create-button"
|
||||
>
|
||||
{formatMessage('createUser.invitationEmailInfo', {
|
||||
link: () => (
|
||||
<a
|
||||
href={createdUser.data.acceptInvitationUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{createdUser.data.acceptInvitationUrl}
|
||||
</a>
|
||||
),
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Form>
|
||||
{formatMessage('createUser.submit')}
|
||||
</LoadingButton>
|
||||
</Stack>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Container>
|
||||
|
||||
@@ -123,8 +123,6 @@ export const RawTriggerPropType = PropTypes.shape({
|
||||
showWebhookUrl: PropTypes.bool,
|
||||
pollInterval: PropTypes.number,
|
||||
description: PropTypes.string,
|
||||
useSingletonWebhook: PropTypes.bool,
|
||||
singletonWebhookRefValueParameter: PropTypes.string,
|
||||
getInterval: PropTypes.func,
|
||||
run: PropTypes.func,
|
||||
testRun: PropTypes.func,
|
||||
@@ -140,8 +138,6 @@ export const TriggerPropType = PropTypes.shape({
|
||||
showWebhookUrl: PropTypes.bool,
|
||||
pollInterval: PropTypes.number,
|
||||
description: PropTypes.string,
|
||||
useSingletonWebhook: PropTypes.bool,
|
||||
singletonWebhookRefValueParameter: PropTypes.string,
|
||||
getInterval: PropTypes.func,
|
||||
run: PropTypes.func,
|
||||
testRun: PropTypes.func,
|
||||
|
||||
@@ -2126,11 +2126,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.10.4.tgz#427d5549943a9c6fce808e39ea64dbe60d4047f1"
|
||||
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":
|
||||
version "0.24.51"
|
||||
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"
|
||||
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:
|
||||
version "0.3.24"
|
||||
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"
|
||||
|
||||
Reference in New Issue
Block a user