feat(web): add exporting flow functionality
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user