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