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">
import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue';
import type { PropType } from 'vue';
<script lang="ts" setup>
import { computed, nextTick, ref, onMounted, watch } from 'vue';
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
@ -15,7 +14,6 @@ import { useTelemetry } from '@/composables/useTelemetry';
import { useRoute } from 'vue-router';
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 { Scope } from '@n8n/permissions';
@ -32,365 +30,325 @@ export type IResource = {
sharedWithProjects?: ProjectSharingData[];
};
interface IFilters {
export interface IFilters {
search: string;
homeProject: string;
[key: string]: boolean | string | string[];
}
type IResourceKeyType = 'credentials' | 'workflows';
type IResourceKeyType = 'credentials' | 'workflows' | 'variables';
export default defineComponent({
name: 'ResourcesListLayout',
components: {
PageViewLayout,
PageViewLayoutList,
ResourceFiltersDropdown,
ResourceListHeader,
const props = withDefaults(
defineProps<{
resourceKey: IResourceKeyType;
displayName?: (resource: IResource) => string;
resources: IResource[];
disabled: boolean;
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,
default: '' as IResourceKeyType,
},
displayName: {
type: Function as PropType<(resource: IResource) => string>,
default: (resource: IResource) => resource.name,
},
resources: {
type: Array as PropType<IResource[]>,
default: (): IResource[] => [],
},
disabled: {
type: Boolean,
default: false,
},
initialize: {
type: Function as PropType<() => Promise<void>>,
default: () => async () => {},
},
filters: {
type: Object,
default: (): IFilters => ({ search: '', homeProject: '' }),
},
additionalFiltersHandler: {
type: Function,
required: false,
default: undefined,
},
shareable: {
type: Boolean,
default: true,
},
showFiltersDropdown: {
type: Boolean,
default: true,
},
sortFns: {
type: Object as PropType<Record<string, (a: IResource, b: IResource) => number>>,
default: (): Record<string, (a: IResource, b: IResource) => number> => ({}),
},
sortOptions: {
type: Array as PropType<string[]>,
default: () => ['lastUpdated', 'lastCreated', 'nameAsc', 'nameDesc'],
},
type: {
type: String as PropType<'datatable' | 'list'>,
default: 'list',
},
typeProps: {
type: Object as PropType<{ itemSize: number } | { columns: DatatableColumn[] }>,
default: () => ({
itemSize: 80,
}),
},
loading: {
type: Boolean,
required: false,
default: true,
},
);
const emit = defineEmits<{
'update:filters': [value: IFilters];
'click:add': [event: Event];
sort: [value: string];
}>();
defineSlots<{
header(): unknown;
empty(): unknown;
preamble(): unknown;
postamble(): unknown;
'add-button'(props: { disabled: boolean }): unknown;
callout(): unknown;
filters(props: {
filters: Record<string, boolean | string | string[]>;
setKeyValue: (key: string, value: unknown) => void;
}): unknown;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
default(props: { data: any; updateItemSize: (data: any) => void }): unknown;
}>();
const route = useRoute();
const i18n = useI18n();
const { callDebounced } = useDebounce();
const usersStore = useUsersStore();
const projectsStore = useProjectsStore();
const telemetry = useTelemetry();
const sortBy = ref(props.sortOptions[0]);
const hasFilters = ref(false);
const filtersModel = ref(props.filters);
const currentPage = ref(1);
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;
};
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]);
const hasFilters = ref(false);
const filtersModel = ref(props.filters);
const currentPage = ref(1);
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.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;
}
});
</script>

View file

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

View file

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