Merge pull request #2267 from automatisch/export-flow

feat: Implement initial logic of exporting flow
This commit is contained in:
Ömer Faruk Aydın
2025-01-13 18:06:55 +01:00
committed by GitHub
17 changed files with 475 additions and 31 deletions

View 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 });
};

View File

@@ -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);
});
});

View File

@@ -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',

View 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;

View File

@@ -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);

View File

@@ -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();

View File

@@ -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,

View 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;

View File

@@ -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==

View File

@@ -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"

View File

@@ -6,29 +6,36 @@ import Button from '@mui/material/Button';
import Tooltip from '@mui/material/Tooltip';
import IconButton from '@mui/material/IconButton';
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
import DownloadIcon from '@mui/icons-material/Download';
import Snackbar from '@mui/material/Snackbar';
import { ReactFlowProvider } from 'reactflow';
import { EditorProvider } from 'contexts/Editor';
import EditableTypography from 'components/EditableTypography';
import Container from 'components/Container';
import Editor from 'components/Editor';
import Can from 'components/Can';
import useFormatMessage from 'hooks/useFormatMessage';
import * as URLS from 'config/urls';
import { TopBar } from './style';
import * as URLS from 'config/urls';
import Can from 'components/Can';
import Container from 'components/Container';
import EditableTypography from 'components/EditableTypography';
import Editor from 'components/Editor';
import EditorNew from 'components/EditorNew/EditorNew';
import useFlow from 'hooks/useFlow';
import useFormatMessage from 'hooks/useFormatMessage';
import useUpdateFlow from 'hooks/useUpdateFlow';
import useUpdateFlowStatus from 'hooks/useUpdateFlowStatus';
import EditorNew from 'components/EditorNew/EditorNew';
import useExportFlow from 'hooks/useExportFlow';
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
import useEnqueueSnackbar from 'hooks/useEnqueueSnackbar';
const useNewFlowEditor = process.env.REACT_APP_USE_NEW_FLOW_EDITOR === 'true';
export default function EditorLayout() {
const { flowId } = useParams();
const formatMessage = useFormatMessage();
const enqueueSnackbar = useEnqueueSnackbar();
const { mutateAsync: updateFlow } = useUpdateFlow(flowId);
const { mutateAsync: updateFlowStatus } = useUpdateFlowStatus(flowId);
const { mutateAsync: exportFlow } = useExportFlow(flowId);
const downloadJsonAsFile = useDownloadJsonAsFile();
const { data, isLoading: isFlowLoading } = useFlow(flowId);
const flow = data?.data;
@@ -38,6 +45,19 @@ export default function EditorLayout() {
});
};
const onExportFlow = async (name) => {
const flowExport = await exportFlow();
downloadJsonAsFile({
contents: flowExport.data,
name: flowExport.data.name,
});
enqueueSnackbar(formatMessage('flowEditor.flowSuccessfullyExported'), {
variant: 'success',
});
};
return (
<>
<TopBar
@@ -80,7 +100,23 @@ export default function EditorLayout() {
)}
</Box>
<Box pr={1}>
<Box pr={1} display="flex" gap={1}>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<Button
disabled={!allowed || !flow}
variant="outlined"
color="info"
size="small"
onClick={onExportFlow}
data-test="export-flow-button"
startIcon={<DownloadIcon />}
>
{formatMessage('flowEditor.export')}
</Button>
)}
</Can>
<Can I="publish" a="Flow" passThrough>
{(allowed) => (
<Button

View File

@@ -12,6 +12,8 @@ import * as URLS from 'config/urls';
import useFormatMessage from 'hooks/useFormatMessage';
import useDuplicateFlow from 'hooks/useDuplicateFlow';
import useDeleteFlow from 'hooks/useDeleteFlow';
import useExportFlow from 'hooks/useExportFlow';
import useDownloadJsonAsFile from 'hooks/useDownloadJsonAsFile';
function ContextMenu(props) {
const { flowId, onClose, anchorEl, onDuplicateFlow, onDeleteFlow, appKey } =
@@ -20,7 +22,9 @@ function ContextMenu(props) {
const formatMessage = useFormatMessage();
const queryClient = useQueryClient();
const { mutateAsync: duplicateFlow } = useDuplicateFlow(flowId);
const { mutateAsync: deleteFlow } = useDeleteFlow();
const { mutateAsync: deleteFlow } = useDeleteFlow(flowId);
const { mutateAsync: exportFlow } = useExportFlow(flowId);
const downloadJsonAsFile = useDownloadJsonAsFile();
const onFlowDuplicate = React.useCallback(async () => {
await duplicateFlow();
@@ -51,7 +55,7 @@ function ContextMenu(props) {
]);
const onFlowDelete = React.useCallback(async () => {
await deleteFlow(flowId);
await deleteFlow();
if (appKey) {
await queryClient.invalidateQueries({
@@ -65,7 +69,30 @@ function ContextMenu(props) {
onDeleteFlow?.();
onClose();
}, [flowId, onClose, deleteFlow, queryClient, onDeleteFlow]);
}, [
deleteFlow,
appKey,
enqueueSnackbar,
formatMessage,
onDeleteFlow,
onClose,
queryClient,
]);
const onFlowExport = React.useCallback(async () => {
const flowExport = await exportFlow();
downloadJsonAsFile({
contents: flowExport.data,
name: flowExport.data.name,
});
enqueueSnackbar(formatMessage('flow.successfullyExported'), {
variant: 'success',
});
onClose();
}, [exportFlow, downloadJsonAsFile, enqueueSnackbar, formatMessage, onClose]);
return (
<Menu
@@ -90,6 +117,14 @@ function ContextMenu(props) {
)}
</Can>
<Can I="read" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowExport}>
{formatMessage('flow.export')}
</MenuItem>
)}
</Can>
<Can I="delete" a="Flow" passThrough>
{(allowed) => (
<MenuItem disabled={!allowed} onClick={onFlowDelete}>

View File

@@ -2,11 +2,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import api from 'helpers/api';
export default function useDeleteFlow() {
export default function useDeleteFlow(flowId) {
const queryClient = useQueryClient();
const query = useMutation({
mutationFn: async (flowId) => {
mutationFn: async () => {
const { data } = await api.delete(`/v1/flows/${flowId}`);
return data;

View 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;
}

View 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;
}

View File

@@ -56,9 +56,11 @@
"flow.draft": "Draft",
"flow.successfullyDeleted": "The flow and associated executions have been deleted.",
"flow.successfullyDuplicated": "The flow has been successfully duplicated.",
"flow.successfullyExported": "The flow export has been successfully generated.",
"flowEditor.publish": "PUBLISH",
"flowEditor.unpublish": "UNPUBLISH",
"flowEditor.publishedFlowCannotBeUpdated": "To edit this flow, you must first unpublish it.",
"flowEditor.export": "EXPORT",
"flowEditor.noTestDataTitle": "We couldn't find matching data",
"flowEditor.noTestDataMessage": "Create a sample in the associated service and test the step again.",
"flowEditor.testAndContinue": "Test & Continue",
@@ -70,6 +72,7 @@
"flowEditor.triggerEvent": "Trigger event",
"flowEditor.actionEvent": "Action event",
"flowEditor.instantTriggerType": "Instant",
"flowEditor.flowSuccessfullyExported": "The flow export has been successfully generated.",
"filterConditions.onlyContinueIf": "Only continue if…",
"filterConditions.orContinueIf": "OR continue if…",
"chooseConnectionSubstep.continue": "Continue",
@@ -81,6 +84,7 @@
"flow.view": "View",
"flow.duplicate": "Duplicate",
"flow.delete": "Delete",
"flow.export": "Export",
"flowStep.triggerType": "Trigger",
"flowStep.actionType": "Action",
"flows.create": "Create flow",

View File

@@ -9633,6 +9633,11 @@ slate@^0.94.1:
is-plain-object "^5.0.0"
tiny-warning "^1.0.3"
slugify@^1.6.6:
version "1.6.6"
resolved "https://registry.yarnpkg.com/slugify/-/slugify-1.6.6.tgz#2d4ac0eacb47add6af9e04d3be79319cbcc7924b"
integrity sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==
sockjs@^0.3.24:
version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"