seitime-frappe/frappe/public/js/workflow_builder/WorkflowBuilder.vue
Fisher Yu 41a7b42f16
fix: Translate form and workflow builder (#25482)
* Update AttachControl.vue

* Update ButtonControl.vue

* Update CheckControl.vue

* Update CodeControl.vue

* Update DataControl.vue

* Update ImageControl.vue

* Update LinkControl.vue

* Update RatingControl.vue

* Update SelectControl.vue

* Update SignatureControl.vue

* Update TableControl.vue

* Update TextControl.vue

* Update TextEditorControl.vue

* Update Section.vue

* Update Column.vue

* Update Tabs.vue

* Update Field.vue

* Update Sidebar.vue

* Update AddFieldButton.vue

* Update AddFieldButton.vue

* Update Section.vue

* Update WorkflowBuilder.vue

* Update Autocomplete.vue

* Update EditableInput.vue

* Update AttachControl.vue

* Update ButtonControl.vue

* Update CheckControl.vue

* Update CodeControl.vue

* Update DataControl.vue

* Update ImageControl.vue

* Update LinkControl.vue

* Update RatingControl.vue

* Update SelectControl.vue

* Update SignatureControl.vue

* Update TextControl.vue

* Update TextEditorControl.vue

* Update Field.vue

* Update EditableInput.vue

* Update TableControl.vue

* Update Column.vue

* fix: variable in translatable string

* fix: add context for row number label

* fix: translate labels in workflow builder

* style: formatting

---------

Co-authored-by: barredterra <14891507+barredterra@users.noreply.github.com>
Co-authored-by: Ankush Menat <ankush@frappe.io>
2024-03-18 18:41:50 +05:30

371 lines
8.9 KiB
Vue

<script setup>
import { VueFlow, useVueFlow, Panel, PanelPosition } from "@vue-flow/core";
import { Background } from "@vue-flow/background";
import TransitionEdge from "./components/TransitionEdge.vue";
import StateNode from "./components/StateNode.vue";
import ActionNode from "./components/ActionNode.vue";
import ConnectionLine from "./components/ConnectionLine.vue";
import Sidebar from "./components/Sidebar.vue";
import { useStore } from "./store";
import { validate_transitions } from "./utils";
import { ref, computed, nextTick, onMounted, watch } from "vue";
import { onClickOutside, useMagicKeys, whenever, useActiveElement } from "@vueuse/core";
let store = useStore();
const {
nodes,
getEdges,
getSelectedNodes,
findNode,
onNodeDragStop,
onConnect,
onEdgeUpdate,
onEdgeUpdateEnd,
addNodes,
addEdges,
setEdges,
updateEdge,
removeNodes,
endConnection,
onPaneReady,
fitView,
zoomIn,
zoomOut,
project,
vueFlowRef,
} = useVueFlow();
let main = ref(null);
onClickOutside(main, loose_focus);
// cmd/ctrl + s to save the form
const { meta_s, ctrl_s, Backspace, meta_backspace, ctrl_backspace } = useMagicKeys();
whenever(
() => meta_s.value || ctrl_s.value,
() => {
store.save_changes();
}
);
const activeElement = useActiveElement();
const notUsingInput = computed(
() => activeElement.value?.tagName !== "INPUT" && activeElement.value?.tagName !== "TEXTAREA"
);
whenever(
() => Backspace.value || meta_backspace.value || ctrl_backspace.value,
() => {
if (meta_backspace.value || ctrl_backspace.value) return;
if (store.workflow.selected) {
if (
notUsingInput.value &&
(store.workflow.selected.type === "state" ||
store.workflow.selected.type === "action")
) {
removeNodes([store.workflow.selected.id]);
if (store.workflow.selected.data?.state) {
let connected_nodes = [];
connected_nodes = nodes.value
.filter(
(node) =>
node.data.from_id == store.workflow.selected.id ||
node.data.to_id == store.workflow.selected.id
)
.map((node) => node.id);
removeNodes(connected_nodes);
}
store.workflow.selected = null;
nextTick(() => store.ref_history.commit());
}
}
}
);
onNodeDragStop(() => {
nextTick(() => store.ref_history.commit());
});
onConnect((edge) => {
let source_node = findNode(edge.source);
let target_node = findNode(edge.target);
let error = validate_transitions(source_node.data, target_node.data);
if (error) {
endConnection();
nextTick(() =>
frappe.throw({
title: "Invalid Transition",
message: error,
})
);
return;
}
let source_center = {
x: source_node.position.x + source_node.dimensions.width / 2,
y: source_node.position.y + source_node.dimensions.height / 2,
};
let target_center = {
x: target_node.position.x + target_node.dimensions.width / 2,
y: target_node.position.y + target_node.dimensions.height / 2,
};
let center_x = (source_center.x + target_center.x) / 2;
let center_y = source_center.y;
let action_ids = nodes.value
.filter((node) => node.type == "action")
.map((node) => parseInt(node.id.replace("action-", "")));
let action_id = action_ids.length ? (Math.max(...action_ids) + 1).toString() : "1";
const action_node = {
id: "action-" + action_id,
type: "action",
position: { x: center_x, y: center_y },
selected: true,
data: {
action: "",
allowed: "All",
allow_self_approval: 1,
from: source_node.data.state,
to: target_node.data.state,
from_id: source_node.id,
to_id: target_node.id,
},
};
addNodes([action_node]);
let action_edge = {
source: edge.source,
sourceHandle: edge.sourceHandle,
target: action_node.id,
targetHandle: "left",
type: "transition",
updatable: true,
animated: true,
};
let state_edge = {
source: action_node.id,
sourceHandle: "right",
target: edge.target,
targetHandle: edge.targetHandle,
type: "transition",
updatable: true,
animated: true,
};
addEdges([action_edge, state_edge]);
nextTick(() => {
const node = findNode(action_node.id);
const stop = watch(
() => node.dimensions,
(dimensions) => {
if (dimensions.width > 0 && dimensions.height > 0) {
node.position = {
x: node.position.x - node.dimensions.width / 2,
y: node.position.y - node.dimensions.height / 2,
};
stop();
node.selected = true;
store.workflow.selected = node;
store.ref_history.commit();
}
},
{ deep: true, flush: "post" }
);
});
});
onEdgeUpdateEnd(({ edge }) => {
getSelectedNodes.value?.forEach((node) => (node.selected = false));
if (edge.source.startsWith("action-")) {
setTimeout(() => (findNode(edge.source).selected = true));
} else if (edge.target.startsWith("action-")) {
setTimeout(() => (findNode(edge.target).selected = true));
}
});
onEdgeUpdate(({ edge, connection }) => {
if (
(connection.source == edge.source && connection.target != edge.target) ||
(connection.source != edge.source && connection.target == edge.target) ||
connection.source === connection.target
)
return;
updateEdge(edge, connection);
setEdges(getEdges.value);
nextTick(() => store.ref_history.commit());
});
function onDragOver(event) {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
}
function onDrop(event) {
const { left, top } = vueFlowRef.value.getBoundingClientRect();
getSelectedNodes.value?.forEach((node) => (node.selected = false));
const position = project({
x: event.clientX - left,
y: event.clientY - top,
});
let state_ids = nodes.value.filter((node) => node.type == "state").map((node) => node.id);
let state_id = state_ids.length ? (Math.max(...state_ids) + 1).toString() : "1";
const new_state = {
id: state_id,
type: "state",
position,
selected: true,
data: {
state: "",
doc_status: "Draft",
allow_edit: "All",
},
};
addNodes([new_state]);
nextTick(() => {
const node = findNode(new_state.id);
const stop = watch(
() => node.dimensions,
(dimensions) => {
if (dimensions.width > 0 && dimensions.height > 0) {
node.position = {
x: node.position.x - node.dimensions.width / 2,
y: node.position.y - node.dimensions.height / 2,
};
stop();
store.workflow.selected = node;
store.ref_history.commit();
}
},
{ deep: true, flush: "post" }
);
});
}
function onDragStart(event) {
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
}
loose_focus();
}
function loose_focus() {
if (store.workflow.selected) {
getSelectedNodes.value?.forEach((node) => (node.selected = false));
store.workflow.selected = null;
store.ref_history.commit();
}
}
onPaneReady(() => fitView());
onMounted(() => store.fetch());
</script>
<template>
<div class="main" ref="main">
<div class="sidebar-container" @click.stop>
<Sidebar />
</div>
<div class="workflow-container" @drop="onDrop" @click.stop="loose_focus">
<VueFlow
v-model="store.workflow.elements"
connection-mode="loose"
@dragover="onDragOver"
:delete-key-code="null"
>
<Background pattern-color="#aaa" gap="10" />
<Panel :position="PanelPosition.TopRight">
<div class="empty-state">
<div
class="btn btn-md drag-handle"
:draggable="true"
@dragstart="onDragStart"
>
{{ __("Drag to add state") }}
</div>
</div>
</Panel>
<Panel :position="PanelPosition.BottomLeft">
<button class="btn btn-sm btn-default mr-2" @click="zoomIn">+</button>
<button class="btn btn-sm btn-default mr-2" @click="zoomOut">-</button>
<button class="btn btn-sm btn-default" @click="fitView()">
{{ __("Fit") }}
</button>
</Panel>
<template #node-state="node">
<StateNode :node="node" />
</template>
<template #node-action="node">
<ActionNode :node="node" />
</template>
<template #edge-transition="props">
<TransitionEdge v-bind="props" />
</template>
<template #connection-line="props">
<ConnectionLine v-bind="props" />
</template>
</VueFlow>
</div>
</div>
</template>
<style lang="scss" scoped>
@import "@vue-flow/core/dist/style.css";
@import "@vue-flow/core/dist/theme-default.css";
.main {
display: flex;
flex-direction: row;
height: calc(100vh - var(--navbar-height) - var(--page-head-height) - 65px);
&.resizing {
user-select: none;
cursor: col-resize;
}
.sidebar-container {
position: relative;
height: 100%;
margin-right: 10px;
border-radius: var(--border-radius-lg);
border: 1px solid var(--border-color);
background-color: var(--fg-color);
}
}
.workflow-container {
width: 100%;
height: calc(100vh - var(--navbar-height) - var(--page-head-height) - 65px);
border-radius: var(--border-radius-lg);
border: 1px solid var(--border-color);
background-color: var(--fg-color);
}
.drag-handle {
background-color: var(--fg-color);
cursor: grab !important;
}
:deep(.transition-edge) {
stroke: var(--gray-600);
stroke-width: 1.5px;
}
:deep(.selected) {
.transition-edge {
stroke: var(--primary);
stroke-width: 2px;
}
}
</style>