diff --git a/packages/backend/src/apps/twilio/triggers/receive-sms/index.js b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js
index d5224ca1..eac75b1a 100644
--- a/packages/backend/src/apps/twilio/triggers/receive-sms/index.js
+++ b/packages/backend/src/apps/twilio/triggers/receive-sms/index.js
@@ -35,9 +35,6 @@ export default defineTrigger({
},
],
- useSingletonWebhook: true,
- singletonWebhookRefValueParameter: 'phoneNumberSid',
-
async run($) {
const dataItem = {
raw: $.request.body,
diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js
new file mode 100644
index 00000000..5a1faac9
--- /dev/null
+++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js
@@ -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 });
+};
diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js
new file mode 100644
index 00000000..1c648e64
--- /dev/null
+++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js
@@ -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);
+ });
+});
diff --git a/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js b/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js
deleted file mode 100644
index 2f5c611f..00000000
--- a/packages/backend/src/controllers/webhooks/handler-by-connection-id-and-ref-value.js
+++ /dev/null
@@ -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);
-};
diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js
index c9f6329f..13718283 100644
--- a/packages/backend/src/helpers/authorization.js
+++ b/packages/backend/src/helpers/authorization.js
@@ -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',
diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js
new file mode 100644
index 00000000..05b238dc
--- /dev/null
+++ b/packages/backend/src/helpers/export-flow.js
@@ -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;
diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js
index 56744396..22be9030 100644
--- a/packages/backend/src/models/flow.js
+++ b/packages/backend/src/models/flow.js
@@ -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);
diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js
index 7faefa17..cbaae474 100644
--- a/packages/backend/src/models/flow.test.js
+++ b/packages/backend/src/models/flow.test.js
@@ -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();
diff --git a/packages/backend/src/models/step.js b/packages/backend/src/models/step.js
index 7c31e0a6..41c53373 100644
--- a/packages/backend/src/models/step.js
+++ b/packages/backend/src/models/step.js
@@ -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`;
}
diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js
index 8b507b82..10b19e74 100644
--- a/packages/backend/src/routes/api/v1/flows.js
+++ b/packages/backend/src/routes/api/v1/flows.js
@@ -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,
diff --git a/packages/backend/src/routes/webhooks.js b/packages/backend/src/routes/webhooks.js
index 98cadef0..cd2f359b 100644
--- a/packages/backend/src/routes/webhooks.js
+++ b/packages/backend/src/routes/webhooks.js
@@ -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);
diff --git a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js
new file mode 100644
index 00000000..c7a1ef6e
--- /dev/null
+++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js
@@ -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;
diff --git a/packages/backend/vitest.config.js b/packages/backend/vitest.config.js
index e75ea51d..9c2ef104 100644
--- a/packages/backend/vitest.config.js
+++ b/packages/backend/vitest.config.js
@@ -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,
},
},
},
diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock
index bf899f12..718eff58 100644
--- a/packages/backend/yarn.lock
+++ b/packages/backend/yarn.lock
@@ -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==
diff --git a/packages/docs/pages/advanced/configuration.md b/packages/docs/pages/advanced/configuration.md
index a6635034..aa461568 100644
--- a/packages/docs/pages/advanced/configuration.md
+++ b/packages/docs/pages/advanced/configuration.md
@@ -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 |
diff --git a/packages/e2e-tests/fixtures/admin/create-user-page.js b/packages/e2e-tests/fixtures/admin/create-user-page.js
index e59ba2c5..ddf0f6e6 100644
--- a/packages/e2e-tests/fixtures/admin/create-user-page.js
+++ b/packages/e2e-tests/fixtures/admin/create-user-page.js
@@ -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();
+ }
}
diff --git a/packages/e2e-tests/tests/admin/manage-roles.spec.js b/packages/e2e-tests/tests/admin/manage-roles.spec.js
index 00299c5d..1e9a405f 100644
--- a/packages/e2e-tests/tests/admin/manage-roles.spec.js
+++ b/packages/e2e-tests/tests/admin/manage-roles.spec.js
@@ -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('/');
diff --git a/packages/e2e-tests/tests/admin/manage-users.spec.js b/packages/e2e-tests/tests/admin/manage-users.spec.js
index 35b7490b..8b5aaf5b 100644
--- a/packages/e2e-tests/tests/admin/manage-users.spec.js
+++ b/packages/e2e-tests/tests/admin/manage-users.spec.js
@@ -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 () => {
diff --git a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js
index 98114c27..6f46454a 100644
--- a/packages/e2e-tests/tests/flow-editor/create-flow.spec.js
+++ b/packages/e2e-tests/tests/flow-editor/create-flow.spec.js
@@ -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',
});
});
- }
-);
\ No newline at end of file
+
+ 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');
+ });
+ });
+});
diff --git a/packages/e2e-tests/tests/my-profile/profile-updates.spec.js b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js
index d77962e4..fc0ce7d0 100644
--- a/packages/e2e-tests/tests/my-profile/profile-updates.spec.js
+++ b/packages/e2e-tests/tests/my-profile/profile-updates.spec.js
@@ -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 () => {
diff --git a/packages/web/package.json b/packages/web/package.json
index cf1eb72c..cd34f29c 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -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",
diff --git a/packages/web/src/components/Container/index.jsx b/packages/web/src/components/Container/index.jsx
index ffafaa14..ef75335b 100644
--- a/packages/web/src/components/Container/index.jsx
+++ b/packages/web/src/components/Container/index.jsx
@@ -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 ;
+export default function Container({ maxWidth = 'lg', ...props }) {
+ return ;
}
-Container.defaultProps = {
- maxWidth: 'lg',
+Container.propTypes = {
+ maxWidth: PropTypes.oneOf([
+ 'xs',
+ 'sm',
+ 'md',
+ 'lg',
+ 'xl',
+ false,
+ PropTypes.string,
+ ]),
};
diff --git a/packages/web/src/components/EditableTypography/index.jsx b/packages/web/src/components/EditableTypography/index.jsx
index f4c4b077..693bed1f 100644
--- a/packages/web/src/components/EditableTypography/index.jsx
+++ b/packages/web/src/components/EditableTypography/index.jsx
@@ -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 (
- {!disabled && iconPosition === 'start' && editing === false && (
-
- )}
-
{component}
- {!disabled && iconPosition === 'end' && editing === false && (
-
+ {!disabled && editing === false && (
+
)}
);
@@ -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,
diff --git a/packages/web/src/components/EditorLayout/index.jsx b/packages/web/src/components/EditorLayout/index.jsx
index c8878b5c..201b6407 100644
--- a/packages/web/src/components/EditorLayout/index.jsx
+++ b/packages/web/src/components/EditorLayout/index.jsx
@@ -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 (
<>
{flow?.name}
@@ -79,7 +100,23 @@ export default function EditorLayout() {
)}
-
+
+
+ {(allowed) => (
+ }
+ >
+ {formatMessage('flowEditor.export')}
+
+ )}
+
+
{(allowed) => (