+
{{ $locale.baseText('executionsList.autoRefresh') }}
+
+
|
{{ $locale.baseText('executionsList.name') }} |
@@ -66,9 +85,10 @@
|
@@ -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'}