mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Preserve workflow list sort & page size preferences (#15101)
This commit is contained in:
committed by
GitHub
parent
c76245519c
commit
cf03a28774
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user