feat(editor): Workflow history [WIP] - Remove pinned data from workflow history version preview (no-changelog) (#7406)

This commit is contained in:
Csaba Tuncsik 2023-10-19 14:38:00 +02:00 committed by GitHub
parent 82129694c6
commit c7c8048430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 447 additions and 195 deletions

View file

@ -34,8 +34,9 @@ const workflowVersionPreview = computed<IWorkflowDb | undefined>(() => {
if (!props.workflowVersion || !props.workflow) { if (!props.workflowVersion || !props.workflow) {
return; return;
} }
const { pinData, ...workflow } = props.workflow;
return { return {
...props.workflow, ...workflow,
nodes: props.workflowVersion.nodes, nodes: props.workflowVersion.nodes,
connections: props.workflowVersion.connections, connections: props.workflowVersion.connections,
}; };

View file

@ -1,10 +1,13 @@
import { vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia'; import { createPinia, setActivePinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import type { UserAction } from 'n8n-design-system'; import type { UserAction } from 'n8n-design-system';
import { createComponentRenderer } from '@/__tests__/render'; import { createComponentRenderer } from '@/__tests__/render';
import WorkflowHistoryContent from '@/components/WorkflowHistory/WorkflowHistoryContent.vue'; import WorkflowHistoryContent from '@/components/WorkflowHistory/WorkflowHistoryContent.vue';
import type { WorkflowHistoryActionTypes } from '@/types/workflowHistory'; import type { WorkflowHistoryActionTypes } from '@/types/workflowHistory';
import { workflowHistoryDataFactory } from '@/stores/__tests__/utils/workflowHistoryTestUtils'; import { workflowVersionDataFactory } from '@/stores/__tests__/utils/workflowHistoryTestUtils';
import type { IWorkflowDb } from '@/Interface';
const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download']; const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download'];
const actions: UserAction[] = actionTypes.map((value) => ({ const actions: UserAction[] = actionTypes.map((value) => ({
@ -16,15 +19,24 @@ const actions: UserAction[] = actionTypes.map((value) => ({
const renderComponent = createComponentRenderer(WorkflowHistoryContent); const renderComponent = createComponentRenderer(WorkflowHistoryContent);
let pinia: ReturnType<typeof createPinia>; let pinia: ReturnType<typeof createPinia>;
let postMessageSpy: vi.SpyInstance;
describe('WorkflowHistoryContent', () => { describe('WorkflowHistoryContent', () => {
beforeEach(() => { beforeEach(() => {
pinia = createPinia(); pinia = createPinia();
setActivePinia(pinia); setActivePinia(pinia);
postMessageSpy = vi.fn();
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
writable: true,
value: {
postMessage: postMessageSpy,
},
});
}); });
it('should use the list item component to render version data', () => { it('should use the list item component to render version data', () => {
const workflowVersion = workflowHistoryDataFactory(); const workflowVersion = workflowVersionDataFactory();
const { getByTestId } = renderComponent({ const { getByTestId } = renderComponent({
pinia, pinia,
props: { props: {
@ -38,7 +50,7 @@ describe('WorkflowHistoryContent', () => {
}); });
test.each(actionTypes)('should emit %s event', async (action) => { test.each(actionTypes)('should emit %s event', async (action) => {
const workflowVersion = workflowHistoryDataFactory(); const workflowVersion = workflowVersionDataFactory();
const { getByTestId, emitted } = renderComponent({ const { getByTestId, emitted } = renderComponent({
pinia, pinia,
props: { props: {
@ -56,4 +68,23 @@ describe('WorkflowHistoryContent', () => {
[{ action, id: workflowVersion.versionId, data: { formattedCreatedAt: expect.any(String) } }], [{ action, id: workflowVersion.versionId, data: { formattedCreatedAt: expect.any(String) } }],
]); ]);
}); });
it('should pass proper workflow data to WorkflowPreview component', async () => {
const workflowVersion = workflowVersionDataFactory();
const workflow = { pinData: {} } as IWorkflowDb;
renderComponent({
pinia,
props: {
workflow,
workflowVersion,
actions,
},
});
window.postMessage('{"command":"n8nReady"}', '*');
await waitFor(() => {
expect(postMessageSpy).toHaveBeenCalledWith(expect.not.stringContaining('pinData'), '*');
});
});
}); });

View file

@ -8,202 +8,194 @@
</div> </div>
<iframe <iframe
:class="{ :class="{
[$style.workflow]: !this.nodeViewDetailsOpened, [$style.workflow]: !nodeViewDetailsOpened,
[$style.executionPreview]: mode === 'execution', [$style.executionPreview]: mode === 'execution',
[$style.openNDV]: this.nodeViewDetailsOpened, [$style.openNDV]: nodeViewDetailsOpened,
[$style.show]: this.showPreview, [$style.show]: showPreview,
}" }"
ref="preview_iframe" ref="iframeRef"
:src="`${rootStore.baseUrl}workflows/demo`" :src="`${rootStore.baseUrl}workflows/demo`"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
></iframe> />
</div> </div>
</template> </template>
<script lang="ts"> <script setup lang="ts">
import { defineComponent } from 'vue'; import { onMounted, onBeforeUnmount, ref, computed, watch } from 'vue';
import { useToast } from '@/composables'; import { useI18n, useToast } from '@/composables';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/n8nRoot.store'; import { useRootStore } from '@/stores/n8nRoot.store';
import { useWorkflowsStore } from '@/stores'; import { useWorkflowsStore } from '@/stores/workflows.store';
export default defineComponent({ const props = withDefaults(
name: 'WorkflowPreview', defineProps<{
props: { loading?: boolean;
loading: { mode?: 'workflow' | 'execution';
type: Boolean, workflow?: IWorkflowDb;
default: false, executionId?: string;
}, executionMode?: string;
mode: { loaderType?: 'image' | 'spinner';
type: String, }>(),
default: 'workflow', {
validator: (value: string): boolean => ['workflow', 'execution'].includes(value), loading: false,
}, mode: 'workflow',
workflow: { loaderType: 'image',
type: Object as () => IWorkflowDb,
required: false,
},
executionId: {
type: String,
required: false,
},
executionMode: {
type: String,
required: false,
},
loaderType: {
type: String,
default: 'image',
validator: (value: string): boolean => ['image', 'spinner'].includes(value),
},
}, },
setup() { );
return {
...useToast(),
};
},
data() {
return {
nodeViewDetailsOpened: false,
ready: false,
insideIframe: false,
scrollX: 0,
scrollY: 0,
};
},
computed: {
...mapStores(useRootStore, useWorkflowsStore),
showPreview(): boolean {
return (
!this.loading &&
((this.mode === 'workflow' && !!this.workflow) ||
(this.mode === 'execution' && !!this.executionId)) &&
this.ready
);
},
},
methods: {
onMouseEnter() {
this.insideIframe = true;
this.scrollX = window.scrollX;
this.scrollY = window.scrollY;
},
onMouseLeave() {
this.insideIframe = false;
},
loadWorkflow() {
try {
if (!this.workflow) {
throw new Error(this.$locale.baseText('workflowPreview.showError.missingWorkflow'));
}
if (!this.workflow.nodes || !Array.isArray(this.workflow.nodes)) {
throw new Error(this.$locale.baseText('workflowPreview.showError.arrayEmpty'));
}
const iframeRef = this.$refs.preview_iframe as HTMLIFrameElement | undefined; const emit = defineEmits<{
if (iframeRef?.contentWindow) { (event: 'close'): void;
iframeRef.contentWindow.postMessage( }>();
JSON.stringify({
command: 'openWorkflow',
workflow: this.workflow,
}),
'*',
);
}
} catch (error) {
this.showError(
error,
this.$locale.baseText('workflowPreview.showError.previewError.title'),
this.$locale.baseText('workflowPreview.showError.previewError.message'),
);
}
},
loadExecution() {
try {
if (!this.executionId) {
throw new Error(this.$locale.baseText('workflowPreview.showError.missingExecution'));
}
const iframeRef = this.$refs.preview_iframe as HTMLIFrameElement | undefined;
if (iframeRef?.contentWindow) {
iframeRef.contentWindow.postMessage(
JSON.stringify({
command: 'openExecution',
executionId: this.executionId,
executionMode: this.executionMode || '',
}),
'*',
);
if (this.workflowsStore.activeWorkflowExecution) { const i18n = useI18n();
iframeRef.contentWindow.postMessage( const toast = useToast();
JSON.stringify({ const rootStore = useRootStore();
command: 'setActiveExecution', const workflowsStore = useWorkflowsStore();
execution: this.workflowsStore.activeWorkflowExecution,
}), const iframeRef = ref<HTMLIFrameElement | null>(null);
'*', const nodeViewDetailsOpened = ref(false);
); const ready = ref(false);
} const insideIframe = ref(false);
} const scrollX = ref(0);
} catch (error) { const scrollY = ref(0);
this.showError(
error, const showPreview = computed(() => {
this.$locale.baseText('workflowPreview.showError.previewError.title'), return (
this.$locale.baseText('workflowPreview.executionMode.showError.previewError.message'), !props.loading &&
); ((props.mode === 'workflow' && props.workflow) ||
} (props.mode === 'execution' && props.executionId)) &&
}, ready.value
receiveMessage({ data }: MessageEvent) { );
try {
const json = JSON.parse(data);
if (json.command === 'n8nReady') {
this.ready = true;
} else if (json.command === 'openNDV') {
this.nodeViewDetailsOpened = true;
} else if (json.command === 'closeNDV') {
this.nodeViewDetailsOpened = false;
} else if (json.command === 'error') {
this.$emit('close');
}
} catch (e) {}
},
onDocumentScroll() {
if (this.insideIframe) {
window.scrollTo(this.scrollX, this.scrollY);
}
},
},
watch: {
showPreview(show) {
if (show) {
if (this.mode === 'workflow') {
this.loadWorkflow();
} else if (this.mode === 'execution') {
this.loadExecution();
}
}
},
executionId(value) {
if (this.mode === 'execution' && this.executionId) {
this.loadExecution();
}
},
workflow() {
if (this.mode === 'workflow' && this.workflow) {
this.loadWorkflow();
}
},
},
mounted() {
window.addEventListener('message', this.receiveMessage);
document.addEventListener('scroll', this.onDocumentScroll);
},
beforeUnmount() {
window.removeEventListener('message', this.receiveMessage);
document.removeEventListener('scroll', this.onDocumentScroll);
},
}); });
const loadWorkflow = () => {
try {
if (!props.workflow) {
throw new Error(i18n.baseText('workflowPreview.showError.missingWorkflow'));
}
if (!props.workflow.nodes || !Array.isArray(props.workflow.nodes)) {
throw new Error(i18n.baseText('workflowPreview.showError.arrayEmpty'));
}
iframeRef.value?.contentWindow?.postMessage?.(
JSON.stringify({
command: 'openWorkflow',
workflow: props.workflow,
}),
'*',
);
} catch (error) {
toast.showError(
error,
i18n.baseText('workflowPreview.showError.previewError.title'),
i18n.baseText('workflowPreview.showError.previewError.message'),
);
}
};
const loadExecution = () => {
try {
if (!props.executionId) {
throw new Error(i18n.baseText('workflowPreview.showError.missingExecution'));
}
iframeRef.value?.contentWindow?.postMessage?.(
JSON.stringify({
command: 'openExecution',
executionId: props.executionId,
executionMode: props.executionMode || '',
}),
'*',
);
if (workflowsStore.activeWorkflowExecution) {
iframeRef.value?.contentWindow?.postMessage?.(
JSON.stringify({
command: 'setActiveExecution',
execution: workflowsStore.activeWorkflowExecution,
}),
'*',
);
}
} catch (error) {
toast.showError(
error,
i18n.baseText('workflowPreview.showError.previewError.title'),
i18n.baseText('workflowPreview.executionMode.showError.previewError.message'),
);
}
};
const onMouseEnter = () => {
insideIframe.value = true;
scrollX.value = window.scrollX;
scrollY.value = window.scrollY;
};
const onMouseLeave = () => {
insideIframe.value = false;
};
const receiveMessage = ({ data }: MessageEvent) => {
try {
const json = JSON.parse(data);
if (json.command === 'n8nReady') {
ready.value = true;
} else if (json.command === 'openNDV') {
nodeViewDetailsOpened.value = true;
} else if (json.command === 'closeNDV') {
nodeViewDetailsOpened.value = false;
} else if (json.command === 'error') {
emit('close');
}
} catch (e) {
console.error(e);
}
};
const onDocumentScroll = () => {
if (insideIframe.value) {
window.scrollTo(scrollX.value, scrollY.value);
}
};
onMounted(() => {
window.addEventListener('message', receiveMessage);
document.addEventListener('scroll', onDocumentScroll);
});
onBeforeUnmount(() => {
window.removeEventListener('message', receiveMessage);
document.removeEventListener('scroll', onDocumentScroll);
});
watch(
() => showPreview.value,
() => {
if (showPreview.value) {
if (props.mode === 'workflow') {
loadWorkflow();
} else if (props.mode === 'execution') {
loadExecution();
}
}
},
);
watch(
() => props.executionId,
() => {
if (props.mode === 'execution' && props.executionId) {
loadExecution();
}
},
);
watch(
() => props.workflow,
() => {
if (props.mode === 'workflow' && props.workflow) {
loadWorkflow();
}
},
);
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -0,0 +1,230 @@
import { vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { waitFor } from '@testing-library/vue';
import type { IExecutionsSummary } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import WorkflowPreview from '@/components/WorkflowPreview.vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
const renderComponent = createComponentRenderer(WorkflowPreview);
let pinia: ReturnType<typeof createPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let postMessageSpy: vi.SpyInstance;
const sendPostMessageCommand = (command: string) => {
window.postMessage(`{"command":"${command}"}`, '*');
};
describe('WorkflowPreview', () => {
beforeEach(() => {
pinia = createPinia();
setActivePinia(pinia);
workflowsStore = useWorkflowsStore();
postMessageSpy = vi.fn();
Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
writable: true,
value: {
postMessage: postMessageSpy,
},
});
});
it('should not call iframe postMessage when it is ready and no workflow or executionId props', async () => {
renderComponent({
pinia,
props: {},
});
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).not.toHaveBeenCalled();
});
});
it('should not call iframe postMessage when it is ready and there are no nodes in the workflow', async () => {
const workflow = {} as IWorkflowDb;
renderComponent({
pinia,
props: {
workflow,
},
});
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).not.toHaveBeenCalled();
});
});
it('should not call iframe postMessage when it is ready and nodes is not an array', async () => {
const workflow = { nodes: {} } as IWorkflowDb;
renderComponent({
pinia,
props: {
workflow,
},
});
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).not.toHaveBeenCalled();
});
});
it('should call iframe postMessage with "openWorkflow" when it is ready and the workflow has nodes', async () => {
const nodes = [{ name: 'Start' }] as INodeUi[];
const workflow = { nodes } as IWorkflowDb;
renderComponent({
pinia,
props: {
workflow,
},
});
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).toHaveBeenCalledWith(
JSON.stringify({
command: 'openWorkflow',
workflow,
}),
'*',
);
});
});
it('should not call iframe postMessage with "openExecution" when executionId is passed but mode not set to "execution"', async () => {
const executionId = '123';
renderComponent({
pinia,
props: {
executionId,
},
});
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).not.toHaveBeenCalled();
});
});
it('should call iframe postMessage with "openExecution" when executionId is passed and mode is set', async () => {
const executionId = '123';
renderComponent({
pinia,
props: {
executionId,
mode: 'execution',
},
});
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).toHaveBeenCalledWith(
JSON.stringify({
command: 'openExecution',
executionId,
executionMode: '',
}),
'*',
);
});
});
it('should call also iframe postMessage with "setActiveExecution" if active execution is set', async () => {
vi.spyOn(workflowsStore, 'activeWorkflowExecution', 'get').mockReturnValue({
id: 'abc',
} as IExecutionsSummary);
const executionId = '123';
renderComponent({
pinia,
props: {
executionId,
mode: 'execution',
},
});
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).toHaveBeenCalledWith(
JSON.stringify({
command: 'openExecution',
executionId,
executionMode: '',
}),
'*',
);
expect(postMessageSpy).toHaveBeenCalledWith(
JSON.stringify({
command: 'setActiveExecution',
execution: { id: 'abc' },
}),
'*',
);
});
});
it('iframe should toggle "openNDV" class with postmessages', async () => {
const nodes = [{ name: 'Start' }] as INodeUi[];
const workflow = { nodes } as IWorkflowDb;
const { container } = renderComponent({
pinia,
props: {
workflow,
},
});
const iframe = container.querySelector('iframe');
expect(iframe?.classList.toString()).not.toContain('openNDV');
sendPostMessageCommand('n8nReady');
await waitFor(() => {
expect(postMessageSpy).toHaveBeenCalledWith(
JSON.stringify({
command: 'openWorkflow',
workflow,
}),
'*',
);
});
sendPostMessageCommand('openNDV');
await waitFor(() => {
expect(iframe?.classList.toString()).toContain('openNDV');
});
sendPostMessageCommand('closeNDV');
await waitFor(() => {
expect(iframe?.classList.toString()).not.toContain('openNDV');
});
});
it('should emit "close" event if iframe sends "error" command', async () => {
const { emitted } = renderComponent({
pinia,
props: {},
});
sendPostMessageCommand('error');
await waitFor(() => {
expect(emitted().close).toBeDefined();
});
});
});

View file

@ -81,18 +81,16 @@ export const useWorkflowHistoryStore = defineStore('workflowHistory', () => {
workflowId: string, workflowId: string,
workflowVersionId: string, workflowVersionId: string,
shouldDeactivate: boolean, shouldDeactivate: boolean,
) => { ): Promise<IWorkflowDb> => {
const workflowVersion = await getWorkflowVersion(workflowId, workflowVersionId); const workflowVersion = await getWorkflowVersion(workflowId, workflowVersionId);
if (workflowVersion?.nodes && workflowVersion?.connections) { const { connections, nodes } = workflowVersion;
const { connections, nodes } = workflowVersion; const updateData: IWorkflowDataUpdate = { connections, nodes };
const updateData: IWorkflowDataUpdate = { connections, nodes };
if (shouldDeactivate) { if (shouldDeactivate) {
updateData.active = false; updateData.active = false;
}
await workflowsStore.updateWorkflow(workflowId, updateData, true);
} }
return workflowsStore.updateWorkflow(workflowId, updateData, true);
}; };
return { return {

View file

@ -199,7 +199,7 @@ const restoreWorkflowVersion = async (
if (modalAction === WorkflowHistoryVersionRestoreModalActions.cancel) { if (modalAction === WorkflowHistoryVersionRestoreModalActions.cancel) {
return; return;
} }
await workflowHistoryStore.restoreWorkflow( activeWorkflow.value = await workflowHistoryStore.restoreWorkflow(
route.params.workflowId, route.params.workflowId,
id, id,
modalAction === WorkflowHistoryVersionRestoreModalActions.deactivateAndRestore, modalAction === WorkflowHistoryVersionRestoreModalActions.deactivateAndRestore,