feat(editor): Preserve workflow list sort & page size preferences (#15101)

This commit is contained in:
Milorad FIlipović
2025-05-06 16:14:06 +02:00
committed by GitHub
parent c76245519c
commit cf03a28774
8 changed files with 520 additions and 69 deletions

View File

@@ -240,10 +240,10 @@ onClickOutside(
</N8nOption>
<N8nOption v-else-if="options.length === 0" value="message" disabled>
<span v-if="createEnabled">{{ i18n.baseText('tagsDropdown.typeToCreateATag') }}</span>
<span v-if="allTags.length > 0">{{
<span v-else-if="allTags.length > 0">{{
i18n.baseText('tagsDropdown.noMatchingTagsExist')
}}</span>
<span v-else-if="filter">{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
<span v-else>{{ i18n.baseText('tagsDropdown.noTagsExist') }}</span>
</N8nOption>
<N8nOption

View File

@@ -1,8 +1,50 @@
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import ResourcesListLayout, { type Resource } from '@/components/layouts/ResourcesListLayout.vue';
import type router from 'vue-router';
import type { ProjectSharingData } from 'n8n-workflow';
import { waitAllPromises } from '@/__tests__/utils';
const TEST_HOME_PROJECT: ProjectSharingData = vi.hoisted(() => ({
id: '1',
type: 'team',
name: 'Test Project 1',
icon: {
type: 'emoji',
value: '🗂️',
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
const TEST_LOCAL_STORAGE_VALUES = vi.hoisted(() => ({
sort: 'name',
pageSize: 50,
}));
const TEST_WORKFLOWS: Resource[] = vi.hoisted(() => [
{
resourceType: 'workflow',
id: '1',
name: 'Test Workflow 1',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
active: true,
readOnly: false,
homeProject: TEST_HOME_PROJECT,
},
{
resourceType: 'workflow',
id: '2',
name: 'Test Workflow 2',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
active: true,
readOnly: false,
homeProject: TEST_HOME_PROJECT,
},
]);
vi.mock('@/composables/useGlobalEntityCreation', () => ({
useGlobalEntityCreation: () => ({
@@ -10,18 +52,42 @@ vi.mock('@/composables/useGlobalEntityCreation', () => ({
}),
}));
vi.mock('@/composables/useN8nLocalStorage', () => {
const loadProjectPreferencesFromLocalStorage = vi.fn().mockReturnValue(TEST_LOCAL_STORAGE_VALUES);
const getProjectKey = vi.fn(() => TEST_HOME_PROJECT.id);
return {
useN8nLocalStorage: () => ({
loadProjectPreferencesFromLocalStorage,
getProjectKey,
}),
};
});
vi.mock('vue-router', async (importOriginal) => {
const { RouterLink } = await importOriginal<typeof router>();
return {
RouterLink,
useRoute: () => ({
params: {},
params: {
projectId: TEST_HOME_PROJECT.id,
},
path: `/projects/${TEST_HOME_PROJECT.id}/workflows`,
query: {},
}),
useRouter: vi.fn(),
};
});
const renderComponent = createComponentRenderer(ResourcesListLayout);
const renderComponent = createComponentRenderer(ResourcesListLayout, {
props: {
resourceKey: 'workflows',
resources: [],
disabled: false,
typeProps: { itemSize: 80 },
loading: false,
},
});
describe('ResourcesListLayout', () => {
beforeEach(() => {
@@ -38,4 +104,49 @@ describe('ResourcesListLayout', () => {
expect(container.querySelectorAll('.el-skeleton__p')).toHaveLength(25);
});
it('should render scrollable list based on `type` prop', () => {
const { getByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-full',
},
});
expect(getByTestId('resources-list-wrapper')).toBeTruthy();
expect(getByTestId('resources-list')).toBeTruthy();
});
it('should render paginated list based on `type` prop', () => {
const { getByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
},
});
expect(getByTestId('resources-list-wrapper')).toBeTruthy();
expect(getByTestId('paginated-list')).toBeTruthy();
});
it('should render paginated table list based on `type` prop', () => {
const { getByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'datatable',
},
});
expect(getByTestId('resources-list-wrapper')).toBeTruthy();
expect(getByTestId('resources-table')).toBeTruthy();
});
it('should load sort & page size from local storage', async () => {
const { emitted } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
},
});
await waitAllPromises();
expect(emitted()['update:pagination-and-sort']).toBeTruthy();
expect(emitted()['update:pagination-and-sort'].pop()).toEqual([TEST_LOCAL_STORAGE_VALUES]);
});
});

View File

@@ -16,6 +16,7 @@ import type { BaseTextKey } from '@/plugins/i18n';
import type { Scope } from '@n8n/permissions';
import type { BaseFolderItem, BaseResource, ITag, ResourceParentFolder } from '@/Interface';
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
import { useN8nLocalStorage } from '@/composables/useN8nLocalStorage';
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
@@ -63,12 +64,19 @@ export type BaseFilters = {
[key: string]: boolean | string | string[];
};
export type SortingAndPaginationUpdates = {
page?: number;
pageSize?: number;
sort?: string;
};
const route = useRoute();
const router = useRouter();
const i18n = useI18n();
const { callDebounced } = useDebounce();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
const n8nLocalStorage = useN8nLocalStorage();
const props = withDefaults(
defineProps<{
@@ -126,12 +134,15 @@ const rowsPerPage = ref<number>(props.customPageSize);
const resettingFilters = ref(false);
const search = ref<HTMLElement | null>(null);
// Preferred sorting and page size
// These refs store the values that are set by the user and preserved in local storage
const preferredPageSize = ref<number>(props.customPageSize);
const preferredSort = ref<string>(props.sortOptions[0]);
const emit = defineEmits<{
'update:filters': [value: BaseFilters];
'click:add': [event: Event];
'update:current-page': [page: number];
'update:page-size': [pageSize: number];
sort: [value: string];
'update:pagination-and-sort': [value: SortingAndPaginationUpdates];
'update:search': [value: string];
}>();
@@ -286,24 +297,18 @@ watch(
},
);
watch(
() => sortBy.value,
(newValue) => {
emit('sort', newValue);
sendSortingTelemetry();
},
);
watch(
() => route?.params?.projectId,
async () => {
await resetFilters();
await loadPaginationPreferences();
await props.initialize();
},
);
// Lifecycle hooks
onMounted(async () => {
await loadPaginationFromQueryString();
await loadPaginationPreferences();
await props.initialize();
await nextTick();
@@ -350,21 +355,33 @@ const hasAppliedFilters = (): boolean => {
const setRowsPerPage = async (numberOfRowsPerPage: number) => {
rowsPerPage.value = numberOfRowsPerPage;
await savePaginationToQueryString();
emit('update:page-size', numberOfRowsPerPage);
await savePaginationPreferences();
emit('update:pagination-and-sort', {
pageSize: numberOfRowsPerPage,
});
};
const setCurrentPage = async (page: number) => {
const setSorting = async (sort: string, persistUpdate = true) => {
sortBy.value = sort;
if (persistUpdate) {
await savePaginationPreferences();
}
emit('update:pagination-and-sort', {
sort,
});
sendSortingTelemetry();
};
const setCurrentPage = async (page: number, persistUpdate = true) => {
currentPage.value = page;
await savePaginationToQueryString();
emit('update:current-page', page);
if (persistUpdate) {
await savePaginationPreferences();
}
emit('update:pagination-and-sort', {
page,
});
};
defineExpose({
currentPage,
setCurrentPage,
});
const sendFiltersTelemetry = (source: string) => {
// Prevent sending multiple telemetry events when resetting filters
// Timeout is required to wait for search debounce to be over
@@ -409,7 +426,7 @@ const resetFilters = async () => {
});
// Reset the current page
await setCurrentPage(1);
await setCurrentPage(1, false);
resettingFilters.value = true;
hasFilters.value = false;
@@ -452,7 +469,10 @@ const findNearestPageSize = (size: number): number => {
);
};
const savePaginationToQueryString = async () => {
/**
* Saves the current pagination preferences to local storage and updates the URL query parameters.
*/
const savePaginationPreferences = async () => {
// For now, only available for paginated lists
if (props.type !== 'list-paginated') {
return;
@@ -466,38 +486,95 @@ const savePaginationToQueryString = async () => {
delete currentQuery.page;
}
if (rowsPerPage.value !== props.customPageSize) {
// Only update sort & page size if they are different from the default values
// otherwise, remove them from the query
if (rowsPerPage.value !== preferredPageSize.value) {
currentQuery.pageSize = rowsPerPage.value.toString();
preferredPageSize.value = rowsPerPage.value;
} else {
delete currentQuery.pageSize;
}
if (sortBy.value !== preferredSort.value) {
currentQuery.sort = sortBy.value;
preferredSort.value = sortBy.value;
} else {
delete currentQuery.sort;
}
n8nLocalStorage.saveProjectPreferencesToLocalStorage(
(route.params.projectId as string) ?? '',
'workflows',
{
sort: sortBy.value,
pageSize: rowsPerPage.value,
},
);
await router.replace({
query: Object.keys(currentQuery).length ? currentQuery : undefined,
});
};
const loadPaginationFromQueryString = async () => {
/**
* Loads the pagination preferences from local storage or URL query parameter
* Current page is only saved in the URL query parameters
* Page size and sort are saved both in local storage and URL query parameters, with query parameters taking precedence
*/
const loadPaginationPreferences = async () => {
// For now, only available for paginated lists
if (props.type !== 'list-paginated') {
return;
}
const query = router.currentRoute.value.query;
const query = route.query;
// For now, only load workflow list preferences from local storage
const localStorageValues = n8nLocalStorage.loadProjectPreferencesFromLocalStorage(
(route.params.projectId as string) ?? '',
'workflows',
);
const emitPayload: SortingAndPaginationUpdates = {};
if (query.page) {
await setCurrentPage(parseInt(query.page as string, 10));
const newPage = parseInt(query.page as string, 10);
if (newPage > 1) {
currentPage.value = newPage;
emitPayload.page = newPage;
}
}
if (query.pageSize) {
const parsedSize = parseInt(query.pageSize as string, 10);
if (query.pageSize ?? localStorageValues.pageSize) {
const parsedSize = parseInt(
(query.pageSize as string) || String(localStorageValues.pageSize),
10,
);
// Round to the nearest available page size, this will prevent users from passing arbitrary values
await setRowsPerPage(findNearestPageSize(parsedSize));
const newPageSize = findNearestPageSize(parsedSize);
rowsPerPage.value = newPageSize;
emitPayload.pageSize = newPageSize;
preferredPageSize.value = newPageSize;
} else {
rowsPerPage.value = props.customPageSize;
emitPayload.pageSize = props.customPageSize;
}
if (query.sort) {
sortBy.value = query.sort as string;
// Update the sortBy value and emit the event based on the query parameter
sortBy.value = emitPayload.sort = preferredSort.value = query.sort as string;
} else if (localStorageValues.sort) {
await setSorting(localStorageValues.sort, false);
emitPayload.sort = localStorageValues.sort;
preferredSort.value = localStorageValues.sort;
} else {
sortBy.value = props.sortOptions[0];
}
emit('update:pagination-and-sort', emitPayload);
};
defineExpose({
currentPage,
setCurrentPage,
});
</script>
<template>
@@ -556,7 +633,12 @@ const loadPaginationFromQueryString = async () => {
</template>
</n8n-input>
<div :class="$style['sort-and-filter']">
<n8n-select v-model="sortBy" size="small" data-test-id="resources-list-sort">
<n8n-select
v-model="sortBy"
size="small"
data-test-id="resources-list-sort"
@change="setSorting(sortBy)"
>
<n8n-option
v-for="sortOption in sortOptions"
:key="sortOption"
@@ -624,7 +706,11 @@ const loadPaginationFromQueryString = async () => {
</template>
</n8n-recycle-scroller>
<!-- PAGINATED LIST -->
<div v-else-if="type === 'list-paginated'" :class="$style.paginatedListWrapper">
<div
v-else-if="type === 'list-paginated'"
:class="$style.paginatedListWrapper"
data-test-id="paginated-list"
>
<div :class="$style.listItems">
<div v-for="(item, index) in resources" :key="index" :class="$style.listItem">
<slot name="item" :item="item" :index="index">