mirror of
https://github.com/n8n-io/n8n.git
synced 2024-11-14 08:34:07 -08:00
feat: add diff status and fix bugs
This commit is contained in:
parent
0a15e85d63
commit
bbb9e2fab4
|
@ -10,6 +10,7 @@ import WorkflowPreview from '@/components/WorkflowPreview.vue';
|
||||||
import WorkflowHistoryListItem from '@/components/WorkflowHistory/WorkflowHistoryListItem.vue';
|
import WorkflowHistoryListItem from '@/components/WorkflowHistory/WorkflowHistoryListItem.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { compareWorkflows } from '@/utils/workflowDiff';
|
import { compareWorkflows } from '@/utils/workflowDiff';
|
||||||
|
import { WORKFLOW_HISTORY_ACTIONS } from '@/constants';
|
||||||
|
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
@ -27,7 +28,7 @@ const emit = defineEmits<{
|
||||||
value: {
|
value: {
|
||||||
action: WorkflowHistoryActionType;
|
action: WorkflowHistoryActionType;
|
||||||
id: WorkflowVersionId;
|
id: WorkflowVersionId;
|
||||||
data: { formattedCreatedAt: string };
|
data?: { formattedCreatedAt: string };
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}>();
|
}>();
|
||||||
|
@ -83,67 +84,120 @@ const onAction = ({
|
||||||
}) => {
|
}) => {
|
||||||
emit('action', { action, id, data });
|
emit('action', { action, id, data });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function onClickCloseDiff() {
|
||||||
|
emit('action', {
|
||||||
|
action: WORKFLOW_HISTORY_ACTIONS.CLOSEDIFF,
|
||||||
|
id: props.workflowVersion?.versionId ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.content">
|
<div :class="$style.content">
|
||||||
<div :class="$style.splitView">
|
<div :class="$style.splitView">
|
||||||
<WorkflowPreview
|
<div v-if="props.workflowVersion" :class="$style.splitViewPanel">
|
||||||
v-if="props.workflowVersion"
|
<WorkflowPreview
|
||||||
:workflow="workflowVersionPreview"
|
:workflow="workflowVersionPreview"
|
||||||
:diff="workflowComparison"
|
:diff="workflowComparison"
|
||||||
:loading="props.isListLoading"
|
:loading="props.isListLoading"
|
||||||
loader-type="spinner"
|
loader-type="spinner"
|
||||||
/>
|
/>
|
||||||
<WorkflowPreview
|
<div :class="$style.info">
|
||||||
v-if="props.workflowDiff"
|
<WorkflowHistoryListItem
|
||||||
:workflow="workflowDiffPreview"
|
:class="$style.card"
|
||||||
:loading="props.isListLoading"
|
:index="-1"
|
||||||
loader-type="spinner"
|
:item="props.workflowVersion"
|
||||||
/>
|
:is-active="false"
|
||||||
|
:actions="actions"
|
||||||
|
@action="onAction"
|
||||||
|
>
|
||||||
|
<template #default="{ formattedCreatedAt }">
|
||||||
|
<section :class="$style.text">
|
||||||
|
<p>
|
||||||
|
<span :class="$style.label">
|
||||||
|
{{ i18n.baseText('workflowHistory.content.title') }}:
|
||||||
|
</span>
|
||||||
|
<time :datetime="props.workflowVersion.createdAt">{{ formattedCreatedAt }}</time>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span :class="$style.label">
|
||||||
|
{{ i18n.baseText('workflowHistory.content.editedBy') }}:
|
||||||
|
</span>
|
||||||
|
<span>{{ props.workflowVersion.authors }}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span :class="$style.label">
|
||||||
|
{{ i18n.baseText('workflowHistory.content.versionId') }}:
|
||||||
|
</span>
|
||||||
|
<data :value="props.workflowVersion.versionId">{{
|
||||||
|
props.workflowVersion.versionId
|
||||||
|
}}</data>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<template #action-toggle-button>
|
||||||
|
<n8n-button type="tertiary" size="large" data-test-id="action-toggle-button">
|
||||||
|
{{ i18n.baseText('workflowHistory.content.actions') }}
|
||||||
|
<n8n-icon class="ml-3xs" icon="chevron-down" size="small" />
|
||||||
|
</n8n-button>
|
||||||
|
</template>
|
||||||
|
</WorkflowHistoryListItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="props.workflowDiff" :class="$style.splitViewPanel">
|
||||||
|
<WorkflowPreview
|
||||||
|
:workflow="workflowDiffPreview"
|
||||||
|
:diff="workflowComparison"
|
||||||
|
:loading="props.isListLoading"
|
||||||
|
loader-type="spinner"
|
||||||
|
/>
|
||||||
|
<div :class="$style.info">
|
||||||
|
<WorkflowHistoryListItem
|
||||||
|
:class="$style.card"
|
||||||
|
:index="-1"
|
||||||
|
:item="props.workflowDiff"
|
||||||
|
:is-active="false"
|
||||||
|
>
|
||||||
|
<template #default="{ formattedCreatedAt }">
|
||||||
|
<section :class="$style.text">
|
||||||
|
<p>
|
||||||
|
<span :class="$style.label">
|
||||||
|
{{ i18n.baseText('workflowHistory.content.title') }}:
|
||||||
|
</span>
|
||||||
|
<time :datetime="props.workflowDiff.createdAt">{{ formattedCreatedAt }}</time>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span :class="$style.label">
|
||||||
|
{{ i18n.baseText('workflowHistory.content.editedBy') }}:
|
||||||
|
</span>
|
||||||
|
<span>{{ props.workflowDiff.authors }}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span :class="$style.label">
|
||||||
|
{{ i18n.baseText('workflowHistory.content.versionId') }}:
|
||||||
|
</span>
|
||||||
|
<data :value="props.workflowDiff.versionId">{{
|
||||||
|
props.workflowDiff.versionId
|
||||||
|
}}</data>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<template #button>
|
||||||
|
<n8n-button
|
||||||
|
type="tertiary"
|
||||||
|
size="large"
|
||||||
|
data-test-id="close-diff-button"
|
||||||
|
@click="onClickCloseDiff"
|
||||||
|
>
|
||||||
|
{{ i18n.baseText('workflowHistory.item.actions.closediff') }}
|
||||||
|
<n8n-icon class="ml-3xs" icon="times" size="small" />
|
||||||
|
</n8n-button>
|
||||||
|
</template>
|
||||||
|
</WorkflowHistoryListItem>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul :class="$style.info">
|
|
||||||
<WorkflowHistoryListItem
|
|
||||||
v-if="props.workflowVersion"
|
|
||||||
:class="$style.card"
|
|
||||||
:index="-1"
|
|
||||||
:item="props.workflowVersion"
|
|
||||||
:is-active="false"
|
|
||||||
:actions="actions"
|
|
||||||
@action="onAction"
|
|
||||||
>
|
|
||||||
<template #default="{ formattedCreatedAt }">
|
|
||||||
<section :class="$style.text">
|
|
||||||
<p>
|
|
||||||
<span :class="$style.label">
|
|
||||||
{{ i18n.baseText('workflowHistory.content.title') }}:
|
|
||||||
</span>
|
|
||||||
<time :datetime="props.workflowVersion.createdAt">{{ formattedCreatedAt }}</time>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span :class="$style.label">
|
|
||||||
{{ i18n.baseText('workflowHistory.content.editedBy') }}:
|
|
||||||
</span>
|
|
||||||
<span>{{ props.workflowVersion.authors }}</span>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span :class="$style.label">
|
|
||||||
{{ i18n.baseText('workflowHistory.content.versionId') }}:
|
|
||||||
</span>
|
|
||||||
<data :value="props.workflowVersion.versionId">{{
|
|
||||||
props.workflowVersion.versionId
|
|
||||||
}}</data>
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
<template #action-toggle-button>
|
|
||||||
<n8n-button type="tertiary" size="large" data-test-id="action-toggle-button">
|
|
||||||
{{ i18n.baseText('workflowHistory.content.actions') }}
|
|
||||||
<n8n-icon class="ml-3xs" icon="chevron-down" size="small" />
|
|
||||||
</n8n-button>
|
|
||||||
</template>
|
|
||||||
</WorkflowHistoryListItem>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -164,8 +218,15 @@ const onAction = ({
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
> *:first-child {
|
.splitViewPanel {
|
||||||
border-right: 1px double var(--color-foreground-base);
|
flex: 1 0 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-right: 1px double var(--color-foreground-base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ import { useI18n } from '@/composables/useI18n';
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
item: WorkflowHistory;
|
item: WorkflowHistory;
|
||||||
index: number;
|
index: number;
|
||||||
actions: UserAction[];
|
actions?: UserAction[];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -116,6 +116,7 @@ onMounted(() => {
|
||||||
{{ i18n.baseText('workflowHistory.item.latest') }}
|
{{ i18n.baseText('workflowHistory.item.latest') }}
|
||||||
</n8n-badge>
|
</n8n-badge>
|
||||||
<n8n-action-toggle
|
<n8n-action-toggle
|
||||||
|
v-if="props.actions"
|
||||||
theme="dark"
|
theme="dark"
|
||||||
:class="$style.actions"
|
:class="$style.actions"
|
||||||
:actions="props.actions"
|
:actions="props.actions"
|
||||||
|
@ -126,6 +127,7 @@ onMounted(() => {
|
||||||
>
|
>
|
||||||
<slot name="action-toggle-button" />
|
<slot name="action-toggle-button" />
|
||||||
</n8n-action-toggle>
|
</n8n-action-toggle>
|
||||||
|
<slot name="button" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -78,15 +78,7 @@ const loadWorkflow = () => {
|
||||||
'*',
|
'*',
|
||||||
);
|
);
|
||||||
|
|
||||||
if (props.diff) {
|
loadDiff();
|
||||||
iframeRef.value?.contentWindow?.postMessage?.(
|
|
||||||
JSON.stringify({
|
|
||||||
command: 'setDiff',
|
|
||||||
diff: props.diff,
|
|
||||||
}),
|
|
||||||
'*',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(
|
toast.showError(
|
||||||
error,
|
error,
|
||||||
|
@ -96,6 +88,45 @@ const loadWorkflow = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadDiff = () => {
|
||||||
|
if (props.diff) {
|
||||||
|
iframeRef.value?.contentWindow?.postMessage?.(
|
||||||
|
JSON.stringify({
|
||||||
|
command: 'setDiff',
|
||||||
|
diff: props.diff,
|
||||||
|
}),
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
|
||||||
|
fitView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unloadDiff = () => {
|
||||||
|
iframeRef.value?.contentWindow?.postMessage?.(
|
||||||
|
JSON.stringify({
|
||||||
|
command: 'setDiff',
|
||||||
|
diff: {},
|
||||||
|
}),
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
|
||||||
|
fitView();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitView = () => {
|
||||||
|
if (!ready.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
iframeRef.value?.contentWindow?.postMessage?.(
|
||||||
|
JSON.stringify({
|
||||||
|
command: 'fitView',
|
||||||
|
}),
|
||||||
|
'*',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const loadExecution = () => {
|
const loadExecution = () => {
|
||||||
try {
|
try {
|
||||||
if (!props.executionId) {
|
if (!props.executionId) {
|
||||||
|
@ -203,6 +234,17 @@ watch(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.diff,
|
||||||
|
(value) => {
|
||||||
|
if (value) {
|
||||||
|
loadDiff();
|
||||||
|
} else {
|
||||||
|
unloadDiff();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||||
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
|
import { NODE_INSERT_SPACER_BETWEEN_INPUT_GROUPS } from '@/constants';
|
||||||
import { N8nTooltip } from 'n8n-design-system';
|
import { N8nTooltip } from 'n8n-design-system';
|
||||||
import type { CanvasNodeDefaultRender } from '@/types';
|
import type { CanvasNodeDefaultRender } from '@/types';
|
||||||
|
import { capitalize } from 'lodash-es';
|
||||||
|
|
||||||
const $style = useCssModule();
|
const $style = useCssModule();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -30,6 +31,7 @@ const {
|
||||||
executionRunning,
|
executionRunning,
|
||||||
hasRunData,
|
hasRunData,
|
||||||
hasIssues,
|
hasIssues,
|
||||||
|
diff,
|
||||||
render,
|
render,
|
||||||
} = useCanvasNode();
|
} = useCanvasNode();
|
||||||
const {
|
const {
|
||||||
|
@ -60,6 +62,7 @@ const classes = computed(() => {
|
||||||
[$style.configurable]: renderOptions.value.configurable,
|
[$style.configurable]: renderOptions.value.configurable,
|
||||||
[$style.configuration]: renderOptions.value.configuration,
|
[$style.configuration]: renderOptions.value.configuration,
|
||||||
[$style.trigger]: renderOptions.value.trigger,
|
[$style.trigger]: renderOptions.value.trigger,
|
||||||
|
...(diff.value ? { [$style[`diff${capitalize(diff.value.status)}`]]: true } : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -121,6 +124,7 @@ function openContextMenu(event: MouseEvent) {
|
||||||
<FontAwesomeIcon icon="bolt" size="lg" />
|
<FontAwesomeIcon icon="bolt" size="lg" />
|
||||||
</div>
|
</div>
|
||||||
</N8nTooltip>
|
</N8nTooltip>
|
||||||
|
<CanvasNodeDiffStatus v-if="diff" :class="$style.diffStatus" />
|
||||||
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
|
<CanvasNodeStatusIcons v-if="!isDisabled" :class="$style.statusIcons" />
|
||||||
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
|
<CanvasNodeDisabledStrikeThrough v-if="isStrikethroughVisible" />
|
||||||
<div :class="$style.description">
|
<div :class="$style.description">
|
||||||
|
@ -220,6 +224,28 @@ function openContextMenu(event: MouseEvent) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff classes
|
||||||
|
*/
|
||||||
|
|
||||||
|
&.diffAdded {
|
||||||
|
border-color: green;
|
||||||
|
border-style: dashed;
|
||||||
|
box-shadow: 0 0 var(--spacing-xs) 0 rgba(0, 255, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.diffDeleted {
|
||||||
|
border-color: red;
|
||||||
|
border-style: dashed;
|
||||||
|
box-shadow: 0 0 var(--spacing-xs) 0 rgba(255, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.diffModified {
|
||||||
|
border-color: orange;
|
||||||
|
border-style: dashed;
|
||||||
|
box-shadow: 0 0 var(--spacing-xs) 0 rgba(255, 153, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State classes
|
* State classes
|
||||||
* The reverse order defines the priority in case multiple states are active
|
* The reverse order defines the priority in case multiple states are active
|
||||||
|
@ -293,6 +319,13 @@ function openContextMenu(event: MouseEvent) {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diffStatus {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
.statusIcons {
|
.statusIcons {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: var(--canvas-node--status-icons-offset);
|
bottom: var(--canvas-node--status-icons-offset);
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, useCssModule } from 'vue';
|
||||||
|
import { useCanvasNode } from '@/composables/useCanvasNode';
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { NodeDiffStatus } from '@/types';
|
||||||
|
|
||||||
|
const { diff } = useCanvasNode();
|
||||||
|
|
||||||
|
const $style = useCssModule();
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const classes = computed(() => ({
|
||||||
|
[$style.diffStatus]: true,
|
||||||
|
[$style[diff.value?.status]]: diff.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const label = computed(() => (diff.value ? i18n.baseText(`diffStatus.${diff.value.status}`) : ''));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<N8nBadge v-if="diff?.status !== NodeDiffStatus.Eq" :class="classes">{{ label }}</N8nBadge>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.diffStatus {
|
||||||
|
color: var(--prim-color-white);
|
||||||
|
|
||||||
|
:global(.n8n-text) {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.added {
|
||||||
|
background: var(--color-success);
|
||||||
|
border-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modified {
|
||||||
|
background: var(--color-warning);
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deleted {
|
||||||
|
background: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -380,6 +380,8 @@ export function useCanvasMapping({
|
||||||
}, {}),
|
}, {}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const nodeDiffById = computed(() => workflowsStore.workflowDiff || {});
|
||||||
|
|
||||||
const additionalNodePropertiesById = computed(() => {
|
const additionalNodePropertiesById = computed(() => {
|
||||||
type StickyNoteBoundingBox = BoundingBox & {
|
type StickyNoteBoundingBox = BoundingBox & {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -459,6 +461,7 @@ export function useCanvasMapping({
|
||||||
disabled: node.disabled,
|
disabled: node.disabled,
|
||||||
inputs: nodeInputsById.value[node.id] ?? [],
|
inputs: nodeInputsById.value[node.id] ?? [],
|
||||||
outputs: nodeOutputsById.value[node.id] ?? [],
|
outputs: nodeOutputsById.value[node.id] ?? [],
|
||||||
|
diff: nodeDiffById.value[node.id],
|
||||||
connections: {
|
connections: {
|
||||||
[CanvasConnectionMode.Input]: inputConnections,
|
[CanvasConnectionMode.Input]: inputConnections,
|
||||||
[CanvasConnectionMode.Output]: outputConnections,
|
[CanvasConnectionMode.Output]: outputConnections,
|
||||||
|
|
|
@ -22,6 +22,7 @@ export function useCanvasNode() {
|
||||||
disabled: false,
|
disabled: false,
|
||||||
inputs: [],
|
inputs: [],
|
||||||
outputs: [],
|
outputs: [],
|
||||||
|
diff: undefined,
|
||||||
connections: { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} },
|
connections: { [CanvasConnectionMode.Input]: {}, [CanvasConnectionMode.Output]: {} },
|
||||||
issues: { items: [], visible: false },
|
issues: { items: [], visible: false },
|
||||||
pinnedData: { count: 0, visible: false },
|
pinnedData: { count: 0, visible: false },
|
||||||
|
@ -63,6 +64,8 @@ export function useCanvasNode() {
|
||||||
const runDataIterations = computed(() => data.value.runData.iterations);
|
const runDataIterations = computed(() => data.value.runData.iterations);
|
||||||
const hasRunData = computed(() => data.value.runData.visible);
|
const hasRunData = computed(() => data.value.runData.visible);
|
||||||
|
|
||||||
|
const diff = computed(() => data.value.diff);
|
||||||
|
|
||||||
const render = computed(() => data.value.render);
|
const render = computed(() => data.value.render);
|
||||||
|
|
||||||
const eventBus = computed(() => node?.eventBus.value);
|
const eventBus = computed(() => node?.eventBus.value);
|
||||||
|
@ -89,6 +92,7 @@ export function useCanvasNode() {
|
||||||
executionStatus,
|
executionStatus,
|
||||||
executionWaiting,
|
executionWaiting,
|
||||||
executionRunning,
|
executionRunning,
|
||||||
|
diff,
|
||||||
render,
|
render,
|
||||||
eventBus,
|
eventBus,
|
||||||
};
|
};
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type {
|
||||||
CanvasNodeInjectionData,
|
CanvasNodeInjectionData,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
import type { InjectionKey } from 'vue';
|
import type { InjectionKey } from 'vue';
|
||||||
|
import type { WorkflowHistoryActionType } from '@/types/workflowHistory';
|
||||||
|
|
||||||
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
|
export const MAX_WORKFLOW_SIZE = 1024 * 1024 * 16; // Workflow size limit in bytes
|
||||||
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
|
export const MAX_EXPECTED_REQUEST_SIZE = 2048; // Expected maximum workflow request metadata (i.e. headers) size in bytes
|
||||||
|
@ -878,3 +879,23 @@ export const CanvasNodeHandleKey =
|
||||||
export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
|
export const BROWSER_ID_STORAGE_KEY = 'n8n-browserId';
|
||||||
|
|
||||||
export const APP_MODALS_ELEMENT_ID = 'app-modals';
|
export const APP_MODALS_ELEMENT_ID = 'app-modals';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workflow History
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const workflowHistoryActionTypes: WorkflowHistoryActionType[] = [
|
||||||
|
'showdiff',
|
||||||
|
'closediff',
|
||||||
|
'restore',
|
||||||
|
'clone',
|
||||||
|
'open',
|
||||||
|
'download',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const WORKFLOW_HISTORY_ACTIONS = workflowHistoryActionTypes.reduce(
|
||||||
|
(record, key) => ({ ...record, [key.toUpperCase()]: key }),
|
||||||
|
{} as {
|
||||||
|
[K in Uppercase<WorkflowHistoryActionType>]: Lowercase<K>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
@ -2202,7 +2202,8 @@
|
||||||
"workflowHistory.item.actions.clone": "Clone to new workflow",
|
"workflowHistory.item.actions.clone": "Clone to new workflow",
|
||||||
"workflowHistory.item.actions.open": "Open version in new tab",
|
"workflowHistory.item.actions.open": "Open version in new tab",
|
||||||
"workflowHistory.item.actions.download": "Download",
|
"workflowHistory.item.actions.download": "Download",
|
||||||
"workflowHistory.item.actions.diff": "Show workflow diff",
|
"workflowHistory.item.actions.showdiff": "Show workflow diff",
|
||||||
|
"workflowHistory.item.actions.closediff": "Close diff",
|
||||||
"workflowHistory.item.unsaved.title": "Unsaved version",
|
"workflowHistory.item.unsaved.title": "Unsaved version",
|
||||||
"workflowHistory.item.latest": "Latest saved",
|
"workflowHistory.item.latest": "Latest saved",
|
||||||
"workflowHistory.empty": "No versions yet.",
|
"workflowHistory.empty": "No versions yet.",
|
||||||
|
@ -2224,6 +2225,10 @@
|
||||||
"workflowHistory.button.tooltip.enabled": "Workflow history to view and restore previous versions of your workflows",
|
"workflowHistory.button.tooltip.enabled": "Workflow history to view and restore previous versions of your workflows",
|
||||||
"workflowHistory.button.tooltip.disabled": "Upgrade to unlock workflow history to view and restore previous versions of your workflows. {link}",
|
"workflowHistory.button.tooltip.disabled": "Upgrade to unlock workflow history to view and restore previous versions of your workflows. {link}",
|
||||||
"workflowHistory.button.tooltip.disabled.link": "View plans",
|
"workflowHistory.button.tooltip.disabled.link": "View plans",
|
||||||
|
"diffStatus.added": "Added",
|
||||||
|
"diffStatus.modified": "Modified",
|
||||||
|
"diffStatus.deleted": "Deleted",
|
||||||
|
"diffStatus.equal": "Equal",
|
||||||
"workflows.heading": "Workflows",
|
"workflows.heading": "Workflows",
|
||||||
"workflows.add": "Add workflow",
|
"workflows.add": "Add workflow",
|
||||||
"workflows.project.add": "Add workflow to project",
|
"workflows.project.add": "Add workflow to project",
|
||||||
|
|
|
@ -554,6 +554,8 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||||
activeExecutionId.value = null;
|
activeExecutionId.value = null;
|
||||||
executingNode.value.length = 0;
|
executingNode.value.length = 0;
|
||||||
executionWaitingForWebhook.value = false;
|
executionWaitingForWebhook.value = false;
|
||||||
|
|
||||||
|
workflowDiff.value = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addExecutingNode(nodeName: string) {
|
function addExecutingNode(nodeName: string) {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import type { IExecutionResponse, INodeUi } from '@/Interface';
|
||||||
import type { ComputedRef, Ref } from 'vue';
|
import type { ComputedRef, Ref } from 'vue';
|
||||||
import type { PartialBy } from '@/utils/typeHelpers';
|
import type { PartialBy } from '@/utils/typeHelpers';
|
||||||
import type { EventBus } from 'n8n-design-system';
|
import type { EventBus } from 'n8n-design-system';
|
||||||
|
import { NodeDiff } from '@/types/workflowDiff.types';
|
||||||
|
|
||||||
export type CanvasConnectionPortType = NodeConnectionType;
|
export type CanvasConnectionPortType = NodeConnectionType;
|
||||||
|
|
||||||
|
@ -86,6 +87,7 @@ export interface CanvasNodeData {
|
||||||
disabled: INodeUi['disabled'];
|
disabled: INodeUi['disabled'];
|
||||||
inputs: CanvasConnectionPort[];
|
inputs: CanvasConnectionPort[];
|
||||||
outputs: CanvasConnectionPort[];
|
outputs: CanvasConnectionPort[];
|
||||||
|
diff?: NodeDiff;
|
||||||
connections: {
|
connections: {
|
||||||
[CanvasConnectionMode.Input]: INodeConnections;
|
[CanvasConnectionMode.Input]: INodeConnections;
|
||||||
[CanvasConnectionMode.Output]: INodeConnections;
|
[CanvasConnectionMode.Output]: INodeConnections;
|
||||||
|
|
|
@ -5,4 +5,8 @@ export const enum NodeDiffStatus {
|
||||||
Deleted = 'deleted',
|
Deleted = 'deleted',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowDiff = Record<string, NodeDiffStatus>;
|
export type NodeDiff = {
|
||||||
|
status: NodeDiffStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkflowDiff = Record<string, NodeDiff>;
|
||||||
|
|
|
@ -16,6 +16,12 @@ export type WorkflowVersion = WorkflowHistory & {
|
||||||
connections: IConnections;
|
connections: IConnections;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowHistoryActionType = 'restore' | 'clone' | 'open' | 'download' | 'diff';
|
export type WorkflowHistoryActionType =
|
||||||
|
| 'restore'
|
||||||
|
| 'clone'
|
||||||
|
| 'open'
|
||||||
|
| 'download'
|
||||||
|
| 'showdiff'
|
||||||
|
| 'closediff';
|
||||||
|
|
||||||
export type WorkflowHistoryRequestParams = { take: number; skip?: number };
|
export type WorkflowHistoryRequestParams = { take: number; skip?: number };
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import type { WorkflowDiff } from '@/types/workflowDiff.types';
|
import type { WorkflowDiff } from '@/types/workflowDiff.types';
|
||||||
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
|
||||||
import { NodeDiffStatus } from '@/types/workflowDiff.types';
|
import { NodeDiffStatus } from '@/types/workflowDiff.types';
|
||||||
import _pick from 'lodash/pick';
|
import type { INodeUi, IWorkflowDb } from '@/Interface';
|
||||||
import _isEqual from 'lodash/isEqual';
|
import { pick as _pick, isEqual as _isEqual } from 'lodash-es';
|
||||||
|
|
||||||
export function compareNodes(base: INodeUi, target: INodeUi): boolean {
|
export function compareNodes(base: INodeUi, target: INodeUi): boolean {
|
||||||
const propsToCompare = ['name', 'type', 'typeVersion', 'webhookId', 'credentials', 'parameters'];
|
const propsToCompare = ['name', 'type', 'typeVersion', 'webhookId', 'credentials', 'parameters'];
|
||||||
|
@ -38,17 +37,17 @@ export function compareWorkflows(
|
||||||
|
|
||||||
Object.keys(baseNodes).forEach((id) => {
|
Object.keys(baseNodes).forEach((id) => {
|
||||||
if (!targetNodes[id]) {
|
if (!targetNodes[id]) {
|
||||||
diff[id] = NodeDiffStatus.Deleted;
|
diff[id] = { status: NodeDiffStatus.Deleted };
|
||||||
} else if (!nodesEqual(baseNodes[id], targetNodes[id])) {
|
} else if (!nodesEqual(baseNodes[id], targetNodes[id])) {
|
||||||
diff[id] = NodeDiffStatus.Modified;
|
diff[id] = { status: NodeDiffStatus.Modified };
|
||||||
} else {
|
} else {
|
||||||
diff[id] = NodeDiffStatus.Eq;
|
diff[id] = { status: NodeDiffStatus.Eq };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Object.keys(targetNodes).forEach((id) => {
|
Object.keys(targetNodes).forEach((id) => {
|
||||||
if (!baseNodes[id]) {
|
if (!baseNodes[id]) {
|
||||||
diff[id] = NodeDiffStatus.Added;
|
diff[id] = { status: NodeDiffStatus.Added };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1351,6 +1351,8 @@ async function onPostMessageReceived(messageEvent: MessageEvent) {
|
||||||
)) as ExecutionSummary;
|
)) as ExecutionSummary;
|
||||||
} else if (json?.command === 'setDiff') {
|
} else if (json?.command === 'setDiff') {
|
||||||
workflowsStore.workflowDiff = json.diff;
|
workflowsStore.workflowDiff = json.diff;
|
||||||
|
} else if (json?.command === 'fitView') {
|
||||||
|
fitView();
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onBeforeMount, ref, watchEffect, computed, h } from 'vue';
|
import { onBeforeMount, ref, watchEffect, computed, h, watch } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import type { IWorkflowDb, UserAction } from '@/Interface';
|
import type { IWorkflowDb, UserAction } from '@/Interface';
|
||||||
import { VIEWS, WORKFLOW_HISTORY_VERSION_RESTORE } from '@/constants';
|
import {
|
||||||
|
VIEWS,
|
||||||
|
WORKFLOW_HISTORY_ACTIONS,
|
||||||
|
WORKFLOW_HISTORY_VERSION_RESTORE,
|
||||||
|
workflowHistoryActionTypes,
|
||||||
|
} from '@/constants';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import type {
|
import type {
|
||||||
|
@ -21,28 +26,12 @@ import { telemetry } from '@/plugins/telemetry';
|
||||||
import { useRootStore } from '@/stores/root.store';
|
import { useRootStore } from '@/stores/root.store';
|
||||||
import { getResourcePermissions } from '@/permissions';
|
import { getResourcePermissions } from '@/permissions';
|
||||||
|
|
||||||
type WorkflowHistoryActionRecord = {
|
|
||||||
[K in Uppercase<WorkflowHistoryActionType>]: Lowercase<K>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const enum WorkflowHistoryVersionRestoreModalActions {
|
const enum WorkflowHistoryVersionRestoreModalActions {
|
||||||
restore = 'restore',
|
restore = 'restore',
|
||||||
deactivateAndRestore = 'deactivateAndRestore',
|
deactivateAndRestore = 'deactivateAndRestore',
|
||||||
cancel = 'cancel',
|
cancel = 'cancel',
|
||||||
}
|
}
|
||||||
|
|
||||||
const workflowHistoryActionTypes: WorkflowHistoryActionType[] = [
|
|
||||||
'diff',
|
|
||||||
'restore',
|
|
||||||
'clone',
|
|
||||||
'open',
|
|
||||||
'download',
|
|
||||||
];
|
|
||||||
const WORKFLOW_HISTORY_ACTIONS = workflowHistoryActionTypes.reduce(
|
|
||||||
(record, key) => ({ ...record, [key.toUpperCase()]: key }),
|
|
||||||
{} as WorkflowHistoryActionRecord,
|
|
||||||
);
|
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const i18n = useI18n();
|
const i18n = useI18n();
|
||||||
|
@ -64,9 +53,7 @@ const selectedWorkflowVersion = ref<WorkflowVersion | null>(null);
|
||||||
const selectedVersionId = computed(() => normalizeSingleRouteParam('versionId'));
|
const selectedVersionId = computed(() => normalizeSingleRouteParam('versionId'));
|
||||||
|
|
||||||
const selectedDiffWorkflowVersion = ref<WorkflowVersion | null>(null);
|
const selectedDiffWorkflowVersion = ref<WorkflowVersion | null>(null);
|
||||||
const selectedDiffVersionId = computed(() =>
|
const selectedDiffVersionId = computed(() => normalizeSingleRouteParam('diffId'));
|
||||||
route.name === VIEWS.WORKFLOW_HISTORY_DIFF ? normalizeSingleRouteParam('diffId') : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeVersionId = computed(() => workflowHistory.value[0]?.versionId);
|
const activeVersionId = computed(() => workflowHistory.value[0]?.versionId);
|
||||||
|
|
||||||
|
@ -82,13 +69,21 @@ const workflowPermissions = computed(
|
||||||
);
|
);
|
||||||
|
|
||||||
const actions = computed<UserAction[]>(() =>
|
const actions = computed<UserAction[]>(() =>
|
||||||
workflowHistoryActionTypes.map((value) => ({
|
workflowHistoryActionTypes
|
||||||
label: i18n.baseText(`workflowHistory.item.actions.${value}`),
|
.filter((value) => {
|
||||||
disabled:
|
if (value === WORKFLOW_HISTORY_ACTIONS.SHOWDIFF && selectedDiffVersionId.value) {
|
||||||
(value === 'clone' && !workflowPermissions.value.create) ||
|
return false;
|
||||||
(value === 'restore' && !workflowPermissions.value.update),
|
}
|
||||||
value,
|
|
||||||
})),
|
return value !== WORKFLOW_HISTORY_ACTIONS.CLOSEDIFF;
|
||||||
|
})
|
||||||
|
.map((value) => ({
|
||||||
|
label: i18n.baseText(`workflowHistory.item.actions.${value}`),
|
||||||
|
disabled:
|
||||||
|
(value === 'clone' && !workflowPermissions.value.create) ||
|
||||||
|
(value === 'restore' && !workflowPermissions.value.update),
|
||||||
|
value,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const isFirstItemShown = computed(
|
const isFirstItemShown = computed(
|
||||||
|
@ -281,7 +276,7 @@ const onAction = async ({
|
||||||
await restoreWorkflowVersion(id, data);
|
await restoreWorkflowVersion(id, data);
|
||||||
sendTelemetry('User restored version');
|
sendTelemetry('User restored version');
|
||||||
break;
|
break;
|
||||||
case WORKFLOW_HISTORY_ACTIONS.DIFF:
|
case WORKFLOW_HISTORY_ACTIONS.SHOWDIFF:
|
||||||
sendTelemetry('User viewed workflow diff');
|
sendTelemetry('User viewed workflow diff');
|
||||||
await router.push({
|
await router.push({
|
||||||
name: VIEWS.WORKFLOW_HISTORY_DIFF,
|
name: VIEWS.WORKFLOW_HISTORY_DIFF,
|
||||||
|
@ -292,6 +287,16 @@ const onAction = async ({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case WORKFLOW_HISTORY_ACTIONS.CLOSEDIFF:
|
||||||
|
sendTelemetry('User closed workflow diff');
|
||||||
|
await router.push({
|
||||||
|
name: VIEWS.WORKFLOW_HISTORY,
|
||||||
|
params: {
|
||||||
|
workflowId: workflowId.value,
|
||||||
|
versionId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(
|
toast.showError(
|
||||||
|
@ -309,6 +314,15 @@ const onPreview = async ({ event, id }: { event: MouseEvent; id: WorkflowVersion
|
||||||
if (event.metaKey || event.ctrlKey) {
|
if (event.metaKey || event.ctrlKey) {
|
||||||
openInNewTab(id);
|
openInNewTab(id);
|
||||||
sendTelemetry('User opened version in new tab');
|
sendTelemetry('User opened version in new tab');
|
||||||
|
} else if (selectedDiffVersionId.value) {
|
||||||
|
await router.push({
|
||||||
|
name: VIEWS.WORKFLOW_HISTORY_DIFF,
|
||||||
|
params: {
|
||||||
|
workflowId: workflowId.value,
|
||||||
|
versionId: id,
|
||||||
|
diffId: selectedDiffVersionId.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await router.push({
|
await router.push({
|
||||||
name: VIEWS.WORKFLOW_HISTORY,
|
name: VIEWS.WORKFLOW_HISTORY,
|
||||||
|
@ -342,22 +356,6 @@ watchEffect(async () => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedDiffVersionId.value) {
|
|
||||||
try {
|
|
||||||
selectedDiffWorkflowVersion.value = await workflowHistoryStore.getWorkflowVersion(
|
|
||||||
workflowId.value,
|
|
||||||
selectedDiffVersionId.value,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
toast.showError(
|
|
||||||
new Error(`${error.message} "${selectedVersionId.value}" `),
|
|
||||||
i18n.baseText('workflowHistory.title'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectedDiffWorkflowVersion.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
activeWorkflow.value = await workflowsStore.fetchWorkflow(workflowId.value);
|
activeWorkflow.value = await workflowsStore.fetchWorkflow(workflowId.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -365,6 +363,28 @@ watchEffect(async () => {
|
||||||
toast.showError(error, i18n.baseText('workflowHistory.title'));
|
toast.showError(error, i18n.baseText('workflowHistory.title'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedDiffVersionId,
|
||||||
|
async (diffVersionId) => {
|
||||||
|
if (diffVersionId) {
|
||||||
|
try {
|
||||||
|
selectedDiffWorkflowVersion.value = await workflowHistoryStore.getWorkflowVersion(
|
||||||
|
workflowId.value,
|
||||||
|
diffVersionId,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(
|
||||||
|
new Error(`${error.message} "${selectedVersionId.value}" `),
|
||||||
|
i18n.baseText('workflowHistory.title'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedDiffWorkflowVersion.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.view">
|
<div :class="$style.view">
|
||||||
|
|
Loading…
Reference in a new issue