feat: add diff status and fix bugs

This commit is contained in:
Alex Grozav 2024-10-31 10:39:12 +02:00
parent 0a15e85d63
commit bbb9e2fab4
16 changed files with 376 additions and 123 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}"&nbsp;`),
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}"&nbsp;`),
i18n.baseText('workflowHistory.title'),
);
}
} else {
selectedDiffWorkflowVersion.value = null;
}
},
{ immediate: true },
);
</script> </script>
<template> <template>
<div :class="$style.view"> <div :class="$style.view">