refactor(editor): Fix type errors in ResourcesListLayout.vue (no-changelog) (#9461)

This commit is contained in:
Ricardo Espinoza 2024-05-20 09:01:38 -04:00 committed by GitHub
parent 87f965e905
commit 75919397d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 226 additions and 159 deletions

View file

@ -1,8 +1,6 @@
<template> <template>
<PageViewLayout> <PageViewLayout>
<template #header> <template #header> <slot name="header" /> </template>
<slot name="header" />
</template>
<div v-if="loading"> <div v-if="loading">
<n8n-loading :class="[$style['header-loading'], 'mb-l']" variant="custom" /> <n8n-loading :class="[$style['header-loading'], 'mb-l']" variant="custom" />
<n8n-loading :class="[$style['card-loading'], 'mb-2xs']" variant="custom" /> <n8n-loading :class="[$style['card-loading'], 'mb-2xs']" variant="custom" />
@ -16,18 +14,18 @@
emoji="👋" emoji="👋"
:heading=" :heading="
i18n.baseText( i18n.baseText(
usersStore.currentUser.firstName usersStore.currentUser?.firstName
? `${resourceKey}.empty.heading` ? (`${resourceKey}.empty.heading` as BaseTextKey)
: `${resourceKey}.empty.heading.userNotSetup`, : (`${resourceKey}.empty.heading.userNotSetup` as BaseTextKey),
{ {
interpolate: { name: usersStore.currentUser.firstName }, interpolate: { name: usersStore.currentUser?.firstName ?? '' },
}, },
) )
" "
:description="i18n.baseText(`${resourceKey}.empty.description`)" :description="i18n.baseText(`${resourceKey}.empty.description` as BaseTextKey)"
:button-text="i18n.baseText(`${resourceKey}.empty.button`)" :button-text="i18n.baseText(`${resourceKey}.empty.button` as BaseTextKey)"
button-type="secondary" button-type="secondary"
@click:button="$emit('click:add', $event)" @click:button="onAddButtonClick"
/> />
</slot> </slot>
</div> </div>
@ -39,7 +37,7 @@
ref="search" ref="search"
:model-value="filtersModel.search" :model-value="filtersModel.search"
:class="[$style['search'], 'mr-2xs']" :class="[$style['search'], 'mr-2xs']"
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder`)" :placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
clearable clearable
data-test-id="resources-list-search" data-test-id="resources-list-search"
@update:model-value="onSearch" @update:model-value="onSearch"
@ -54,7 +52,7 @@
:reset="resetFilters" :reset="resetFilters"
:model-value="filtersModel" :model-value="filtersModel"
:shareable="shareable" :shareable="shareable"
@update:model-value="$emit('update:filters', $event)" @update:model-value="onUpdateFilters"
@update:filters-length="onUpdateFiltersLength" @update:filters-length="onUpdateFiltersLength"
> >
<template #default="resourceFiltersSlotProps"> <template #default="resourceFiltersSlotProps">
@ -68,7 +66,7 @@
:key="sortOption" :key="sortOption"
data-test-id="resources-list-sort-item" data-test-id="resources-list-sort-item"
:value="sortOption" :value="sortOption"
:label="i18n.baseText(`${resourceKey}.sort.${sortOption}`)" :label="i18n.baseText(`${resourceKey}.sort.${sortOption}` as BaseTextKey)"
/> />
</n8n-select> </n8n-select>
</div> </div>
@ -78,9 +76,9 @@
size="large" size="large"
:disabled="disabled" :disabled="disabled"
data-test-id="resources-list-add" data-test-id="resources-list-add"
@click="$emit('click:add', $event)" @click="onAddButtonClick"
> >
{{ i18n.baseText(`${resourceKey}.add`) }} {{ i18n.baseText(`${resourceKey}.add` as BaseTextKey) }}
</n8n-button> </n8n-button>
</slot> </slot>
</div> </div>
@ -89,9 +87,9 @@
<div v-if="showFiltersDropdown" v-show="hasFilters" class="mt-xs"> <div v-if="showFiltersDropdown" v-show="hasFilters" class="mt-xs">
<n8n-info-tip :bold="false"> <n8n-info-tip :bold="false">
{{ i18n.baseText(`${resourceKey}.filters.active`) }} {{ i18n.baseText(`${resourceKey}.filters.active` as BaseTextKey) }}
<n8n-link data-test-id="workflows-filter-reset" size="small" @click="resetFilters"> <n8n-link data-test-id="workflows-filter-reset" size="small" @click="resetFilters">
{{ i18n.baseText(`${resourceKey}.filters.active.reset`) }} {{ i18n.baseText(`${resourceKey}.filters.active.reset` as BaseTextKey) }}
</n8n-link> </n8n-link>
</n8n-info-tip> </n8n-info-tip>
</div> </div>
@ -110,7 +108,7 @@
v-if="type === 'list'" v-if="type === 'list'"
data-test-id="resources-list" data-test-id="resources-list"
:items="filteredAndSortedResources" :items="filteredAndSortedResources"
:item-size="typeProps.itemSize" :item-size="itemSize()"
item-key="id" item-key="id"
> >
<template #default="{ item, updateItemSize }"> <template #default="{ item, updateItemSize }">
@ -121,10 +119,10 @@
</template> </template>
</n8n-recycle-scroller> </n8n-recycle-scroller>
<n8n-datatable <n8n-datatable
v-if="typeProps.columns" v-if="type === 'datatable'"
data-test-id="resources-table" data-test-id="resources-table"
:class="$style.datatable" :class="$style.datatable"
:columns="typeProps.columns" :columns="getColumns()"
:rows="filteredAndSortedResources" :rows="filteredAndSortedResources"
:current-page="currentPage" :current-page="currentPage"
:rows-per-page="rowsPerPage" :rows-per-page="rowsPerPage"
@ -138,7 +136,7 @@
</div> </div>
<n8n-text v-else color="text-base" size="medium" data-test-id="resources-list-empty"> <n8n-text v-else color="text-base" size="medium" data-test-id="resources-list-empty">
{{ i18n.baseText(`${resourceKey}.noResults`) }} {{ i18n.baseText(`${resourceKey}.noResults` as BaseTextKey) }}
</n8n-text> </n8n-text>
<slot name="postamble" /> <slot name="postamble" />
@ -148,20 +146,22 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent } from 'vue'; import { computed, defineComponent, nextTick, ref, onMounted, watch } from 'vue';
import type { PropType } from 'vue'; import type { PropType } from 'vue';
import { mapStores } from 'pinia';
import type { ProjectSharingData } from '@/features/projects/projects.types'; import type { ProjectSharingData } from '@/features/projects/projects.types';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue'; import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
import { EnterpriseEditionFeature } from '@/constants';
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue'; import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
import { useSettingsStore } from '@/stores/settings.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { N8nInput, DatatableColumn } from 'n8n-design-system'; import type { DatatableColumn } from 'n8n-design-system';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import { useTelemetry } from '@/composables/useTelemetry';
import { useRoute } from 'vue-router';
// eslint-disable-next-line unused-imports/no-unused-imports, @typescript-eslint/no-unused-vars
import type { BaseTextKey } from '@/plugins/i18n';
export interface IResource { export interface IResource {
id: string; id: string;
@ -178,7 +178,6 @@ interface IFilters {
} }
type IResourceKeyType = 'credentials' | 'workflows'; type IResourceKeyType = 'credentials' | 'workflows';
type SearchRef = InstanceType<typeof N8nInput>;
export default defineComponent({ export default defineComponent({
name: 'ResourcesListLayout', name: 'ResourcesListLayout',
@ -197,7 +196,7 @@ export default defineComponent({
default: (resource: IResource) => resource.name, default: (resource: IResource) => resource.name,
}, },
resources: { resources: {
type: Array, type: Array as PropType<IResource[]>,
default: (): IResource[] => [], default: (): IResource[] => [],
}, },
disabled: { disabled: {
@ -244,162 +243,113 @@ export default defineComponent({
}), }),
}, },
}, },
setup() { emits: ['update:filters', 'click:add', 'sort'],
setup(props, { emit }) {
const route = useRoute();
const i18n = useI18n(); const i18n = useI18n();
const { callDebounced } = useDebounce(); const { callDebounced } = useDebounce();
const usersStore = useUsersStore();
const telemetry = useTelemetry();
return { const loading = ref(true);
i18n, const sortBy = ref(props.sortOptions[0]);
callDebounced, const hasFilters = ref(false);
}; const filtersModel = ref(props.filters);
}, const currentPage = ref(1);
data() { const rowsPerPage = ref<number | '*'>(10);
return { const resettingFilters = ref(false);
loading: true, const search = ref<HTMLElement | null>(null);
sortBy: this.sortOptions[0],
hasFilters: false, //computed
filtersModel: { ...this.filters },
currentPage: 1, const filterKeys = computed(() => {
rowsPerPage: 10 as number | '*', return Object.keys(filtersModel.value);
resettingFilters: false, });
EnterpriseEditionFeature,
}; const filteredAndSortedResources = computed(() => {
}, const filtered = props.resources.filter((resource) => {
computed: {
...mapStores(useSettingsStore, useUsersStore),
filterKeys(): string[] {
return Object.keys(this.filtersModel);
},
filteredAndSortedResources(): IResource[] {
const filtered: IResource[] = this.resources.filter((resource: IResource) => {
let matches = true; let matches = true;
if (this.filtersModel.homeProject) { if (filtersModel.value.homeProject) {
matches = matches =
matches && matches &&
!!(resource.homeProject && resource.homeProject.id === this.filtersModel.homeProject); !!(resource.homeProject && resource.homeProject.id === filtersModel.value.homeProject);
} }
if (this.filtersModel.search) { if (filtersModel.value.search) {
const searchString = this.filtersModel.search.toLowerCase(); const searchString = filtersModel.value.search.toLowerCase();
matches = matches && props.displayName(resource).toLowerCase().includes(searchString);
matches = matches && this.displayName(resource).toLowerCase().includes(searchString);
} }
if (this.additionalFiltersHandler) { if (props.additionalFiltersHandler) {
matches = this.additionalFiltersHandler(resource, this.filtersModel, matches); matches = props.additionalFiltersHandler(resource, filtersModel.value, matches);
} }
return matches; return matches;
}); });
return filtered.sort((a, b) => { return filtered.sort((a, b) => {
switch (this.sortBy) { switch (sortBy.value) {
case 'lastUpdated': case 'lastUpdated':
return this.sortFns.lastUpdated return props.sortFns.lastUpdated
? this.sortFns.lastUpdated(a, b) ? props.sortFns.lastUpdated(a, b)
: new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf(); : new Date(b.updatedAt).valueOf() - new Date(a.updatedAt).valueOf();
case 'lastCreated': case 'lastCreated':
return this.sortFns.lastCreated return props.sortFns.lastCreated
? this.sortFns.lastCreated(a, b) ? props.sortFns.lastCreated(a, b)
: new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf(); : new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf();
case 'nameAsc': case 'nameAsc':
return this.sortFns.nameAsc return props.sortFns.nameAsc
? this.sortFns.nameAsc(a, b) ? props.sortFns.nameAsc(a, b)
: this.displayName(a).trim().localeCompare(this.displayName(b).trim()); : props.displayName(a).trim().localeCompare(props.displayName(b).trim());
case 'nameDesc': case 'nameDesc':
return this.sortFns.nameDesc return props.sortFns.nameDesc
? this.sortFns.nameDesc(a, b) ? props.sortFns.nameDesc(a, b)
: this.displayName(b).trim().localeCompare(this.displayName(a).trim()); : props.displayName(b).trim().localeCompare(props.displayName(a).trim());
default: default:
return this.sortFns[this.sortBy] ? this.sortFns[this.sortBy](a, b) : 0; return props.sortFns[sortBy.value] ? props.sortFns[sortBy.value](a, b) : 0;
} }
}); });
}, });
},
watch: {
filters(value) {
this.filtersModel = value;
},
'filtersModel.homeProject'() {
this.sendFiltersTelemetry('homeProject');
},
'filtersModel.search'() {
void this.callDebounced(
this.sendFiltersTelemetry,
{ debounceTime: 1000, trailing: true },
'search',
);
},
sortBy(newValue) {
this.$emit('sort', newValue);
this.sendSortingTelemetry();
},
'$route.params.projectId'() {
this.resetFilters();
},
},
mounted() {
void this.onMounted();
},
methods: {
async onMounted() {
await this.initialize();
this.loading = false; //methods
await this.$nextTick();
this.focusSearchInput();
if (this.hasAppliedFilters()) { const focusSearchInput = () => {
this.hasFilters = true; if (search.value) {
search.value.focus();
} }
}, };
hasAppliedFilters(): boolean {
return !!this.filterKeys.find( const hasAppliedFilters = (): boolean => {
return !!filterKeys.value.find(
(key) => (key) =>
key !== 'search' && key !== 'search' &&
(Array.isArray(this.filters[key]) (Array.isArray(props.filters[key])
? this.filters[key].length > 0 ? props.filters[key].length > 0
: this.filters[key] !== ''), : props.filters[key] !== ''),
); );
}, };
setCurrentPage(page: number) {
this.currentPage = page;
},
setRowsPerPage(rowsPerPage: number | '*') {
this.rowsPerPage = rowsPerPage;
},
resetFilters() {
Object.keys(this.filtersModel).forEach((key) => {
this.filtersModel[key] = Array.isArray(this.filtersModel[key]) ? [] : '';
});
this.resettingFilters = true; const setRowsPerPage = (numberOfRowsPerPage: number | '*') => {
this.sendFiltersTelemetry('reset'); rowsPerPage.value = numberOfRowsPerPage;
this.$emit('update:filters', this.filtersModel); };
},
focusSearchInput() { const setCurrentPage = (page: number) => {
if (this.$refs.search) { currentPage.value = page;
(this.$refs.search as SearchRef).focus(); };
}
}, const sendFiltersTelemetry = (source: string) => {
sendSortingTelemetry() {
this.$telemetry.track(`User changed sorting in ${this.resourceKey} list`, {
sorting: this.sortBy,
});
},
sendFiltersTelemetry(source: string) {
// Prevent sending multiple telemetry events when resetting filters // Prevent sending multiple telemetry events when resetting filters
// Timeout is required to wait for search debounce to be over // Timeout is required to wait for search debounce to be over
if (this.resettingFilters) { if (resettingFilters.value) {
if (source !== 'reset') { if (source !== 'reset') {
return; return;
} }
setTimeout(() => (this.resettingFilters = false), 1500); setTimeout(() => (resettingFilters.value = false), 1500);
} }
const filters = this.filtersModel as Record<string, string[] | string | boolean>; const filters = filtersModel.value as Record<string, string[] | string | boolean>;
const filtersSet: string[] = []; const filtersSet: string[] = [];
const filterValues: Array<string[] | string | boolean | null> = []; const filterValues: Array<string[] | string | boolean | null> = [];
@ -410,20 +360,134 @@ export default defineComponent({
} }
}); });
this.$telemetry.track(`User set filters in ${this.resourceKey} list`, { telemetry.track(`User set filters in ${props.resourceKey} list`, {
filters_set: filtersSet, filters_set: filtersSet,
filter_values: filterValues, filter_values: filterValues,
[`${this.resourceKey}_total_in_view`]: this.resources.length, [`${props.resourceKey}_total_in_view`]: props.resources.length,
[`${this.resourceKey}_after_filtering`]: this.filteredAndSortedResources.length, [`${props.resourceKey}_after_filtering`]: filteredAndSortedResources.value.length,
}); });
}, };
onUpdateFiltersLength(length: number) {
this.hasFilters = length > 0; const onAddButtonClick = (e: Event) => {
}, emit('click:add', e);
onSearch(search: string) { };
this.filtersModel.search = search;
this.$emit('update:filters', this.filtersModel); 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.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();
loading.value = false;
await nextTick();
focusSearchInput();
if (hasAppliedFilters()) {
hasFilters.value = true;
}
});
return {
loading,
i18n,
search,
usersStore,
filterKeys,
currentPage,
rowsPerPage,
filteredAndSortedResources,
hasFilters,
sortBy,
resettingFilters,
filtersModel,
sendFiltersTelemetry,
getColumns,
itemSize,
onAddButtonClick,
onUpdateFiltersLength,
onUpdateFilters,
resetFilters,
callDebounced,
setCurrentPage,
setRowsPerPage,
onSearch,
};
}, },
}); });
</script> </script>

View file

@ -6,13 +6,16 @@ export interface DebounceOptions {
trailing?: boolean; trailing?: boolean;
} }
export type DebouncedFunction<R = void> = (...args: unknown[]) => R; export type DebouncedFunction<Args extends unknown[] = unknown[], R = void> = (...args: Args) => R;
export function useDebounce() { export function useDebounce() {
// Create a ref for the WeakMap to store debounced functions. // Create a ref for the WeakMap to store debounced functions.
const debounceCache = ref(new WeakMap<DebouncedFunction<unknown>, DebouncedFunction<unknown>>()); const debounceCache = ref(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
new WeakMap<DebouncedFunction<any, any>, DebouncedFunction<any, any>>(),
);
const debounce = <T extends DebouncedFunction<ReturnType<T>>>( const debounce = <T extends DebouncedFunction<Parameters<T>, ReturnType<T>>>(
fn: T, fn: T,
options: DebounceOptions, options: DebounceOptions,
): T => { ): T => {
@ -24,7 +27,7 @@ export function useDebounce() {
// If a debounced version is not found, create one and store it in the WeakMap. // If a debounced version is not found, create one and store it in the WeakMap.
if (debouncedFn === undefined) { if (debouncedFn === undefined) {
debouncedFn = _debounce( debouncedFn = _debounce(
async (...args: unknown[]) => { async (...args: Parameters<T>) => {
return fn(...args); return fn(...args);
}, },
debounceTime, debounceTime,
@ -37,7 +40,7 @@ export function useDebounce() {
return debouncedFn as T; return debouncedFn as T;
}; };
const callDebounced = <T extends DebouncedFunction<ReturnType<T>>>( const callDebounced = <T extends DebouncedFunction<Parameters<T>, ReturnType<T>>>(
fn: T, fn: T,
options: DebounceOptions, options: DebounceOptions,
...inputParameters: Parameters<T> ...inputParameters: Parameters<T>