refactor(editor): ResourceListLayout to script setup (#11526)

This commit is contained in:
Raúl Gómez Morales 2024-11-04 13:07:21 +01:00 committed by GitHub
parent e10968b26f
commit 5f3deea60f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 336 additions and 380 deletions

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts" setup>
import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue'; import { computed, nextTick, ref, onMounted, watch } from 'vue';
import type { PropType } from 'vue';
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types'; import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
@ -15,7 +14,6 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
import type { BaseTextKey } from '@/plugins/i18n'; import type { BaseTextKey } from '@/plugins/i18n';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
@ -32,365 +30,325 @@ export type IResource = {
sharedWithProjects?: ProjectSharingData[]; sharedWithProjects?: ProjectSharingData[];
}; };
interface IFilters { export interface IFilters {
search: string; search: string;
homeProject: string; homeProject: string;
[key: string]: boolean | string | string[]; [key: string]: boolean | string | string[];
} }
type IResourceKeyType = 'credentials' | 'workflows'; type IResourceKeyType = 'credentials' | 'workflows' | 'variables';
export default defineComponent({ const props = withDefaults(
name: 'ResourcesListLayout', defineProps<{
components: { resourceKey: IResourceKeyType;
PageViewLayout, displayName?: (resource: IResource) => string;
PageViewLayoutList, resources: IResource[];
ResourceFiltersDropdown, disabled: boolean;
ResourceListHeader, initialize: () => Promise<void>;
filters?: IFilters;
additionalFiltersHandler?: (
resource: IResource,
filters: IFilters,
matches: boolean,
) => boolean;
shareable?: boolean;
showFiltersDropdown?: boolean;
sortFns?: Record<string, (a: IResource, b: IResource) => number>;
sortOptions?: string[];
type?: 'datatable' | 'list';
typeProps: { itemSize: number } | { columns: DatatableColumn[] };
loading: boolean;
}>(),
{
displayName: (resource: IResource) => resource.name,
initialize: async () => {},
filters: () => ({ search: '', homeProject: '' }),
sortFns: () => ({}),
sortOptions: () => ['lastUpdated', 'lastCreated', 'nameAsc', 'nameDesc'],
type: 'list',
typeProps: () => ({ itemSize: 80 }),
loading: true,
additionalFiltersHandler: undefined,
showFiltersDropdown: true,
shareable: true,
}, },
props: { );
resourceKey: {
type: String, const emit = defineEmits<{
default: '' as IResourceKeyType, 'update:filters': [value: IFilters];
}, 'click:add': [event: Event];
displayName: { sort: [value: string];
type: Function as PropType<(resource: IResource) => string>, }>();
default: (resource: IResource) => resource.name,
}, defineSlots<{
resources: { header(): unknown;
type: Array as PropType<IResource[]>, empty(): unknown;
default: (): IResource[] => [], preamble(): unknown;
}, postamble(): unknown;
disabled: { 'add-button'(props: { disabled: boolean }): unknown;
type: Boolean, callout(): unknown;
default: false, filters(props: {
}, filters: Record<string, boolean | string | string[]>;
initialize: { setKeyValue: (key: string, value: unknown) => void;
type: Function as PropType<() => Promise<void>>, }): unknown;
default: () => async () => {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any
}, default(props: { data: any; updateItemSize: (data: any) => void }): unknown;
filters: { }>();
type: Object,
default: (): IFilters => ({ search: '', homeProject: '' }), const route = useRoute();
}, const i18n = useI18n();
additionalFiltersHandler: { const { callDebounced } = useDebounce();
type: Function, const usersStore = useUsersStore();
required: false, const projectsStore = useProjectsStore();
default: undefined, const telemetry = useTelemetry();
},
shareable: { const sortBy = ref(props.sortOptions[0]);
type: Boolean, const hasFilters = ref(false);
default: true, const filtersModel = ref(props.filters);
}, const currentPage = ref(1);
showFiltersDropdown: { const rowsPerPage = ref<number>(10);
type: Boolean, const resettingFilters = ref(false);
default: true, const search = ref<HTMLElement | null>(null);
},
sortFns: { //computed
type: Object as PropType<Record<string, (a: IResource, b: IResource) => number>>,
default: (): Record<string, (a: IResource, b: IResource) => number> => ({}), const filterKeys = computed(() => {
}, return Object.keys(filtersModel.value);
sortOptions: { });
type: Array as PropType<string[]>,
default: () => ['lastUpdated', 'lastCreated', 'nameAsc', 'nameDesc'], const filteredAndSortedResources = computed(() => {
}, const filtered = props.resources.filter((resource) => {
type: { let matches = true;
type: String as PropType<'datatable' | 'list'>,
default: 'list', if (filtersModel.value.homeProject) {
}, matches =
typeProps: { matches &&
type: Object as PropType<{ itemSize: number } | { columns: DatatableColumn[] }>, !!(resource.homeProject && resource.homeProject.id === filtersModel.value.homeProject);
default: () => ({ }
itemSize: 80,
}), if (filtersModel.value.search) {
}, const searchString = filtersModel.value.search.toLowerCase();
loading: { matches = matches && props.displayName(resource).toLowerCase().includes(searchString);
type: Boolean, }
required: false,
default: true, if (props.additionalFiltersHandler) {
}, matches = props.additionalFiltersHandler(resource, filtersModel.value, matches);
}
return matches;
});
return filtered.sort((a, b) => {
switch (sortBy.value) {
case 'lastUpdated':
return props.sortFns.lastUpdated
? props.sortFns.lastUpdated(a, b)
: new Date(b.updatedAt ?? '').valueOf() - new Date(a.updatedAt ?? '').valueOf();
case 'lastCreated':
return props.sortFns.lastCreated
? props.sortFns.lastCreated(a, b)
: new Date(b.createdAt ?? '').valueOf() - new Date(a.createdAt ?? '').valueOf();
case 'nameAsc':
return props.sortFns.nameAsc
? props.sortFns.nameAsc(a, b)
: props.displayName(a).trim().localeCompare(props.displayName(b).trim());
case 'nameDesc':
return props.sortFns.nameDesc
? props.sortFns.nameDesc(a, b)
: props.displayName(b).trim().localeCompare(props.displayName(a).trim());
default:
return props.sortFns[sortBy.value] ? props.sortFns[sortBy.value](a, b) : 0;
}
});
});
//methods
const focusSearchInput = () => {
if (search.value) {
search.value.focus();
}
};
const hasAppliedFilters = (): boolean => {
return !!filterKeys.value.find(
(key) =>
key !== 'search' &&
(Array.isArray(props.filters[key])
? props.filters[key].length > 0
: props.filters[key] !== ''),
);
};
const setRowsPerPage = (numberOfRowsPerPage: number) => {
rowsPerPage.value = numberOfRowsPerPage;
};
const setCurrentPage = (page: number) => {
currentPage.value = 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
if (resettingFilters.value) {
if (source !== 'reset') {
return;
}
setTimeout(() => (resettingFilters.value = false), 1500);
}
const filters = filtersModel.value as Record<string, string[] | string | boolean>;
const filtersSet: string[] = [];
const filterValues: Array<string[] | string | boolean | null> = [];
Object.keys(filters).forEach((key) => {
if (filters[key]) {
filtersSet.push(key);
filterValues.push(key === 'search' ? null : filters[key]);
}
});
telemetry.track(`User set filters in ${props.resourceKey} list`, {
filters_set: filtersSet,
filter_values: filterValues,
[`${props.resourceKey}_total_in_view`]: props.resources.length,
[`${props.resourceKey}_after_filtering`]: filteredAndSortedResources.value.length,
});
};
const onAddButtonClick = (e: Event) => {
emit('click:add', e);
};
const onUpdateFilters = (e: IFilters) => {
emit('update:filters', e);
};
const resetFilters = () => {
Object.keys(filtersModel.value).forEach((key) => {
filtersModel.value[key] = Array.isArray(filtersModel.value[key]) ? [] : '';
});
resettingFilters.value = true;
sendFiltersTelemetry('reset');
emit('update:filters', filtersModel.value);
};
const itemSize = () => {
if ('itemSize' in props.typeProps) {
return props.typeProps.itemSize;
}
return 0;
};
const getColumns = () => {
if ('columns' in props.typeProps) {
return props.typeProps.columns;
}
return {};
};
const sendSortingTelemetry = () => {
telemetry.track(`User changed sorting in ${props.resourceKey} list`, {
sorting: sortBy.value,
});
};
const onUpdateFiltersLength = (length: number) => {
hasFilters.value = length > 0;
};
const onSearch = (s: string) => {
filtersModel.value.search = s;
emit('update:filters', filtersModel.value);
};
//watchers
watch(
() => props.filters,
(value) => {
filtersModel.value = value;
}, },
emits: ['update:filters', 'click:add', 'sort'], );
setup(props, { emit }) {
const route = useRoute();
const i18n = useI18n();
const { callDebounced } = useDebounce();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const sortBy = ref(props.sortOptions[0]); watch(
const hasFilters = ref(false); () => filtersModel.value.homeProject,
const filtersModel = ref(props.filters); () => {
const currentPage = ref(1); sendFiltersTelemetry('homeProject');
const rowsPerPage = ref<number>(10);
const resettingFilters = ref(false);
const search = ref<HTMLElement | null>(null);
//computed
const filterKeys = computed(() => {
return Object.keys(filtersModel.value);
});
const filteredAndSortedResources = computed(() => {
const filtered = props.resources.filter((resource) => {
let matches = true;
if (filtersModel.value.homeProject) {
matches =
matches &&
!!(resource.homeProject && resource.homeProject.id === filtersModel.value.homeProject);
}
if (filtersModel.value.search) {
const searchString = filtersModel.value.search.toLowerCase();
matches = matches && props.displayName(resource).toLowerCase().includes(searchString);
}
if (props.additionalFiltersHandler) {
matches = props.additionalFiltersHandler(resource, filtersModel.value, matches);
}
return matches;
});
return filtered.sort((a, b) => {
switch (sortBy.value) {
case 'lastUpdated':
return props.sortFns.lastUpdated
? props.sortFns.lastUpdated(a, b)
: new Date(b.updatedAt ?? '').valueOf() - new Date(a.updatedAt ?? '').valueOf();
case 'lastCreated':
return props.sortFns.lastCreated
? props.sortFns.lastCreated(a, b)
: new Date(b.createdAt ?? '').valueOf() - new Date(a.createdAt ?? '').valueOf();
case 'nameAsc':
return props.sortFns.nameAsc
? props.sortFns.nameAsc(a, b)
: props.displayName(a).trim().localeCompare(props.displayName(b).trim());
case 'nameDesc':
return props.sortFns.nameDesc
? props.sortFns.nameDesc(a, b)
: props.displayName(b).trim().localeCompare(props.displayName(a).trim());
default:
return props.sortFns[sortBy.value] ? props.sortFns[sortBy.value](a, b) : 0;
}
});
});
//methods
const focusSearchInput = () => {
if (search.value) {
search.value.focus();
}
};
const hasAppliedFilters = (): boolean => {
return !!filterKeys.value.find(
(key) =>
key !== 'search' &&
(Array.isArray(props.filters[key])
? props.filters[key].length > 0
: props.filters[key] !== ''),
);
};
const setRowsPerPage = (numberOfRowsPerPage: number) => {
rowsPerPage.value = numberOfRowsPerPage;
};
const setCurrentPage = (page: number) => {
currentPage.value = page;
};
const sendFiltersTelemetry = (source: string) => {
// Prevent sending multiple telemetry events when resetting filters
// Timeout is required to wait for search debounce to be over
if (resettingFilters.value) {
if (source !== 'reset') {
return;
}
setTimeout(() => (resettingFilters.value = false), 1500);
}
const filters = filtersModel.value as Record<string, string[] | string | boolean>;
const filtersSet: string[] = [];
const filterValues: Array<string[] | string | boolean | null> = [];
Object.keys(filters).forEach((key) => {
if (filters[key]) {
filtersSet.push(key);
filterValues.push(key === 'search' ? null : filters[key]);
}
});
telemetry.track(`User set filters in ${props.resourceKey} list`, {
filters_set: filtersSet,
filter_values: filterValues,
[`${props.resourceKey}_total_in_view`]: props.resources.length,
[`${props.resourceKey}_after_filtering`]: filteredAndSortedResources.value.length,
});
};
const onAddButtonClick = (e: Event) => {
emit('click:add', e);
};
const onUpdateFilters = (e: Event) => {
emit('update:filters', e);
};
const resetFilters = () => {
Object.keys(filtersModel.value).forEach((key) => {
filtersModel.value[key] = Array.isArray(filtersModel.value[key]) ? [] : '';
});
resettingFilters.value = true;
sendFiltersTelemetry('reset');
emit('update:filters', filtersModel.value);
};
const itemSize = () => {
if ('itemSize' in props.typeProps) {
return props.typeProps.itemSize;
}
return 0;
};
const getColumns = () => {
if ('columns' in props.typeProps) {
return props.typeProps.columns;
}
return {};
};
const sendSortingTelemetry = () => {
telemetry.track(`User changed sorting in ${props.resourceKey} list`, {
sorting: sortBy.value,
});
};
const onUpdateFiltersLength = (length: number) => {
hasFilters.value = length > 0;
};
const onSearch = (s: string) => {
filtersModel.value.search = s;
emit('update:filters', filtersModel.value);
};
//watchers
watch(
() => props.filters,
(value) => {
filtersModel.value = value;
},
);
watch(
() => filtersModel.value.homeProject,
() => {
sendFiltersTelemetry('homeProject');
},
);
watch(
() => filtersModel.value.tags,
() => {
sendFiltersTelemetry('tags');
},
);
watch(
() => filtersModel.value.type,
() => {
sendFiltersTelemetry('type');
},
);
watch(
() => filtersModel.value.search,
() => callDebounced(sendFiltersTelemetry, { debounceTime: 1000, trailing: true }, 'search'),
);
watch(
() => sortBy.value,
(newValue) => {
emit('sort', newValue);
sendSortingTelemetry();
},
);
watch(
() => route?.params?.projectId,
() => {
resetFilters();
},
);
onMounted(async () => {
await props.initialize();
await nextTick();
focusSearchInput();
if (hasAppliedFilters()) {
hasFilters.value = true;
}
});
const headerIcon = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return 'user';
} else if (projectsStore.currentProject?.name) {
return 'layer-group';
} else {
return 'home';
}
});
const projectName = computed(() => {
if (!projectsStore.currentProject) {
return i18n.baseText('projects.menu.home');
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
} else {
return projectsStore.currentProject.name;
}
});
return {
i18n,
search,
usersStore,
projectsStore,
filterKeys,
currentPage,
rowsPerPage,
filteredAndSortedResources,
hasFilters,
sortBy,
resettingFilters,
filtersModel,
sendFiltersTelemetry,
getColumns,
itemSize,
onAddButtonClick,
onUpdateFiltersLength,
onUpdateFilters,
resetFilters,
callDebounced,
setCurrentPage,
setRowsPerPage,
onSearch,
headerIcon,
projectName,
};
}, },
);
watch(
() => filtersModel.value.tags,
() => {
sendFiltersTelemetry('tags');
},
);
watch(
() => filtersModel.value.type,
() => {
sendFiltersTelemetry('type');
},
);
watch(
() => filtersModel.value.search,
() => callDebounced(sendFiltersTelemetry, { debounceTime: 1000, trailing: true }, 'search'),
);
watch(
() => sortBy.value,
(newValue) => {
emit('sort', newValue);
sendSortingTelemetry();
},
);
watch(
() => route?.params?.projectId,
() => {
resetFilters();
},
);
onMounted(async () => {
await props.initialize();
await nextTick();
focusSearchInput();
if (hasAppliedFilters()) {
hasFilters.value = true;
}
});
const headerIcon = computed(() => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return 'user';
} else if (projectsStore.currentProject?.name) {
return 'layer-group';
} else {
return 'home';
}
});
const projectName = computed(() => {
if (!projectsStore.currentProject) {
return i18n.baseText('projects.menu.home');
} else if (projectsStore.currentProject.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
} else {
return projectsStore.currentProject.name;
}
}); });
</script> </script>

View file

@ -2,8 +2,10 @@
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface'; import type { ICredentialsResponse, ICredentialTypeMap } from '@/Interface';
import type { IResource } from '@/components/layouts/ResourcesListLayout.vue'; import ResourcesListLayout, {
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue'; type IResource,
type IFilters,
} from '@/components/layouts/ResourcesListLayout.vue';
import CredentialCard from '@/components/CredentialCard.vue'; import CredentialCard from '@/components/CredentialCard.vue';
import type { ICredentialType } from 'n8n-workflow'; import type { ICredentialType } from 'n8n-workflow';
import { import {
@ -43,10 +45,10 @@ const router = useRouter();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
const i18n = useI18n(); const i18n = useI18n();
const filters = ref({ const filters = ref<IFilters>({
search: '', search: '',
homeProject: '', homeProject: '',
type: '', type: [],
}); });
const loading = ref(false); const loading = ref(false);
@ -123,13 +125,11 @@ watch(
}, },
); );
const onFilter = ( const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
resource: ICredentialsResponse, const iResource = resource as ICredentialsResponse;
filtersToApply: { type: string[]; search: string }, const filtersToApply = newFilters as IFilters & { type: string[] };
matches: boolean,
): boolean => {
if (filtersToApply.type.length > 0) { if (filtersToApply.type.length > 0) {
matches = matches && filtersToApply.type.includes(resource.type); matches = matches && filtersToApply.type.includes(iResource.type);
} }
if (filtersToApply.search) { if (filtersToApply.search) {
@ -137,8 +137,8 @@ const onFilter = (
matches = matches =
matches || matches ||
(credentialTypesById.value[resource.type] && (credentialTypesById.value[iResource.type] &&
credentialTypesById.value[resource.type].displayName.toLowerCase().includes(searchString)); credentialTypesById.value[iResource.type].displayName.toLowerCase().includes(searchString));
} }
return matches; return matches;

View file

@ -1,10 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, watch, ref } from 'vue'; import { computed, onMounted, watch, ref } from 'vue';
import ResourcesListLayout, { type IResource } from '@/components/layouts/ResourcesListLayout.vue'; import ResourcesListLayout, {
type IResource,
type IFilters,
} from '@/components/layouts/ResourcesListLayout.vue';
import WorkflowCard from '@/components/WorkflowCard.vue'; import WorkflowCard from '@/components/WorkflowCard.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue'; import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants'; import { EnterpriseEditionFeature, MORE_ONBOARDING_OPTIONS_EXPERIMENT, VIEWS } from '@/constants';
import type { ITag, IUser, IWorkflowDb } from '@/Interface'; import type { IUser, IWorkflowDb } from '@/Interface';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
@ -49,9 +52,7 @@ const uiStore = useUIStore();
const tagsStore = useTagsStore(); const tagsStore = useTagsStore();
const documentTitle = useDocumentTitle(); const documentTitle = useDocumentTitle();
interface Filters { interface Filters extends IFilters {
search: string;
homeProject: string;
status: string | boolean; status: string | boolean;
tags: string[]; tags: string[];
} }
@ -137,16 +138,13 @@ const emptyListDescription = computed(() => {
} }
}); });
const onFilter = ( const onFilter = (resource: IResource, newFilters: IFilters, matches: boolean): boolean => {
resource: IWorkflowDb, const iFilters = newFilters as Filters;
newFilters: { tags: string[]; search: string; status: string | boolean }, if (settingsStore.areTagsEnabled && iFilters.tags.length > 0) {
matches: boolean,
): boolean => {
if (settingsStore.areTagsEnabled && newFilters.tags.length > 0) {
matches = matches =
matches && matches &&
newFilters.tags.every((tag) => iFilters.tags.every((tag) =>
(resource.tags as ITag[])?.find((resourceTag) => (resource as IWorkflowDb).tags?.find((resourceTag) =>
typeof resourceTag === 'object' typeof resourceTag === 'object'
? `${resourceTag.id}` === `${tag}` ? `${resourceTag.id}` === `${tag}`
: `${resourceTag}` === `${tag}`, : `${resourceTag}` === `${tag}`,
@ -155,14 +153,14 @@ const onFilter = (
} }
if (newFilters.status !== '') { if (newFilters.status !== '') {
matches = matches && resource.active === newFilters.status; matches = matches && (resource as IWorkflowDb).active === newFilters.status;
} }
return matches; return matches;
}; };
// Methods // Methods
const onFiltersUpdated = (newFilters: Filters) => { const onFiltersUpdated = (newFilters: IFilters) => {
Object.assign(filters.value, newFilters); Object.assign(filters.value, newFilters);
}; };