diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.js b/packages/backend/src/controllers/api/v1/flows/export-flow.js new file mode 100644 index 00000000..5a1faac9 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.js @@ -0,0 +1,11 @@ +import { renderObject } from '../../../../helpers/renderer.js'; + +export default async (request, response) => { + const flow = await request.currentUser.authorizedFlows + .findById(request.params.flowId) + .throwIfNotFound(); + + const exportedFlow = await flow.export(); + + return renderObject(response, exportedFlow, { status: 201 }); +}; diff --git a/packages/backend/src/controllers/api/v1/flows/export-flow.test.js b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js new file mode 100644 index 00000000..1c648e64 --- /dev/null +++ b/packages/backend/src/controllers/api/v1/flows/export-flow.test.js @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import Crypto from 'crypto'; +import app from '../../../../app.js'; +import createAuthTokenByUserId from '../../../../helpers/create-auth-token-by-user-id.js'; +import { createUser } from '../../../../../test/factories/user.js'; +import { createFlow } from '../../../../../test/factories/flow.js'; +import { createStep } from '../../../../../test/factories/step.js'; +import { createPermission } from '../../../../../test/factories/permission.js'; +import exportFlowMock from '../../../../../test/mocks/rest/api/v1/flows/export-flow.js'; + +describe('POST /api/v1/flows/:flowId/export', () => { + let currentUser, currentUserRole, token; + + beforeEach(async () => { + currentUser = await createUser(); + currentUserRole = await currentUser.$relatedQuery('role'); + + token = await createAuthTokenByUserId(currentUser.id); + }); + + it('should export the flow data of the current user', async () => { + const currentUserFlow = await createFlow({ userId: currentUser.id }); + + const triggerStep = await createStep({ + flowId: currentUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${currentUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: currentUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const response = await request(app) + .post(`/api/v1/flows/${currentUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(currentUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should export the flow data of another user', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + const triggerStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'trigger', + appKey: 'webhook', + key: 'catchRawWebhook', + name: 'Catch raw webhook', + parameters: { + workSynchronously: true, + }, + position: 1, + webhookPath: `/webhooks/flows/${anotherUserFlow.id}/sync`, + }); + + const actionStep = await createStep({ + flowId: anotherUserFlow.id, + type: 'action', + appKey: 'formatter', + key: 'text', + name: 'Text', + parameters: { + input: `hello {{step.${triggerStep.id}.query.sample}} deneme`, + transform: 'capitalize', + }, + position: 2, + }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: [], + }); + + const response = await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(201); + + const expectedPayload = await exportFlowMock(anotherUserFlow, [ + triggerStep, + actionStep, + ]); + + expect(response.body).toStrictEqual(expectedPayload); + }); + + it('should return not found response for not existing flow UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + const notExistingFlowUUID = Crypto.randomUUID(); + + await request(app) + .post(`/api/v1/flows/${notExistingFlowUUID}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return not found response for unauthorized flow', async () => { + const anotherUser = await createUser(); + const anotherUserFlow = await createFlow({ userId: anotherUser.id }); + + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post(`/api/v1/flows/${anotherUserFlow.id}/export`) + .set('Authorization', token) + .expect(404); + }); + + it('should return bad request response for invalid UUID', async () => { + await createPermission({ + action: 'read', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await createPermission({ + action: 'update', + subject: 'Flow', + roleId: currentUserRole.id, + conditions: ['isCreator'], + }); + + await request(app) + .post('/api/v1/flows/invalidFlowUUID/export') + .set('Authorization', token) + .expect(400); + }); +}); diff --git a/packages/backend/src/helpers/authorization.js b/packages/backend/src/helpers/authorization.js index c9f6329f..13718283 100644 --- a/packages/backend/src/helpers/authorization.js +++ b/packages/backend/src/helpers/authorization.js @@ -113,6 +113,10 @@ const authorizationList = { action: 'create', subject: 'Flow', }, + 'POST /api/v1/flows/:flowId/export': { + action: 'update', + subject: 'Flow', + }, 'POST /api/v1/flows/:flowId/steps': { action: 'update', subject: 'Flow', diff --git a/packages/backend/src/helpers/export-flow.js b/packages/backend/src/helpers/export-flow.js new file mode 100644 index 00000000..05b238dc --- /dev/null +++ b/packages/backend/src/helpers/export-flow.js @@ -0,0 +1,45 @@ +import Crypto from 'crypto'; + +const exportFlow = async (flow) => { + const steps = await flow.$relatedQuery('steps'); + + const newFlowId = Crypto.randomUUID(); + const stepIdMap = Object.fromEntries( + steps.map((step) => [step.id, Crypto.randomUUID()]) + ); + + const exportedFlow = { + id: newFlowId, + name: flow.name, + steps: steps.map((step) => ({ + id: stepIdMap[step.id], + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: updateParameters(step.parameters, stepIdMap), + position: step.position, + webhookPath: step.webhookPath?.replace(flow.id, newFlowId), + })), + }; + + return exportedFlow; +}; + +const updateParameters = (parameters, stepIdMap) => { + if (!parameters) return parameters; + + const stringifiedParameters = JSON.stringify(parameters); + let updatedParameters = stringifiedParameters; + + Object.entries(stepIdMap).forEach(([oldStepId, newStepId]) => { + updatedParameters = updatedParameters.replace( + `{{step.${oldStepId}.`, + `{{step.${newStepId}.` + ); + }); + + return JSON.parse(updatedParameters); +}; + +export default exportFlow; diff --git a/packages/backend/src/models/flow.js b/packages/backend/src/models/flow.js index 56744396..22be9030 100644 --- a/packages/backend/src/models/flow.js +++ b/packages/backend/src/models/flow.js @@ -7,6 +7,7 @@ import ExecutionStep from './execution-step.js'; import globalVariable from '../helpers/global-variable.js'; import logger from '../helpers/logger.js'; import Telemetry from '../helpers/telemetry/index.js'; +import exportFlow from '../helpers/export-flow.js'; import flowQueue from '../queues/flow.js'; import { REMOVE_AFTER_30_DAYS_OR_150_JOBS, @@ -426,6 +427,10 @@ class Flow extends Base { } } + async export() { + return await exportFlow(this); + } + async $beforeUpdate(opt, queryContext) { await super.$beforeUpdate(opt, queryContext); diff --git a/packages/backend/src/models/flow.test.js b/packages/backend/src/models/flow.test.js index 7faefa17..cbaae474 100644 --- a/packages/backend/src/models/flow.test.js +++ b/packages/backend/src/models/flow.test.js @@ -10,6 +10,7 @@ import { createFlow } from '../../test/factories/flow.js'; import { createStep } from '../../test/factories/step.js'; import { createExecution } from '../../test/factories/execution.js'; import { createExecutionStep } from '../../test/factories/execution-step.js'; +import * as exportFlow from '../helpers/export-flow.js'; describe('Flow model', () => { it('tableName should return correct name', () => { @@ -506,6 +507,22 @@ describe('Flow model', () => { }); }); + describe('export', () => { + it('should return exportedFlow', async () => { + const flow = await createFlow(); + + const exportedFlowAsString = { + name: 'My Flow Name', + }; + + vi.spyOn(exportFlow, 'default').mockReturnValue(exportedFlowAsString); + + expect(await flow.export()).toStrictEqual({ + name: 'My Flow Name', + }); + }); + }); + describe('throwIfHavingLessThanTwoSteps', () => { it('should throw validation error with less than two steps', async () => { const flow = await createFlow(); diff --git a/packages/backend/src/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..c7a1ef6e --- /dev/null +++ b/packages/backend/test/mocks/rest/api/v1/flows/export-flow.js @@ -0,0 +1,41 @@ +import { expect } from 'vitest'; + +const exportFlowMock = async (flow, steps = []) => { + const data = { + id: expect.any(String), + name: flow.name, + }; + + if (steps.length) { + data.steps = steps.map((step) => { + const computedStep = { + id: expect.any(String), + key: step.key, + name: step.name, + appKey: step.appKey, + type: step.type, + parameters: expect.any(Object), + position: step.position, + }; + + if (step.type === 'trigger') { + computedStep.webhookPath = expect.stringContaining('/webhooks/flows/'); + } + + return computedStep; + }); + } + + return { + data: data, + meta: { + count: 1, + currentPage: null, + isArray: false, + totalPages: null, + type: 'Object', + }, + }; +}; + +export default exportFlowMock; diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock index bf899f12..718eff58 100644 --- a/packages/backend/yarn.lock +++ b/packages/backend/yarn.lock @@ -4261,16 +4261,7 @@ streamsearch@^1.1.0: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -4302,14 +4293,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== diff --git a/packages/web/package.json b/packages/web/package.json index 501d1ccc..cd34f29c 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -37,6 +37,7 @@ "slate": "^0.94.1", "slate-history": "^0.93.0", "slate-react": "^0.94.2", + "slugify": "^1.6.6", "uuid": "^9.0.0", "web-vitals": "^1.0.1", "yup": "^0.32.11" diff --git a/packages/web/src/components/EditorLayout/index.jsx b/packages/web/src/components/EditorLayout/index.jsx index 9c5a4faa..201b6407 100644 --- a/packages/web/src/components/EditorLayout/index.jsx +++ b/packages/web/src/components/EditorLayout/index.jsx @@ -6,29 +6,36 @@ import Button from '@mui/material/Button'; import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew'; +import DownloadIcon from '@mui/icons-material/Download'; import Snackbar from '@mui/material/Snackbar'; import { ReactFlowProvider } from 'reactflow'; import { EditorProvider } from 'contexts/Editor'; -import EditableTypography from 'components/EditableTypography'; -import Container from 'components/Container'; -import Editor from 'components/Editor'; -import Can from 'components/Can'; -import useFormatMessage from 'hooks/useFormatMessage'; -import * as URLS from 'config/urls'; import { TopBar } from './style'; +import * as URLS from 'config/urls'; +import Can from 'components/Can'; +import Container from 'components/Container'; +import EditableTypography from 'components/EditableTypography'; +import Editor from 'components/Editor'; +import EditorNew from 'components/EditorNew/EditorNew'; import useFlow from 'hooks/useFlow'; +import useFormatMessage from 'hooks/useFormatMessage'; import useUpdateFlow from 'hooks/useUpdateFlow'; import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus'; -import EditorNew from 'components/EditorNew/EditorNew'; +import useExportFlow from 'hooks/useExportFlow'; +import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile'; +import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar'; const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true'; export default function EditorLayout() { const { flowId } = useParams(); const formatMessage = useFormatMessage(); + const enqueueSnackbar = useEnqueueSnackbar(); const { mutateAsync: updateFlow } = useUpdateFlow(flowId); const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId); + const { mutateAsync: exportFlow } = useExportFlow(flowId); + const downloadJsonAsFile = useDownloadJsonAsFile(); const { data, isLoading: isFlowLoading } = useFlow(flowId); const flow = data?.data; @@ -38,6 +45,19 @@ export default function EditorLayout() { }); }; + const onExportFlow = async (name) => { + const flowExport = await exportFlow(); + + downloadJsonAsFile({ + contents: flowExport.data, + name: flowExport.data.name, + }); + + enqueueSnackbar(formatMessage('flowEditor.flowSuccessfullyExported'), { + variant: 'success', + }); + }; + return ( <> - + + + {(allowed) => ( + + )} + + {(allowed) => (