diff --git a/packages/cli/src/Server.ts b/packages/cli/src/Server.ts index 944ce74e81..ae78cd2dbc 100644 --- a/packages/cli/src/Server.ts +++ b/packages/cli/src/Server.ts @@ -474,7 +474,7 @@ export class Server extends AbstractServer { LICENSE_FEATURES.SHOW_NON_PROD_BANNER, ), debugInEditor: isDebugInEditorLicensed(), - history: isWorkflowHistoryEnabled(), + workflowHistory: isWorkflowHistoryEnabled(), }); if (isLdapEnabled()) { diff --git a/packages/editor-ui/src/__tests__/router.test.ts b/packages/editor-ui/src/__tests__/router.test.ts index 04e2644746..0e5bf2e7e4 100644 --- a/packages/editor-ui/src/__tests__/router.test.ts +++ b/packages/editor-ui/src/__tests__/router.test.ts @@ -24,6 +24,8 @@ describe('router', () => { ['/workflow/R9JFXwkUCL1jZBuw/debug/29021', VIEWS.EXECUTION_DEBUG], ['/workflows/templates/R9JFXwkUCL1jZBuw', VIEWS.TEMPLATE_IMPORT], ['/workflows/demo', VIEWS.DEMO], + ['/workflow/8IFYawZ9dKqJu8sT/history', VIEWS.WORKFLOW_HISTORY], + ['/workflow/8IFYawZ9dKqJu8sT/history/6513ed960252b846f3792f0c', VIEWS.WORKFLOW_HISTORY], ])('should resolve %s to %s', async (path, name) => { await router.push(path); expect(router.currentRoute.value.name).toBe(name); diff --git a/packages/editor-ui/src/api/workflowHistory.ts b/packages/editor-ui/src/api/workflowHistory.ts new file mode 100644 index 0000000000..3083dbeb6b --- /dev/null +++ b/packages/editor-ui/src/api/workflowHistory.ts @@ -0,0 +1,32 @@ +import type { IRestApiContext } from '@/Interface'; +import { get } from '@/utils'; +import type { + WorkflowHistory, + WorkflowVersion, + WorkflowHistoryRequestParams, +} from '@/types/workflowHistory'; + +export const getWorkflowHistory = async ( + context: IRestApiContext, + workflowId: string, + queryParams: WorkflowHistoryRequestParams, +): Promise => { + const { data } = await get( + context.baseUrl, + `/workflow-history/workflow/${workflowId}`, + queryParams, + ); + return data; +}; + +export const getWorkflowVersion = async ( + context: IRestApiContext, + workflowId: string, + versionId: string, +): Promise => { + const { data } = await get( + context.baseUrl, + `/workflow-history/workflow/${workflowId}/version/${versionId}`, + ); + return data; +}; diff --git a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryContent.vue b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryContent.vue new file mode 100644 index 0000000000..c577445789 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryContent.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryList.vue b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryList.vue new file mode 100644 index 0000000000..ca2793dc39 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryList.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryListItem.vue b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryListItem.vue new file mode 100644 index 0000000000..15c1f97600 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowHistory/WorkflowHistoryListItem.vue @@ -0,0 +1,176 @@ + + + diff --git a/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryList.test.ts b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryList.test.ts new file mode 100644 index 0000000000..dd96b0a7b7 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryList.test.ts @@ -0,0 +1,124 @@ +import { within } from '@testing-library/dom'; +import userEvent from '@testing-library/user-event'; +import { createPinia, setActivePinia } from 'pinia'; +import { faker } from '@faker-js/faker'; +import { createComponentRenderer } from '@/__tests__/render'; +import WorkflowHistoryList from '@/components/WorkflowHistory/WorkflowHistoryList.vue'; +import type { WorkflowHistory, WorkflowHistoryActionTypes } from '@/types/workflowHistory'; + +vi.stubGlobal( + 'IntersectionObserver', + vi.fn(() => ({ + disconnect: vi.fn(), + observe: vi.fn(), + takeRecords: vi.fn(), + unobserve: vi.fn(), + })), +); + +const workflowHistoryDataFactory: () => WorkflowHistory = () => ({ + versionId: faker.string.nanoid(), + createdAt: faker.date.past().toDateString(), + authors: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, faker.person.fullName).join( + ', ', + ), +}); + +const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download']; + +const renderComponent = createComponentRenderer(WorkflowHistoryList); + +let pinia: ReturnType; + +describe('WorkflowHistoryList', () => { + beforeAll(() => { + Element.prototype.scrollTo = vi.fn(); + }); + + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + }); + + it('should render empty list', () => { + const { getByText } = renderComponent({ + pinia, + props: { + items: [], + actionTypes, + activeItem: null, + requestNumberOfItems: 20, + }, + }); + + expect(getByText(/No versions yet/)).toBeInTheDocument(); + }); + + it('should render list and delegate preview event', async () => { + const numberOfItems = faker.number.int({ min: 10, max: 50 }); + const items = Array.from({ length: numberOfItems }, workflowHistoryDataFactory); + + const { getAllByTestId, emitted } = renderComponent({ + pinia, + props: { + items, + actionTypes, + activeItem: null, + requestNumberOfItems: 20, + }, + }); + + const listItems = getAllByTestId('workflow-history-list-item'); + const listItem = listItems[items.length - 1]; + await userEvent.click(within(listItem).getByText(/ID: /)); + expect(emitted().preview).toEqual([ + [ + expect.objectContaining({ + id: items[items.length - 1].versionId, + event: expect.any(MouseEvent), + }), + ], + ]); + + expect(listItems).toHaveLength(numberOfItems); + }); + + it('should scroll to active item', async () => { + const items = Array.from({ length: 30 }, workflowHistoryDataFactory); + + const { getByTestId } = renderComponent({ + pinia, + props: { + items, + actionTypes, + activeItem: items[0], + requestNumberOfItems: 20, + }, + }); + + expect(getByTestId('workflow-history-list').scrollTo).toHaveBeenCalled(); + }); + + test.each(actionTypes)('should delegate %s event from item', async (action) => { + const items = Array.from({ length: 2 }, workflowHistoryDataFactory); + const index = 1; + const { getAllByTestId, emitted } = renderComponent({ + pinia, + props: { + items, + actionTypes, + activeItem: null, + requestNumberOfItems: 20, + }, + }); + + const listItem = getAllByTestId('workflow-history-list-item')[index]; + + await userEvent.click(within(listItem).getByTestId('action-toggle')); + const actionsDropdown = getAllByTestId('action-toggle-dropdown')[index]; + expect(actionsDropdown).toBeInTheDocument(); + + await userEvent.click(within(actionsDropdown).getByTestId(`action-${action}`)); + expect(emitted().action).toEqual([[{ action, id: items[index].versionId }]]); + }); +}); diff --git a/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryListItem.test.ts b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryListItem.test.ts new file mode 100644 index 0000000000..386f901471 --- /dev/null +++ b/packages/editor-ui/src/components/WorkflowHistory/__tests__/WorkflowHistoryListItem.test.ts @@ -0,0 +1,86 @@ +import { createPinia, setActivePinia } from 'pinia'; +import userEvent from '@testing-library/user-event'; +import { faker } from '@faker-js/faker'; +import type { UserAction } from 'n8n-design-system'; +import { createComponentRenderer } from '@/__tests__/render'; +import WorkflowHistoryListItem from '@/components/WorkflowHistory/WorkflowHistoryListItem.vue'; +import type { WorkflowHistory } from '@/types/workflowHistory'; + +const workflowHistoryDataFactory: () => WorkflowHistory = () => ({ + versionId: faker.string.nanoid(), + createdAt: faker.date.past().toDateString(), + authors: Array.from({ length: faker.number.int({ min: 2, max: 5 }) }, faker.person.fullName).join( + ', ', + ), +}); + +const actionTypes: WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download']; +const actions: UserAction[] = actionTypes.map((value) => ({ + label: value, + disabled: false, + value, +})); + +const renderComponent = createComponentRenderer(WorkflowHistoryListItem); + +let pinia: ReturnType; + +describe('WorkflowHistoryListItem', () => { + beforeEach(() => { + pinia = createPinia(); + setActivePinia(pinia); + }); + + it('should render item with badge', async () => { + const item = workflowHistoryDataFactory(); + item.authors = 'John Doe'; + const { getByText, container, queryByRole, emitted } = renderComponent({ + pinia, + props: { + item, + index: 0, + actions, + isActive: false, + }, + }); + + await userEvent.hover(container.querySelector('.el-tooltip__trigger')); + expect(queryByRole('tooltip')).not.toBeInTheDocument(); + + await userEvent.click(container.querySelector('p')); + expect(emitted().preview).toEqual([ + [expect.objectContaining({ id: item.versionId, event: expect.any(MouseEvent) })], + ]); + + expect(emitted().mounted).toEqual([[{ index: 0, isActive: false, offsetTop: 0 }]]); + expect(getByText(/Latest saved/)).toBeInTheDocument(); + }); + + test.each(actionTypes)('should emit %s event', async (action) => { + const item = workflowHistoryDataFactory(); + const authors = item.authors.split(', '); + const { queryByText, getByRole, getByTestId, container, emitted } = renderComponent({ + pinia, + props: { + item, + index: 2, + actions, + isActive: true, + }, + }); + + const authorsTag = container.querySelector('.el-tooltip__trigger'); + expect(authorsTag).toHaveTextContent(`${authors[0]} + ${authors.length - 1}`); + await userEvent.hover(authorsTag); + expect(getByRole('tooltip')).toBeInTheDocument(); + + await userEvent.click(getByTestId('action-toggle')); + expect(getByTestId('action-toggle-dropdown')).toBeInTheDocument(); + + await userEvent.click(getByTestId(`action-${action}`)); + expect(emitted().action).toEqual([[{ action, id: item.versionId }]]); + + expect(queryByText(/Latest saved/)).not.toBeInTheDocument(); + expect(emitted().mounted).toEqual([[{ index: 2, isActive: true, offsetTop: 0 }]]); + }); +}); diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index 512072473d..e49876e657 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -1762,6 +1762,19 @@ "workflowSettings.timeoutWorkflow": "Timeout Workflow", "workflowSettings.timezone": "Timezone", "workflowHistory.title": "Version History", + "workflowHistory.item.id": "ID: {id}", + "workflowHistory.item.createdAt": "{date} at {time}", + "workflowHistory.item.actions.restore": "Restore this version", + "workflowHistory.item.actions.clone": "Clone to new workflow", + "workflowHistory.item.actions.open": "Open version in new tab", + "workflowHistory.item.actions.download": "Download", + "workflowHistory.item.unsaved.title": "Unsaved version", + "workflowHistory.item.latest": "Latest saved", + "workflowHistory.empty": "No versions yet.", + "workflowHistory.hint": "Save the workflow to create the first version!", + "workflowHistory.limit": "Version history is limited to {maxRetentionPeriod} days", + "workflowHistory.upgrade": "{link} to activate full history", + "workflowHistory.upgrade.link": "Upgrade plan", "workflows.heading": "Workflows", "workflows.add": "Add Workflow", "workflows.menu.my": "My workflows", @@ -2090,4 +2103,4 @@ "executionUsage.button.upgrade": "Upgrade plan", "executionUsage.expired.text": "Your trial is over. Upgrade now to keep your data.", "executionUsage.ranOutOfExecutions.text": "You’re out of executions. Upgrade your plan to keep automating." -} \ No newline at end of file +} diff --git a/packages/editor-ui/src/router.ts b/packages/editor-ui/src/router.ts index 600bcce89e..c8c3c12ce7 100644 --- a/packages/editor-ui/src/router.ts +++ b/packages/editor-ui/src/router.ts @@ -296,7 +296,7 @@ export const routes = [ ], }, { - path: '/workflow/:workflowId/history/:historyId?', + path: '/workflow/:workflowId/history/:versionId?', name: VIEWS.WORKFLOW_HISTORY, components: { default: WorkflowHistory, diff --git a/packages/editor-ui/src/stores/workflowHistory.store.ts b/packages/editor-ui/src/stores/workflowHistory.store.ts new file mode 100644 index 0000000000..3d1de702b5 --- /dev/null +++ b/packages/editor-ui/src/stores/workflowHistory.store.ts @@ -0,0 +1,56 @@ +import { ref, computed } from 'vue'; +import { defineStore } from 'pinia'; +import * as whApi from '@/api/workflowHistory'; +import { useRootStore } from '@/stores/n8nRoot.store'; +import type { + WorkflowHistory, + WorkflowVersion, + WorkflowHistoryRequestParams, +} from '@/types/workflowHistory'; + +export const useWorkflowHistoryStore = defineStore('workflowHistory', () => { + const rootStore = useRootStore(); + + const workflowHistory = ref([]); + const activeWorkflowVersion = ref(null); + const maxRetentionPeriod = ref(0); + const retentionPeriod = ref(0); + const shouldUpgrade = computed(() => maxRetentionPeriod.value === retentionPeriod.value); + + const getWorkflowHistory = async ( + workflowId: string, + queryParams: WorkflowHistoryRequestParams, + ): Promise => + whApi + .getWorkflowHistory(rootStore.getRestApiContext, workflowId, queryParams) + .catch((error) => { + console.error(error); + return [] as WorkflowHistory[]; + }); + const addWorkflowHistory = (history: WorkflowHistory[]) => { + workflowHistory.value = workflowHistory.value.concat(history); + }; + + const getWorkflowVersion = async ( + workflowId: string, + versionId: string, + ): Promise => + whApi.getWorkflowVersion(rootStore.getRestApiContext, workflowId, versionId).catch((error) => { + console.error(error); + return null; + }); + const setActiveWorkflowVersion = (version: WorkflowVersion | null) => { + activeWorkflowVersion.value = version; + }; + + return { + getWorkflowHistory, + addWorkflowHistory, + getWorkflowVersion, + setActiveWorkflowVersion, + workflowHistory, + activeWorkflowVersion, + shouldUpgrade, + maxRetentionPeriod, + }; +}); diff --git a/packages/editor-ui/src/types/workflowHistory.ts b/packages/editor-ui/src/types/workflowHistory.ts new file mode 100644 index 0000000000..ce4d752ff1 --- /dev/null +++ b/packages/editor-ui/src/types/workflowHistory.ts @@ -0,0 +1,19 @@ +import type { IWorkflowDb } from '@/Interface'; + +export type WorkflowHistory = { + versionId: string; + authors: string; + createdAt: string; +}; + +export type WorkflowVersionId = WorkflowHistory['versionId']; + +export type WorkflowVersion = WorkflowHistory & { + nodes: IWorkflowDb['nodes']; + connection: IWorkflowDb['connections']; + workflow: IWorkflowDb; +}; + +export type WorkflowHistoryActionTypes = ['restore', 'clone', 'open', 'download']; + +export type WorkflowHistoryRequestParams = { take: number; skip?: number }; diff --git a/packages/editor-ui/src/views/WorkflowHistory.vue b/packages/editor-ui/src/views/WorkflowHistory.vue index c364461209..db768da8ff 100644 --- a/packages/editor-ui/src/views/WorkflowHistory.vue +++ b/packages/editor-ui/src/views/WorkflowHistory.vue @@ -1,19 +1,162 @@ diff --git a/packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts b/packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts new file mode 100644 index 0000000000..2ec57fb9fb --- /dev/null +++ b/packages/editor-ui/src/views/__tests__/WorkflowHistory.test.ts @@ -0,0 +1,156 @@ +import type { SpyInstance } from 'vitest'; +import { createTestingPinia } from '@pinia/testing'; +import { waitFor } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { defineComponent } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import { faker } from '@faker-js/faker'; +import { createComponentRenderer } from '@/__tests__/render'; +import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils'; +import WorkflowHistoryPage from '@/views/WorkflowHistory.vue'; +import { useWorkflowHistoryStore } from '@/stores/workflowHistory.store'; +import { STORES, VIEWS } from '@/constants'; +import type { WorkflowHistory } from '@/types/workflowHistory'; + +vi.mock('vue-router', () => { + const params = {}; + const push = vi.fn(); + const replace = vi.fn(); + const resolve = vi.fn().mockImplementation(() => ({ href: '' })); + return { + useRoute: () => ({ + params, + }), + useRouter: () => ({ + push, + replace, + resolve, + }), + }; +}); + +const workflowHistoryDataFactory: () => WorkflowHistory = () => ({ + versionId: faker.string.nanoid(), + createdAt: faker.date.past().toDateString(), + authors: Array.from({ length: faker.number.int({ min: 2, max: 5 }) }, faker.person.fullName).join( + ', ', + ), +}); + +const workflowVersionDataFactory: () => WorkflowHistory = () => ({ + ...workflowHistoryDataFactory(), + workflow: { + name: faker.lorem.words(3), + }, +}); + +const workflowId = faker.string.nanoid(); +const historyData = Array.from({ length: 5 }, workflowHistoryDataFactory); +const versionData = { + ...workflowVersionDataFactory(), + ...historyData[0], +}; +const versionId = faker.string.nanoid(); + +const renderComponent = createComponentRenderer(WorkflowHistoryPage, { + global: { + stubs: { + 'workflow-history-content': true, + 'workflow-history-list': defineComponent({ + props: { + id: { + type: String, + default: versionId, + }, + }, + template: + '
button>
', + }), + }, + }, +}); + +let pinia: ReturnType; +let router: ReturnType; +let route: ReturnType; +let workflowHistoryStore: ReturnType; +let windowOpenSpy: SpyInstance; + +describe('WorkflowHistory', () => { + beforeEach(() => { + pinia = createTestingPinia({ + initialState: { + [STORES.SETTINGS]: SETTINGS_STORE_DEFAULT_STATE, + }, + }); + workflowHistoryStore = useWorkflowHistoryStore(); + route = useRoute(); + router = useRouter(); + + vi.spyOn(workflowHistoryStore, 'workflowHistory', 'get').mockReturnValue(historyData); + vi.spyOn(workflowHistoryStore, 'activeWorkflowVersion', 'get').mockReturnValue(versionData); + windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should replace url path to contain /:versionId', async () => { + route.params.workflowId = workflowId; + + renderComponent({ pinia }); + + await waitFor(() => + expect(router.replace).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOW_HISTORY, + params: { workflowId, versionId: versionData.versionId }, + }), + ); + }); + + it('should load version data if path contains /:versionId', async () => { + const getWorkflowVersionSpy = vi.spyOn(workflowHistoryStore, 'getWorkflowVersion'); + + route.params.workflowId = workflowId; + route.params.versionId = versionData.versionId; + + renderComponent({ pinia }); + + await waitFor(() => expect(router.replace).not.toHaveBeenCalled()); + expect(getWorkflowVersionSpy).toHaveBeenCalledWith(workflowId, versionData.versionId); + }); + + it('should change path on preview', async () => { + route.params.workflowId = workflowId; + + const { getByTestId } = renderComponent({ pinia }); + + await userEvent.click(getByTestId('stub-preview-button')); + + await waitFor(() => + expect(router.push).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOW_HISTORY, + params: { workflowId, versionId }, + }), + ); + }); + + it('should open preview in new tab if meta key used', async () => { + route.params.workflowId = workflowId; + const { getByTestId } = renderComponent({ pinia }); + + const user = userEvent.setup(); + + await user.keyboard('[ControlLeft>]'); + await user.click(getByTestId('stub-preview-button')); + + await waitFor(() => + expect(router.resolve).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOW_HISTORY, + params: { workflowId, versionId }, + }), + ); + expect(windowOpenSpy).toHaveBeenCalled(); + }); +});