feat(web): add exporting flow functionality

This commit is contained in:
Ali BARIN
2025-01-13 12:30:15 +00:00
parent 7d621c07f1
commit ec14801261
8 changed files with 138 additions and 13 deletions

View File

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

View File

@@ -10,25 +10,31 @@ 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 +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 ( return (
<> <>
<TopBar <TopBar
@@ -79,7 +98,22 @@ 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="contained"
color="info"
size="small"
onClick={onExportFlow}
data-test="export-flow-button"
>
{formatMessage('flowEditor.export')}
</Button>
)}
</Can>
<Can I="publish" a="Flow" passThrough> <Can I="publish" a="Flow" passThrough>
{(allowed) => ( {(allowed) => (
<Button <Button

View File

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

View File

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

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.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",

View File

@@ -9638,6 +9638,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"