From 169c86a7480777eda26b76f82665f93fa2962e62 Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Wed, 8 Jan 2025 11:43:37 +0300 Subject: [PATCH 1/7] feat: Implement initial logic of exporting flow --- packages/backend/src/helpers/export-flow.js | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 packages/backend/src/helpers/export-flow.js diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js new file mode 100644 index 00000000..9c4f6986 --- /dev/null +++ b/packages/backend/src/helpers/export-flow.js @@ -0,0 +1,46 @@ +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), + })), + }; + + console.log(JSON.stringify(exportedFlow, null, 2)); + 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; From c180b98460636d42f56c0912f6be8219ab52e4ac Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Fri, 10 Jan 2025 17:21:43 +0300 Subject: [PATCH 2/7] feat: Complete export flow rest API endpoint --- packages/backend/package.json | 1 + .../controllers/api/v1/flows/export-flow.js | 9 + .../api/v1/flows/export-flow.test.js | 217 ++++++++++++++++++ packages/backend/src/helpers/authorization.js | 4 + packages/backend/src/helpers/export-flow.js | 1 - packages/backend/src/models/flow.js | 20 ++ packages/backend/src/models/flow.test.js | 64 ++++++ packages/backend/src/routes/api/v1/flows.js | 8 + .../mocks/rest/api/v1/flows/export-flow.js | 32 +++ packages/backend/yarn.lock | 25 +- 10 files changed, 362 insertions(+), 19 deletions(-) create mode 100644 packages/backend/src/controllers/api/v1/flows/export-flow.js create mode 100644 packages/backend/src/controllers/api/v1/flows/export-flow.test.js create mode 100644 packages/backend/test/mocks/rest/api/v1/flows/export-flow.js diff --git a/packages/backend/package.json b/packages/backend/package.json index 2686d597..e16585a7 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -69,6 +69,7 @@ "prettier": "^2.5.1", "raw-body": "^2.5.2", "showdown": "^2.1.0", + "slugify": "^1.6.6", "uuid": "^9.0.1", "winston": "^3.7.1", "xmlrpc": "^1.3.2" diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js new file mode 100644 index 00000000..d3446dd9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js @@ -0,0 +1,9 @@ +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const { exportedFlowAsString, slug } = await flow.export(); + + response.status(201).attachment(slug).send(exportedFlowAsString); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js new file mode 100644 index 00000000..1f72ade9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -0,0 +1,217 @@ +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); + + // Test headers for file attachment + expect(response.headers['content-disposition']).toContain( + 'attachment; filename="name-your-flow.json"' + ); + expect(response.headers['content-type']).toBe( + 'application/json; charset=utf-8' + ); + + const expectedFileStructure = await exportFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedFileStructure); + }); + + 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); + + expect(response.headers['content-disposition']).toStrictEqual( + 'attachment; filename="name-your-flow.json"' + ); + expect(response.headers['content-type']).toStrictEqual( + 'application/json; charset=utf-8' + ); + + const expectedFileStructure = await exportFlowMock(anotherUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedFileStructure); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/export') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index c9f6329f..13718283 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -113,6 +113,10 @@ const authorizationList = { action: 'create', subject: 'Flow', }, + 'POST /api/v1/flows/:flowId/export': { + action: 'update', + subject: 'Flow', + }, 'POST /api/v1/flows/:flowId/steps': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js index 9c4f6986..05b238dc 100644 --- a/packages/backend/src/helpers/export-flow.js +++ b/packages/backend/src/helpers/export-flow.js @@ -23,7 +23,6 @@ const exportFlow = async (flow) => { })), }; - console.log(JSON.stringify(exportedFlow, null, 2)); return exportedFlow; }; diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 56744396..e9d32811 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -1,4 +1,5 @@ import { ValidationError } from 'objection'; +import slugify from 'slugify'; import Base from './base.js'; import Step from './step.js'; import User from './user.js'; @@ -7,6 +8,7 @@ import ExecutionStep from './execution-step.js'; import globalVariable from '../helpers/global-variable.js'; import logger from '../helpers/logger.js'; import Telemetry from '../helpers/telemetry/index.js'; +import exportFlow from '../helpers/export-flow.js'; import flowQueue from '../queues/flow.js'; import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, @@ -426,6 +428,24 @@ class Flow extends Base { } } + slugifyNameAsFilename() { + const slug = slugify(this.name, { + lower: true, + strict: true, + replacement: '-', + }); + + return `${slug}.json`; + } + + async export() { + const exportedFlow = await exportFlow(this); + const exportedFlowAsString = JSON.stringify(exportedFlow, null, 2); + const slug = this.slugifyNameAsFilename(); + + return { exportedFlowAsString, slug }; + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index 7faefa17..0329d6b6 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -10,6 +10,7 @@ import { createFlow } from '../../test/factories/flow.js'; import { createStep } from '../../test/factories/step.js'; import { createExecution } from '../../test/factories/execution.js'; import { createExecutionStep } from '../../test/factories/execution-step.js'; +import * as exportFlow from '../helpers/export-flow.js'; describe('Flow model', () => { it('tableName should return correct name', () => { @@ -506,6 +507,69 @@ describe('Flow model', () => { }); }); + describe('slugifyNameAsFilename', () => { + it('should generate a slug file name from flow name', async () => { + const flow = await createFlow({ + name: 'My Flow Name', + }); + + const slug = flow.slugifyNameAsFilename(); + expect(slug).toBe('my-flow-name.json'); + }); + }); + + describe('export', () => { + it('should call slugifyNameAsFilename method', async () => { + const flow = await createFlow({ + name: 'My Flow Name', + }); + + const slugifyNameAsFilenameSpy = vi + .spyOn(flow, 'slugifyNameAsFilename') + .mockImplementation(() => 'my-flow-name.json'); + + await flow.export(); + + expect(slugifyNameAsFilenameSpy).toHaveBeenCalledOnce(); + }); + + it('should call exportFlow method', async () => { + const flow = await createFlow(); + + const exportFlowSpy = vi + .spyOn(exportFlow, 'default') + .mockImplementation(() => {}); + + await flow.export(); + + expect(exportFlowSpy).toHaveBeenCalledOnce(); + }); + + it('should return exportedFlowAsString and slug', async () => { + const flow = await createFlow(); + + const exportedFlowAsString = { + name: 'My Flow Name', + }; + + const slug = 'slug'; + + vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString); + vi.spyOn(flow, 'slugifyNameAsFilename').mockReturnValue(slug); + + const expectedExportedFlowAsString = JSON.stringify( + exportedFlowAsString, + null, + 2 + ); + + expect(await flow.export()).toStrictEqual({ + exportedFlowAsString: expectedExportedFlowAsString, + slug: 'slug', + }); + }); + }); + describe('throwIfHavingLessThanTwoSteps', () => { it('should throw validation error with less than two steps', async () => { const flow = await createFlow(); diff --git a/packages/backend/src/routes/api/v1/flows.js b/packages/backend/src/routes/api/v1/flows.js index 8b507b82..10b19e74 100644 --- a/packages/backend/src/routes/api/v1/flows.js +++ b/packages/backend/src/routes/api/v1/flows.js @@ -9,6 +9,7 @@ import createFlowAction from '../../../controllers/api/v1/flows/create-flow.js'; import createStepAction from '../../../controllers/api/v1/flows/create-step.js'; import deleteFlowAction from '../../../controllers/api/v1/flows/delete-flow.js'; import duplicateFlowAction from '../../../controllers/api/v1/flows/duplicate-flow.js'; +import exportFlowAction from '../../../controllers/api/v1/flows/export-flow.js'; const router = Router(); @@ -17,6 +18,13 @@ router.get('/:flowId', authenticateUser, authorizeUser, getFlowAction); router.post('/', authenticateUser, authorizeUser, createFlowAction); router.patch('/:flowId', authenticateUser, authorizeUser, updateFlowAction); +router.post( + '/:flowId/export', + authenticateUser, + authorizeUser, + exportFlowAction +); + router.patch( '/:flowId/status', authenticateUser, diff --git a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js new file mode 100644 index 00000000..e235a6c7 --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js @@ -0,0 +1,32 @@ +import { expect } from 'vitest'; + +const duplicateFlowMock = 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; +}; + +export default duplicateFlowMock; diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock index bf899f12..184e2e40 100644 --- a/packages/backend/yarn.lock +++ b/packages/backend/yarn.lock @@ -4177,6 +4177,11 @@ simple-update-notifier@^1.0.7: dependencies: semver "~7.0.0" +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== + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" @@ -4261,16 +4266,7 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4302,14 +4298,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== From 51291889cdae7e0f463deebbba6a0d4685771d83 Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Mon, 13 Jan 2025 12:13:46 +0100 Subject: [PATCH 3/7] chore: Remove slugify library --- packages/backend/package.json | 1 - packages/backend/yarn.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index e16585a7..2686d597 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -69,7 +69,6 @@ "prettier": "^2.5.1", "raw-body": "^2.5.2", "showdown": "^2.1.0", - "slugify": "^1.6.6", "uuid": "^9.0.1", "winston": "^3.7.1", "xmlrpc": "^1.3.2" diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock index 184e2e40..718eff58 100644 --- a/packages/backend/yarn.lock +++ b/packages/backend/yarn.lock @@ -4177,11 +4177,6 @@ simple-update-notifier@^1.0.7: dependencies: semver "~7.0.0" -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== - smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" From f15d1ac7b15a9941e2b79ed56418b740777fa16b Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Mon, 13 Jan 2025 12:14:16 +0100 Subject: [PATCH 4/7] refactor: Only return JSON for flow export --- .../controllers/api/v1/flows/export-flow.js | 6 ++- .../api/v1/flows/export-flow.test.js | 23 ++------- packages/backend/src/models/flow.js | 17 +------ packages/backend/src/models/flow.test.js | 51 +------------------ 4 files changed, 11 insertions(+), 86 deletions(-) diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js index d3446dd9..5a1faac9 100644 --- a/packages/backend/src/controllers/api/v1/flows/export-flow.js +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js @@ -1,9 +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 { exportedFlowAsString, slug } = await flow.export(); + const exportedFlow = await flow.export(); - response.status(201).attachment(slug).send(exportedFlowAsString); + return renderObject(response, exportedFlow, { status: 201 }); }; diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js index 1f72ade9..1c648e64 100644 --- a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -67,20 +67,12 @@ describe('POST /api/v1/flows/:flowId/export', () => { .set('Authorization', token) .expect(201); - // Test headers for file attachment - expect(response.headers['content-disposition']).toContain( - 'attachment; filename="name-your-flow.json"' - ); - expect(response.headers['content-type']).toBe( - 'application/json; charset=utf-8' - ); - - const expectedFileStructure = await exportFlowMock(currentUserFlow, [ + const expectedPayload = await exportFlowMock(currentUserFlow, [ triggerStep, actionStep, ]); - expect(response.body).toStrictEqual(expectedFileStructure); + expect(response.body).toStrictEqual(expectedPayload); }); it('should export the flow data of another user', async () => { @@ -132,19 +124,12 @@ describe('POST /api/v1/flows/:flowId/export', () => { .set('Authorization', token) .expect(201); - expect(response.headers['content-disposition']).toStrictEqual( - 'attachment; filename="name-your-flow.json"' - ); - expect(response.headers['content-type']).toStrictEqual( - 'application/json; charset=utf-8' - ); - - const expectedFileStructure = await exportFlowMock(anotherUserFlow, [ + const expectedPayload = await exportFlowMock(anotherUserFlow, [ triggerStep, actionStep, ]); - expect(response.body).toStrictEqual(expectedFileStructure); + expect(response.body).toStrictEqual(expectedPayload); }); it('should return not found response for not existing flow UUID', async () => { diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index e9d32811..22be9030 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -1,5 +1,4 @@ import { ValidationError } from 'objection'; -import slugify from 'slugify'; import Base from './base.js'; import Step from './step.js'; import User from './user.js'; @@ -428,22 +427,8 @@ class Flow extends Base { } } - slugifyNameAsFilename() { - const slug = slugify(this.name, { - lower: true, - strict: true, - replacement: '-', - }); - - return `${slug}.json`; - } - async export() { - const exportedFlow = await exportFlow(this); - const exportedFlowAsString = JSON.stringify(exportedFlow, null, 2); - const slug = this.slugifyNameAsFilename(); - - return { exportedFlowAsString, slug }; + return await exportFlow(this); } async $beforeUpdate(opt, queryContext) { diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index 0329d6b6..cbaae474 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -507,65 +507,18 @@ describe('Flow model', () => { }); }); - describe('slugifyNameAsFilename', () => { - it('should generate a slug file name from flow name', async () => { - const flow = await createFlow({ - name: 'My Flow Name', - }); - - const slug = flow.slugifyNameAsFilename(); - expect(slug).toBe('my-flow-name.json'); - }); - }); - describe('export', () => { - it('should call slugifyNameAsFilename method', async () => { - const flow = await createFlow({ - name: 'My Flow Name', - }); - - const slugifyNameAsFilenameSpy = vi - .spyOn(flow, 'slugifyNameAsFilename') - .mockImplementation(() => 'my-flow-name.json'); - - await flow.export(); - - expect(slugifyNameAsFilenameSpy).toHaveBeenCalledOnce(); - }); - - it('should call exportFlow method', async () => { - const flow = await createFlow(); - - const exportFlowSpy = vi - .spyOn(exportFlow, 'default') - .mockImplementation(() => {}); - - await flow.export(); - - expect(exportFlowSpy).toHaveBeenCalledOnce(); - }); - - it('should return exportedFlowAsString and slug', async () => { + it('should return exportedFlow', async () => { const flow = await createFlow(); const exportedFlowAsString = { name: 'My Flow Name', }; - const slug = 'slug'; - vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString); - vi.spyOn(flow, 'slugifyNameAsFilename').mockReturnValue(slug); - - const expectedExportedFlowAsString = JSON.stringify( - exportedFlowAsString, - null, - 2 - ); expect(await flow.export()).toStrictEqual({ - exportedFlowAsString: expectedExportedFlowAsString, - slug: 'slug', + name: 'My Flow Name', }); }); }); From 7d621c07f11bd7b4e4862e18b368b2f6835546cc Mon Sep 17 00:00:00 2001 From: Faruk AYDIN Date: Mon, 13 Jan 2025 12:14:48 +0100 Subject: [PATCH 5/7] fix: Rename duplicate flow mock as export flow mock --- .../test/mocks/rest/api/v1/flows/export-flow.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js index e235a6c7..c7a1ef6e 100644 --- a/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js +++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js @@ -1,6 +1,6 @@ import { expect } from 'vitest'; -const duplicateFlowMock = async (flow, steps = []) => { +const exportFlowMock = async (flow, steps = []) => { const data = { id: expect.any(String), name: flow.name, @@ -26,7 +26,16 @@ const duplicateFlowMock = async (flow, steps = []) => { }); } - return data; + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; }; -export default duplicateFlowMock; +export default exportFlowMock; From ec148012619f45646605a67a372b40bc6ac4a453 Mon Sep 17 00:00:00 2001 From: Ali BARIN Date: Mon, 13 Jan 2025 12:30:15 +0000 Subject: [PATCH 6/7] feat(web): add exporting flow functionality --- packages/web/package.json | 1 + .../web/src/components/EditorLayout/index.jsx | 50 ++++++++++++++++--- .../src/components/FlowContextMenu/index.jsx | 41 +++++++++++++-- packages/web/src/hooks/useDeleteFlow.js | 4 +- .../web/src/hooks/useDownloadJsonAsFile.js | 31 ++++++++++++ packages/web/src/hooks/useExportFlow.js | 15 ++++++ packages/web/src/locales/en.json | 4 ++ packages/web/yarn.lock | 5 ++ 8 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 packages/web/src/hooks/useDownloadJsonAsFile.js create mode 100644 packages/web/src/hooks/useExportFlow.js diff --git a/packages/web/package.json b/packages/web/package.json index cf1eb72c..b8ab799b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -37,6 +37,7 @@ "slate": "^0.94.1", "slate-history": "^0.93.0", "slate-react": "^0.94.2", + "slugify": "^1.6.6", "uuid": "^9.0.0", "web-vitals": "^1.0.1", "yup": "^0.32.11" diff --git a/packages/web/src/components/EditorLayout/index.jsx b/packages/web/src/components/EditorLayout/index.jsx index c8878b5c..0d72f0bc 100644 --- a/packages/web/src/components/EditorLayout/index.jsx +++ b/packages/web/src/components/EditorLayout/index.jsx @@ -10,25 +10,31 @@ import Snackbar from '@mui/material/Snackbar'; import { ReactFlowProvider } from 'reactflow'; import { EditorProvider } from 'contexts/Editor'; -import EditableTypography from 'components/EditableTypography'; -import Container from 'components/Container'; -import Editor from 'components/Editor'; -import Can from 'components/Can'; -import useFormatMessage from 'hooks/useFormatMessage'; -import * as URLS from 'config/urls'; import { TopBar } from './style'; +import * as URLS from 'config/urls'; +import Can from 'components/Can'; +import Container from 'components/Container'; +import EditableTypography from 'components/EditableTypography'; +import Editor from 'components/Editor'; +import EditorNew from 'components/EditorNew/EditorNew'; import useFlow from 'hooks/useFlow'; +import useFormatMessage from 'hooks/useFormatMessage'; import useUpdateFlow from 'hooks/useUpdateFlow'; import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus'; -import EditorNew from 'components/EditorNew/EditorNew'; +import useExportFlow from 'hooks/useExportFlow'; +import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; export default function EditorLayout() { const { flowId } = useParams(); const formatMessage = useFormatMessage(); + const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: updateFlow } = useUpdateFlow(flowId); const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId); + const { mutateAsync: exportFlow } = useExportFlow(flowId); + const downloadJsonAsFile = useDownloadJsonAsFile(); const { data, isLoading: isFlowLoading } = useFlow(flowId); const flow = data?.data; @@ -38,6 +44,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 ( <> - + + + {(allowed) => ( + + )} + + {(allowed) => (