feat(editor): Add readonly mode to new canvas (no-changelog) (#10133)

This commit is contained in:
Alex Grozav 2024-07-23 11:27:09 +03:00 committed by GitHub
parent a96db344e5
commit 6b8ad6fc3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 208 additions and 16 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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