mirror of
https://github.com/n8n-io/n8n.git
synced 2024-12-25 12:44:07 -08:00
feat(editor): Add readonly mode to new canvas (no-changelog) (#10133)
This commit is contained in:
parent
a96db344e5
commit
6b8ad6fc3e
|
@ -64,12 +64,20 @@ export function createCanvasNodeProps({
|
|||
id = 'node',
|
||||
label = 'Test Node',
|
||||
selected = false,
|
||||
readOnly = false,
|
||||
data = {},
|
||||
}: { id?: string; label?: string; selected?: boolean; data?: Partial<CanvasNodeData> } = {}) {
|
||||
}: {
|
||||
id?: string;
|
||||
label?: string;
|
||||
selected?: boolean;
|
||||
readOnly?: boolean;
|
||||
data?: Partial<CanvasNodeData>;
|
||||
} = {}) {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
selected,
|
||||
readOnly,
|
||||
data: createCanvasNodeData(data),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import { Controls } from '@vue-flow/controls';
|
|||
import { MiniMap } from '@vue-flow/minimap';
|
||||
import Node from './elements/nodes/CanvasNode.vue';
|
||||
import Edge from './elements/edges/CanvasEdge.vue';
|
||||
import { computed, onMounted, onUnmounted, ref, useCssModule } from 'vue';
|
||||
import { computed, onMounted, onUnmounted, ref, useCssModule, watch } from 'vue';
|
||||
import type { EventBus } from 'n8n-design-system';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { useContextMenu, type ContextMenuAction } from '@/composables/useContextMenu';
|
||||
|
@ -59,6 +59,7 @@ const props = withDefaults(
|
|||
connections: CanvasConnection[];
|
||||
controlsPosition?: PanelPosition;
|
||||
eventBus?: EventBus;
|
||||
readOnly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
id: 'canvas',
|
||||
|
@ -66,6 +67,7 @@ const props = withDefaults(
|
|||
connections: () => [],
|
||||
controlsPosition: PanelPosition.BottomLeft,
|
||||
eventBus: () => createEventBus(),
|
||||
readOnly: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -75,6 +77,8 @@ const {
|
|||
removeSelectedNodes,
|
||||
viewportRef,
|
||||
fitView,
|
||||
setInteractive,
|
||||
elementsSelectable,
|
||||
project,
|
||||
nodes: graphNodes,
|
||||
onPaneReady,
|
||||
|
@ -267,6 +271,11 @@ async function onFitView() {
|
|||
await fitView({ maxZoom: 1.2, padding: 0.1 });
|
||||
}
|
||||
|
||||
function setReadonly(value: boolean) {
|
||||
setInteractive(!value);
|
||||
elementsSelectable.value = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context menu
|
||||
*/
|
||||
|
@ -337,6 +346,10 @@ onPaneReady(async () => {
|
|||
await onFitView();
|
||||
paneReady.value = true;
|
||||
});
|
||||
|
||||
watch(() => props.readOnly, setReadonly, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -365,6 +378,7 @@ onPaneReady(async () => {
|
|||
<template #node-canvas-node="canvasNodeProps">
|
||||
<Node
|
||||
v-bind="canvasNodeProps"
|
||||
:read-only="readOnly"
|
||||
@delete="onDeleteNode"
|
||||
@run="onRunNode"
|
||||
@select="onSelectNode"
|
||||
|
@ -380,6 +394,7 @@ onPaneReady(async () => {
|
|||
<template #edge-canvas-edge="canvasEdgeProps">
|
||||
<Edge
|
||||
v-bind="canvasEdgeProps"
|
||||
:read-only="readOnly"
|
||||
:hovered="hoveredEdges[canvasEdgeProps.id]"
|
||||
@add="onClickConnectionAdd"
|
||||
@delete="onDeleteConnection"
|
||||
|
@ -394,6 +409,7 @@ onPaneReady(async () => {
|
|||
data-test-id="canvas-controls"
|
||||
:class="$style.canvasControls"
|
||||
:position="controlsPosition"
|
||||
:show-interactive="!readOnly"
|
||||
@fit-view="onFitView"
|
||||
></Controls>
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ const props = withDefaults(
|
|||
workflowObject: Workflow;
|
||||
fallbackNodes?: IWorkflowDb['nodes'];
|
||||
eventBus?: EventBus;
|
||||
readOnly?: boolean;
|
||||
}>(),
|
||||
{
|
||||
id: 'canvas',
|
||||
|
@ -49,6 +50,7 @@ const { nodes: mappedNodes, connections: mappedConnections } = useCanvasMapping(
|
|||
:nodes="mappedNodes"
|
||||
:connections="mappedConnections"
|
||||
:event-bus="eventBus"
|
||||
:read-only="readOnly"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -38,6 +38,26 @@ describe('CanvasEdge', () => {
|
|||
expect(emitted()).toHaveProperty('delete');
|
||||
});
|
||||
|
||||
it('should emit add event when toolbar add is clicked', async () => {
|
||||
const { emitted, getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('add-connection-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
expect(emitted()).toHaveProperty('add');
|
||||
});
|
||||
|
||||
it('should not render toolbar actions when readOnly', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
readOnly: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => getByTestId('add-connection-button')).toThrow();
|
||||
expect(() => getByTestId('delete-connection-button')).toThrow();
|
||||
});
|
||||
|
||||
it('should compute edgeStyle correctly', () => {
|
||||
const { container } = renderComponent();
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
export type CanvasEdgeProps = EdgeProps<CanvasConnectionData> & {
|
||||
readOnly?: boolean;
|
||||
hovered?: boolean;
|
||||
};
|
||||
|
||||
|
@ -51,7 +52,7 @@ const edgeStyle = computed(() => ({
|
|||
}));
|
||||
|
||||
const edgeLabel = computed(() => {
|
||||
if (isFocused.value) {
|
||||
if (isFocused.value && !props.readOnly) {
|
||||
return '';
|
||||
}
|
||||
|
||||
|
@ -117,7 +118,7 @@ function onDelete() {
|
|||
:label-style="edgeLabelStyle"
|
||||
:label-show-bg="false"
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<EdgeLabelRenderer v-if="!readOnly">
|
||||
<CanvasEdgeToolbar
|
||||
:type="connectionType"
|
||||
:class="edgeToolbarClasses"
|
||||
|
|
|
@ -91,6 +91,29 @@ describe('CanvasNode', () => {
|
|||
await fireEvent.mouseOver(node);
|
||||
|
||||
expect(getByTestId('canvas-node-toolbar')).toBeInTheDocument();
|
||||
expect(getByTestId('execute-node-button')).toBeInTheDocument();
|
||||
expect(getByTestId('disable-node-button')).toBeInTheDocument();
|
||||
expect(getByTestId('delete-node-button')).toBeInTheDocument();
|
||||
expect(getByTestId('overflow-node-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should contain only context menu when node is disabled', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...createCanvasNodeProps({
|
||||
readOnly: true,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const node = getByTestId('canvas-node');
|
||||
await fireEvent.mouseOver(node);
|
||||
|
||||
expect(getByTestId('canvas-node-toolbar')).toBeInTheDocument();
|
||||
expect(() => getByTestId('execute-node-button')).toThrow();
|
||||
expect(() => getByTestId('disable-node-button')).toThrow();
|
||||
expect(() => getByTestId('delete-node-button')).toThrow();
|
||||
expect(getByTestId('overflow-node-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,6 +17,10 @@ import { useContextMenu } from '@/composables/useContextMenu';
|
|||
import { Position } from '@vue-flow/core';
|
||||
import type { XYPosition, NodeProps } from '@vue-flow/core';
|
||||
|
||||
type Props = NodeProps<CanvasNodeData> & {
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
const emit = defineEmits<{
|
||||
add: [id: string, handle: string];
|
||||
delete: [id: string];
|
||||
|
@ -28,7 +32,8 @@ const emit = defineEmits<{
|
|||
update: [id: string, parameters: Record<string, unknown>];
|
||||
move: [id: string, position: XYPosition];
|
||||
}>();
|
||||
const props = defineProps<NodeProps<CanvasNodeData>>();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const nodeTypesStore = useNodeTypesStore();
|
||||
const contextMenu = useContextMenu();
|
||||
|
@ -248,6 +253,7 @@ watch(
|
|||
<CanvasNodeToolbar
|
||||
v-if="nodeTypeDescription"
|
||||
data-test-id="canvas-node-toolbar"
|
||||
:read-only="readOnly"
|
||||
:class="$style.canvasNodeToolbar"
|
||||
@delete="onDelete"
|
||||
@toggle="onDisabledToggle"
|
||||
|
@ -256,7 +262,7 @@ watch(
|
|||
/>
|
||||
|
||||
<CanvasNodeRenderer
|
||||
@dblclick="onActivate"
|
||||
@dblclick.stop="onActivate"
|
||||
@move="onMove"
|
||||
@update="onUpdate"
|
||||
@open:contextmenu="onOpenContextMenuFromNode"
|
||||
|
|
|
@ -11,6 +11,10 @@ const emit = defineEmits<{
|
|||
'open:contextmenu': [event: MouseEvent];
|
||||
}>();
|
||||
|
||||
const props = defineProps<{
|
||||
readOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const $style = useCssModule();
|
||||
const i18n = useI18n();
|
||||
|
||||
|
@ -22,8 +26,14 @@ const workflowRunning = false;
|
|||
// @TODO
|
||||
const nodeDisabledTitle = 'Test';
|
||||
|
||||
const classes = computed(() => ({
|
||||
[$style.canvasNodeToolbar]: true,
|
||||
[$style.readOnly]: props.readOnly,
|
||||
}));
|
||||
|
||||
const isExecuteNodeVisible = computed(() => {
|
||||
return (
|
||||
!props.readOnly &&
|
||||
render.value.type === CanvasNodeRenderType.Default &&
|
||||
'configuration' in render.value.options &&
|
||||
!render.value.options.configuration
|
||||
|
@ -31,9 +41,11 @@ const isExecuteNodeVisible = computed(() => {
|
|||
});
|
||||
|
||||
const isDisableNodeVisible = computed(() => {
|
||||
return render.value.type === CanvasNodeRenderType.Default;
|
||||
return !props.readOnly && render.value.type === CanvasNodeRenderType.Default;
|
||||
});
|
||||
|
||||
const isDeleteNodeVisible = computed(() => !props.readOnly);
|
||||
|
||||
function executeNode() {
|
||||
emit('run');
|
||||
}
|
||||
|
@ -52,7 +64,7 @@ function onOpenContextMenu(event: MouseEvent) {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.canvasNodeToolbar">
|
||||
<div :class="classes">
|
||||
<div :class="$style.canvasNodeToolbarItems">
|
||||
<N8nIconButton
|
||||
v-if="isExecuteNodeVisible"
|
||||
|
@ -76,6 +88,7 @@ function onOpenContextMenu(event: MouseEvent) {
|
|||
@click="onToggleNode"
|
||||
/>
|
||||
<N8nIconButton
|
||||
v-if="isDeleteNodeVisible"
|
||||
data-test-id="delete-node-button"
|
||||
type="tertiary"
|
||||
size="small"
|
||||
|
@ -99,6 +112,9 @@ function onOpenContextMenu(event: MouseEvent) {
|
|||
<style lang="scss" module>
|
||||
.canvasNodeToolbar {
|
||||
padding-bottom: var(--spacing-2xs);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.canvasNodeToolbarItems {
|
||||
|
|
|
@ -213,8 +213,11 @@ export function useCanvasMapping({
|
|||
const lastNodeExecuted = workflowExecution?.data?.resultData?.lastNodeExecuted;
|
||||
|
||||
if (workflowExecution && lastNodeExecuted && isExecutionSummary(workflowExecution)) {
|
||||
if (node.name === workflowExecution.data?.resultData?.lastNodeExecuted) {
|
||||
const waitDate = new Date(workflowExecution.waitTill as Date);
|
||||
if (
|
||||
node.name === workflowExecution.data?.resultData?.lastNodeExecuted &&
|
||||
workflowExecution.waitTill
|
||||
) {
|
||||
const waitDate = new Date(workflowExecution.waitTill);
|
||||
|
||||
if (waitDate.toISOString() === WAIT_TIME_UNLIMITED) {
|
||||
acc[node.id] = i18n.baseText(
|
||||
|
|
|
@ -19,6 +19,7 @@ import { useWorkflowsStore } from '@/stores/workflows.store';
|
|||
import { useRunWorkflow } from '@/composables/useRunWorkflow';
|
||||
import type {
|
||||
AddedNodesAndConnections,
|
||||
IExecutionResponse,
|
||||
INodeUi,
|
||||
IUpdateInformation,
|
||||
IWorkflowDataUpdate,
|
||||
|
@ -194,11 +195,11 @@ const isReadOnlyEnvironment = computed(() => {
|
|||
});
|
||||
|
||||
const isCanvasReadOnly = computed(() => {
|
||||
return isLoading.value || isDemoRoute.value || isReadOnlyEnvironment.value;
|
||||
return isDemoRoute.value || isReadOnlyEnvironment.value;
|
||||
});
|
||||
|
||||
const fallbackNodes = computed<INodeUi[]>(() =>
|
||||
isCanvasReadOnly.value
|
||||
isLoading.value || isCanvasReadOnly.value
|
||||
? []
|
||||
: [
|
||||
{
|
||||
|
@ -940,8 +941,101 @@ function trackRunWorkflowToNode(node: INodeUi) {
|
|||
void externalHooks.run('nodeView.onRunNode', telemetryPayload);
|
||||
}
|
||||
|
||||
async function openExecution(_executionId: string) {
|
||||
// @TODO
|
||||
async function openExecution(executionId: string) {
|
||||
canvasStore.startLoading();
|
||||
resetWorkspace();
|
||||
|
||||
let data: IExecutionResponse | undefined;
|
||||
try {
|
||||
data = await workflowsStore.getExecution(executionId);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('nodeView.showError.openExecution.title'));
|
||||
return;
|
||||
}
|
||||
if (data === undefined) {
|
||||
throw new Error(`Execution with id "${executionId}" could not be found!`);
|
||||
}
|
||||
|
||||
await initializeData();
|
||||
await initializeWorkspace(data.workflowData);
|
||||
workflowsStore.setWorkflowExecutionData(data);
|
||||
|
||||
uiStore.stateIsDirty = false;
|
||||
canvasStore.stopLoading();
|
||||
|
||||
fitView();
|
||||
|
||||
canvasEventBus.emit('open:execution', data);
|
||||
|
||||
void externalHooks.run('execution.open', {
|
||||
workflowId: data.workflowData.id,
|
||||
workflowName: data.workflowData.name,
|
||||
executionId,
|
||||
});
|
||||
|
||||
telemetry.track('User opened read-only execution', {
|
||||
workflow_id: data.workflowData.id,
|
||||
execution_mode: data.mode,
|
||||
execution_finished: data.finished,
|
||||
});
|
||||
}
|
||||
|
||||
function onExecutionOpenedWithError(data: IExecutionResponse) {
|
||||
if (!data.finished && data.data?.resultData?.error) {
|
||||
// Check if any node contains an error
|
||||
let nodeErrorFound = false;
|
||||
if (data.data.resultData.runData) {
|
||||
const runData = data.data.resultData.runData;
|
||||
errorCheck: for (const nodeName of Object.keys(runData)) {
|
||||
for (const taskData of runData[nodeName]) {
|
||||
if (taskData.error) {
|
||||
nodeErrorFound = true;
|
||||
break errorCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!nodeErrorFound &&
|
||||
(data.data.resultData.error.stack ?? data.data.resultData.error.message)
|
||||
) {
|
||||
// Display some more information for now in console to make debugging easier
|
||||
console.error(`Execution ${data.id} error:`);
|
||||
console.error(data.data.resultData.error.stack);
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('nodeView.showError.workflowError'),
|
||||
message: data.data.resultData.error.message,
|
||||
type: 'error',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onExecutionOpenedWithWaitTill(data: IExecutionResponse) {
|
||||
if ((data as ExecutionSummary).waitTill) {
|
||||
toast.showMessage({
|
||||
title: i18n.baseText('nodeView.thisExecutionHasntFinishedYet'),
|
||||
message: `<a data-action="reload">${i18n.baseText('nodeView.refresh')}</a> ${i18n.baseText(
|
||||
'nodeView.toSeeTheLatestStatus',
|
||||
)}.<br/> <a href="https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.wait/" target="_blank">${i18n.baseText(
|
||||
'nodeView.moreInfo',
|
||||
)}</a>`,
|
||||
type: 'warning',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addExecutionOpenedEventBindings() {
|
||||
canvasEventBus.on('open:execution', onExecutionOpenedWithError);
|
||||
canvasEventBus.on('open:execution', onExecutionOpenedWithWaitTill);
|
||||
}
|
||||
|
||||
function removeExecutionOpenedEventBindings() {
|
||||
canvasEventBus.off('open:execution', onExecutionOpenedWithError);
|
||||
canvasEventBus.off('open:execution', onExecutionOpenedWithWaitTill);
|
||||
}
|
||||
|
||||
async function onStopExecution() {
|
||||
|
@ -1353,6 +1447,7 @@ onMounted(async () => {
|
|||
addPostMessageEventBindings();
|
||||
addSourceControlEventBindings();
|
||||
addImportEventBindings();
|
||||
addExecutionOpenedEventBindings();
|
||||
addWorkflowSavedEventBindings();
|
||||
|
||||
registerCustomActions();
|
||||
|
@ -1366,6 +1461,7 @@ onBeforeUnmount(() => {
|
|||
removePostMessageEventBindings();
|
||||
removeSourceControlEventBindings();
|
||||
removeImportEventBindings();
|
||||
removeExecutionOpenedEventBindings();
|
||||
removeWorkflowSavedEventBindings();
|
||||
});
|
||||
</script>
|
||||
|
@ -1377,6 +1473,7 @@ onBeforeUnmount(() => {
|
|||
:workflow-object="editableWorkflowObject"
|
||||
:fallback-nodes="fallbackNodes"
|
||||
:event-bus="canvasEventBus"
|
||||
:read-only="isCanvasReadOnly"
|
||||
@update:nodes:position="onUpdateNodesPosition"
|
||||
@update:node:position="onUpdateNodePosition"
|
||||
@update:node:active="onSetNodeActive"
|
||||
|
@ -1429,7 +1526,7 @@ onBeforeUnmount(() => {
|
|||
</div>
|
||||
<Suspense>
|
||||
<NodeCreation
|
||||
v-if="!isReadOnlyRoute && !isReadOnlyEnvironment"
|
||||
v-if="!isCanvasReadOnly"
|
||||
:create-node-active="uiStore.isCreateNodeActive"
|
||||
:node-view-scale="1"
|
||||
@toggle-node-creator="onOpenNodeCreator"
|
||||
|
@ -1439,7 +1536,7 @@ onBeforeUnmount(() => {
|
|||
<Suspense>
|
||||
<NodeDetailsView
|
||||
:workflow-object="editableWorkflowObject"
|
||||
:read-only="isReadOnlyRoute || isReadOnlyEnvironment"
|
||||
:read-only="isCanvasReadOnly"
|
||||
:is-production-execution-preview="isProductionExecutionPreview"
|
||||
:renaming="false"
|
||||
@value-changed="onRenameNode"
|
||||
|
|
Loading…
Reference in a new issue