diff --git a/packages/cli/src/executions/executions.service.ts b/packages/cli/src/executions/executions.service.ts index c6cceb1bf6..d087c7acfd 100644 --- a/packages/cli/src/executions/executions.service.ts +++ b/packages/cli/src/executions/executions.service.ts @@ -541,6 +541,9 @@ export class ExecutionsService { // delete executions by date, if user may access the underlying workflows where.startedAt = LessThanOrEqual(deleteBefore); Object.assign(where, requestFilters); + if (where.status) { + where.status = In(requestFiltersRaw!.status as string[]); + } } else if (ids) { // delete executions by IDs, if user may access the underlying workflows where.id = In(ids); @@ -568,6 +571,10 @@ export class ExecutionsService { idsToDelete.map(async (id) => binaryDataManager.deleteBinaryDataByExecutionId(id)), ); - await Db.collections.Execution.delete(idsToDelete); + do { + // Delete in batches to avoid "SQLITE_ERROR: Expression tree is too large (maximum depth 1000)" error + const batch = idsToDelete.splice(0, 500); + await Db.collections.Execution.delete(batch); + } while (idsToDelete.length > 0); } } diff --git a/packages/editor-ui/package.json b/packages/editor-ui/package.json index 10d49235af..6823adcc84 100644 --- a/packages/editor-ui/package.json +++ b/packages/editor-ui/package.json @@ -86,6 +86,7 @@ "@faker-js/faker": "^7.6.0", "@pinia/testing": "^0.0.14", "@testing-library/jest-dom": "^5.16.5", + "@testing-library/user-event": "^14.4.3", "@testing-library/vue": "^5.8.3", "@types/dateformat": "^3.0.0", "@types/express": "^4.17.6", diff --git a/packages/editor-ui/src/components/ExecutionsList.vue b/packages/editor-ui/src/components/ExecutionsList.vue index 82815dcd9f..e7b6a548bf 100644 --- a/packages/editor-ui/src/components/ExecutionsList.vue +++ b/packages/editor-ui/src/components/ExecutionsList.vue @@ -31,20 +31,39 @@ > - + {{ $locale.baseText('executionsList.autoRefresh') }} + + @@ -66,9 +85,10 @@
{{ $locale.baseText('executionsList.name') }} @@ -213,9 +233,16 @@
+
+ {{ $locale.baseText('executionsList.empty') }} +
finishedExecutions.length || finishedExecutionsCountEstimated " > @@ -225,23 +252,37 @@ :label="$locale.baseText('executionsList.loadMore')" @click="loadMore()" :loading="isDataLoading" + data-testid="load-more-button" />
-
{{ $locale.baseText('executionsList.loadedAll') }}
+
+ {{ $locale.baseText('executionsList.loadedAll') }} +
-
+
- {{ $locale.baseText('executionsList.selected', { interpolate: { numSelected } }) }} + {{ + $locale.baseText('executionsList.selected', { + adjustToNumber: numSelected, + interpolate: { numSelected }, + }) + }}
@@ -261,10 +302,9 @@ import { IExecutionsCurrentSummaryExtended, IExecutionDeleteFilter, IExecutionsListResponse, - IExecutionsSummary, IWorkflowShortResponse, } from '@/Interface'; -import type { ExecutionStatus, IDataObject } from 'n8n-workflow'; +import type { IExecutionsSummary, ExecutionStatus, IDataObject } from 'n8n-workflow'; import { range as _range } from 'lodash-es'; import mixins from 'vue-typed-mixins'; import { mapStores } from 'pinia'; @@ -285,7 +325,8 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, finishedExecutionsCount: 0, finishedExecutionsCountEstimated: false, - checkAll: false, + allVisibleSelected: false, + allExistingSelected: false, autoRefresh: true, autoRefreshInterval: undefined as undefined | NodeJS.Timer, @@ -353,33 +394,28 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, return this.workflowsStore.activeExecutions; }, combinedExecutions(): IExecutionsSummary[] { - const returnData: IExecutionsSummary[] = []; + const returnData = []; if (['ALL', 'running'].includes(this.filter.status)) { - returnData.push(...this.activeExecutions); + returnData.push(...(this.activeExecutions as IExecutionsSummary[])); } + if (['ALL', 'error', 'success', 'waiting'].includes(this.filter.status)) { returnData.push(...this.finishedExecutions); } - return returnData; - }, - combinedExecutionsCount(): number { - return 0 + this.activeExecutions.length + this.finishedExecutionsCount; + + return returnData.filter( + (execution) => + this.filter.workflowId === 'ALL' || execution.workflowId === this.filter.workflowId, + ); }, numSelected(): number { - if (this.checkAll) { + if (this.allExistingSelected) { return this.finishedExecutionsCount; } return Object.keys(this.selectedItems).length; }, - isIndeterminate(): boolean { - if (this.checkAll) { - return false; - } - - return this.numSelected > 0; - }, workflowFilterCurrent(): IDataObject { const filter: IDataObject = {}; if (this.filter.workflowId !== 'ALL') { @@ -434,9 +470,18 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, this.autoRefreshInterval = setInterval(() => this.loadAutoRefresh(), 4 * 1000); // refresh data every 4 secs } }, - handleCheckAllChange() { - if (!this.checkAll) { + handleCheckAllExistingChange() { + this.allExistingSelected = !this.allExistingSelected; + this.allVisibleSelected = !this.allExistingSelected; + this.handleCheckAllVisibleChange(); + }, + handleCheckAllVisibleChange() { + this.allVisibleSelected = !this.allVisibleSelected; + if (!this.allVisibleSelected) { + this.allExistingSelected = false; Vue.set(this, 'selectedItems', {}); + } else { + this.selectAllVisibleExecutions(); } }, handleCheckboxChanged(executionId: string) { @@ -445,6 +490,10 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, } else { Vue.set(this.selectedItems, executionId, true); } + this.allVisibleSelected = + Object.keys(this.selectedItems).length === this.combinedExecutions.length; + this.allExistingSelected = + Object.keys(this.selectedItems).length === this.finishedExecutionsCount; }, async handleDeleteSelected() { const deleteExecutions = await this.confirmMessage( @@ -464,7 +513,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, this.isDataLoading = true; const sendData: IExecutionDeleteFilter = {}; - if (this.checkAll) { + if (this.allExistingSelected) { sendData.deleteBefore = this.finishedExecutions[0].startedAt as Date; } else { sendData.ids = Object.keys(this.selectedItems); @@ -474,45 +523,6 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, try { await this.restApi().deleteExecutions(sendData); - let removedCurrentlyLoadedExecution = false; - let removedActiveExecution = false; - const currentWorkflow: string = this.workflowsStore.workflowId; - const activeExecution: IExecutionsSummary | null = - this.workflowsStore.activeWorkflowExecution; - // Also update current workflow executions view if needed - for (const selectedId of Object.keys(this.selectedItems)) { - const execution: IExecutionsSummary | undefined = - this.workflowsStore.getExecutionDataById(selectedId); - if (execution && execution.workflowId === currentWorkflow) { - this.workflowsStore.deleteExecution(execution); - removedCurrentlyLoadedExecution = true; - } - if ( - execution !== undefined && - activeExecution !== null && - execution.id === activeExecution.id - ) { - removedActiveExecution = true; - } - } - // Also update route if needed - if (removedCurrentlyLoadedExecution) { - const currentWorkflowExecutions: IExecutionsSummary[] = - this.workflowsStore.currentWorkflowExecutions; - if (currentWorkflowExecutions.length === 0) { - this.workflowsStore.activeWorkflowExecution = null; - - this.$router.push({ name: VIEWS.EXECUTION_HOME, params: { name: currentWorkflow } }); - } else if (removedActiveExecution) { - this.workflowsStore.activeWorkflowExecution = currentWorkflowExecutions[0]; - this.$router - .push({ - name: VIEWS.EXECUTION_PREVIEW, - params: { name: currentWorkflow, executionId: currentWorkflowExecutions[0].id }, - }) - .catch(() => {}); - } - } } catch (error) { this.isDataLoading = false; this.$showError( @@ -529,16 +539,15 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, type: 'success', }); - Vue.set(this, 'selectedItems', {}); - this.checkAll = false; - + this.handleClearSelection(); this.refreshData(); }, - handleClearSelection() { - this.checkAll = false; - this.handleCheckAllChange(); + handleClearSelection(): void { + this.allVisibleSelected = false; + this.allExistingSelected = false; + Vue.set(this, 'selectedItems', {}); }, - handleFilterChanged() { + handleFilterChanged(): void { this.refreshData(); }, handleActionItemClick(commandData: { command: string; execution: IExecutionsSummary }) { @@ -668,6 +677,8 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, Vue.set(this, 'finishedExecutions', alreadyPresentExecutionsFiltered); this.workflowsStore.addToCurrentExecutions(alreadyPresentExecutionsFiltered); + + this.adjustSelectionAfterMoreItemsLoaded(); }, async loadFinishedExecutions(): Promise { if (this.filter.status === 'running') { @@ -685,6 +696,10 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, this.finishedExecutionsCountEstimated = data.estimated; this.workflowsStore.addToCurrentExecutions(data.results); + + if (this.finishedExecutions.length === 0) { + this.handleClearSelection(); + } }, async loadMore() { if (this.filter.status === 'running') { @@ -726,6 +741,8 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, this.isDataLoading = false; this.workflowsStore.addToCurrentExecutions(data.results); + + this.adjustSelectionAfterMoreItemsLoaded(); }, async loadWorkflows() { try { @@ -919,6 +936,11 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, try { await this.restApi().deleteExecutions({ ids: [execution.id] }); await this.refreshData(); + + if (this.allVisibleSelected) { + Vue.set(this, 'selectedItems', {}); + this.selectAllVisibleExecutions(); + } } catch (error) { this.$showError( error, @@ -936,6 +958,17 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, isRunning(execution: IExecutionsSummary): boolean { return this.getStatus(execution) === 'running'; }, + selectAllVisibleExecutions() { + this.combinedExecutions.forEach((execution: IExecutionsSummary) => { + Vue.set(this.selectedItems, execution.id, true); + }); + }, + adjustSelectionAfterMoreItemsLoaded() { + if (this.allExistingSelected) { + this.allVisibleSelected = true; + this.selectAllVisibleExecutions(); + } + }, }, }, ); @@ -969,7 +1002,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, z-index: 2; left: 50%; transform: translateX(-50%); - bottom: var(--spacing-xl); + bottom: var(--spacing-3xl); background: var(--color-background-dark); border-radius: var(--border-radius-base); color: var(--color-text-xlight); @@ -1141,7 +1174,7 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, } .loadMore { - margin: var(--spacing-l) 0; + margin: var(--spacing-m) 0; width: 100%; text-align: center; } @@ -1159,4 +1192,10 @@ export default mixins(externalHooks, genericHelpers, executionHelpers, restApi, .retryAction + .deleteAction { border-top: 1px solid var(--color-foreground-light); } + +.selectAll { + display: inline-block; + margin: 0 0 var(--spacing-s) var(--spacing-s); + color: var(--color-danger); +} diff --git a/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts new file mode 100644 index 0000000000..aaba8382aa --- /dev/null +++ b/packages/editor-ui/src/components/__tests__/ExecutionsList.test.ts @@ -0,0 +1,167 @@ +import { vi, describe, it, expect } from 'vitest'; +import Vue from 'vue'; +import { PiniaVuePlugin } from 'pinia'; +import { createTestingPinia } from '@pinia/testing'; +import { render } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { faker } from '@faker-js/faker'; +import { STORES } from '@/constants'; +import ExecutionsList from '@/components/ExecutionsList.vue'; +import { externalHooks } from '@/mixins/externalHooks'; +import { genericHelpers } from '@/mixins/genericHelpers'; +import { executionHelpers } from '@/mixins/executionsHelpers'; +import { showMessage } from '@/mixins/showMessage'; +import { i18nInstance } from '@/plugins/i18n'; +import type { IWorkflowShortResponse } from '@/Interface'; +import type { IExecutionsSummary } from 'n8n-workflow'; + +const waitAllPromises = () => new Promise((resolve) => setTimeout(resolve)); + +const workflowDataFactory = (): IWorkflowShortResponse => ({ + createdAt: faker.date.past().toDateString(), + updatedAt: faker.date.past().toDateString(), + id: faker.datatype.uuid(), + name: faker.datatype.string(), + active: faker.datatype.boolean(), + tags: [], +}); + +const executionDataFactory = (): IExecutionsSummary => ({ + id: faker.datatype.uuid(), + finished: faker.datatype.boolean(), + mode: faker.helpers.arrayElement(['manual', 'trigger']), + startedAt: faker.date.past(), + stoppedAt: faker.date.past(), + workflowId: faker.datatype.number().toString(), + workflowName: faker.datatype.string(), + status: faker.helpers.arrayElement(['failed', 'success']), + nodeExecutionStatus: {}, +}); + +const workflowsData = Array.from({ length: 10 }, workflowDataFactory); + +const executionsData = Array.from({ length: 2 }, () => ({ + count: 20, + results: Array.from({ length: 10 }, executionDataFactory), + estimated: false, +})); + +let getPastExecutionsSpy = vi.fn().mockResolvedValue({ count: 0, results: [], estimated: false }); + +const mockRestApiMixin = Vue.extend({ + methods: { + restApi() { + return { + getWorkflows: vi.fn().mockResolvedValue(workflowsData), + getCurrentExecutions: vi.fn().mockResolvedValue([]), + getPastExecutions: getPastExecutionsSpy, + }; + }, + }, +}); + +const renderOptions = { + pinia: createTestingPinia({ + initialState: { + [STORES.SETTINGS]: { + settings: { + templates: { + enabled: true, + host: 'https://api.n8n.io/api/', + }, + }, + }, + }, + }), + i18n: i18nInstance, + stubs: ['font-awesome-icon'], + mixins: [externalHooks, genericHelpers, executionHelpers, showMessage, mockRestApiMixin], +}; + +function TelemetryPlugin(vue: typeof Vue): void { + Object.defineProperty(vue, '$telemetry', { + get() { + return { + track: () => {}, + }; + }, + }); + Object.defineProperty(vue.prototype, '$telemetry', { + get() { + return { + track: () => {}, + }; + }, + }); +} + +const renderComponent = async () => { + const renderResult = render(ExecutionsList, renderOptions); + await waitAllPromises(); + return renderResult; +}; + +Vue.use(TelemetryPlugin); +Vue.use(PiniaVuePlugin); + +describe('ExecutionsList.vue', () => { + it('should render empty list', async () => { + const { queryAllByTestId, queryByTestId, getByTestId } = await renderComponent(); + await userEvent.click(getByTestId('execution-auto-refresh-checkbox')); + + expect(queryAllByTestId('select-execution-checkbox').length).toBe(0); + expect(queryByTestId('load-more-button')).not.toBeInTheDocument(); + expect(queryByTestId('select-all-executions-checkbox')).not.toBeInTheDocument(); + expect(getByTestId('execution-list-empty')).toBeInTheDocument(); + }); + + it('should handle selection flow when loading more items', async () => { + getPastExecutionsSpy = vi + .fn() + .mockResolvedValueOnce(executionsData[0]) + .mockResolvedValueOnce(executionsData[1]); + + const { getByTestId, getAllByTestId, queryByTestId } = await renderComponent(); + await userEvent.click(getByTestId('execution-auto-refresh-checkbox')); + + await userEvent.click(getByTestId('select-visible-executions-checkbox')); + + expect(getPastExecutionsSpy).toHaveBeenCalledTimes(1); + expect( + getAllByTestId('select-execution-checkbox').filter((el) => + el.contains(el.querySelector(':checked')), + ).length, + ).toBe(10); + expect(getByTestId('select-all-executions-checkbox')).toBeInTheDocument(); + expect(getByTestId('selected-executions-info').textContent).toContain(10); + + await userEvent.click(getByTestId('load-more-button')); + + expect(getAllByTestId('select-execution-checkbox').length).toBe(20); + expect( + getAllByTestId('select-execution-checkbox').filter((el) => + el.contains(el.querySelector(':checked')), + ).length, + ).toBe(10); + + await userEvent.click(getByTestId('select-all-executions-checkbox')); + expect(getAllByTestId('select-execution-checkbox').length).toBe(20); + expect( + getAllByTestId('select-execution-checkbox').filter((el) => + el.contains(el.querySelector(':checked')), + ).length, + ).toBe(20); + expect(getByTestId('selected-executions-info').textContent).toContain(20); + + await userEvent.click(getAllByTestId('select-execution-checkbox')[2]); + expect(getAllByTestId('select-execution-checkbox').length).toBe(20); + expect( + getAllByTestId('select-execution-checkbox').filter((el) => + el.contains(el.querySelector(':checked')), + ).length, + ).toBe(19); + expect(getByTestId('selected-executions-info').textContent).toContain(19); + expect(getByTestId('select-visible-executions-checkbox')).toBeInTheDocument(); + expect(queryByTestId('select-all-executions-checkbox')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/editor-ui/src/plugins/i18n/locales/en.json b/packages/editor-ui/src/plugins/i18n/locales/en.json index d761c5a869..1e7d1f6d95 100644 --- a/packages/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/editor-ui/src/plugins/i18n/locales/en.json @@ -454,6 +454,7 @@ "executionsList.error": "Failed", "executionsList.filters": "Filters", "executionsList.loadMore": "Load More", + "executionsList.empty": "No executions", "executionsList.loadedAll": "No more executions to fetch", "executionsList.modes.error": "error", "executionsList.modes.integrated": "integrated", @@ -471,7 +472,8 @@ "executionsList.succeeded": "Succeeded", "executionsList.selectStatus": "Select Status", "executionsList.selectWorkflow": "Select Workflow", - "executionsList.selected": "{numSelected} execution selected:", + "executionsList.selected": "{numSelected} execution selected: | {numSelected} executions selected:", + "executionsList.selectAll": "Select {executionNum} finished execution | Select all {executionNum} finished executions", "executionsList.test": "Test execution", "executionsList.showError.handleDeleteSelected.title": "Problem deleting executions", "executionsList.showError.loadMore.title": "Problem loading executions", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec0e3abd71..d4bd3329da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -573,6 +573,7 @@ importers: '@jsplumb/util': ^5.13.2 '@pinia/testing': ^0.0.14 '@testing-library/jest-dom': ^5.16.5 + '@testing-library/user-event': ^14.4.3 '@testing-library/vue': ^5.8.3 '@types/dateformat': ^3.0.0 '@types/express': ^4.17.6 @@ -696,6 +697,7 @@ importers: '@faker-js/faker': 7.6.0 '@pinia/testing': 0.0.14_pinia@2.0.23+vue@2.7.14 '@testing-library/jest-dom': 5.16.5 + '@testing-library/user-event': 14.4.3_7izb363m7fjrh7ob6q4a2yqaqe '@testing-library/vue': 5.8.3_rhqkolmkwunxzlyyxxsuwaiuri '@types/dateformat': 3.0.1 '@types/express': 4.17.14 @@ -5304,6 +5306,15 @@ packages: redent: 3.0.0 dev: true + /@testing-library/user-event/14.4.3_7izb363m7fjrh7ob6q4a2yqaqe: + resolution: {integrity: sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 7.31.2 + dev: true + /@testing-library/vue/5.8.3_rhqkolmkwunxzlyyxxsuwaiuri: resolution: {integrity: sha512-M6+QqP1xuFHixKOeXF9pCLbtiyJZRKfJRP+unBf6Ljm7aS1V2CSS95oTetFoblaj0W1+AC9XJgwmUDtlLoaakQ==} engines: {node: '>10.18'}