app/assets/javascripts/groups_projects/components/tab_view.vue (350 lines of code) (raw):
<script>
import { GlLoadingIcon, GlKeysetPagination, GlPagination } from '@gitlab/ui';
import { get } from 'lodash';
import { DEFAULT_PER_PAGE } from '~/api';
import { __ } from '~/locale';
import { createAlert } from '~/alert';
import { TIMESTAMP_TYPES } from '~/vue_shared/components/resource_lists/constants';
import { ACCESS_LEVELS_INTEGER_TO_STRING } from '~/access_level/constants';
import { COMPONENT_NAME as NESTED_GROUPS_PROJECTS_LIST_COMPONENT_NAME } from '~/vue_shared/components/nested_groups_projects_list/constants';
import { InternalEvents } from '~/tracking';
import {
FILTERED_SEARCH_TOKEN_LANGUAGE,
FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL,
PAGINATION_TYPE_KEYSET,
PAGINATION_TYPE_OFFSET,
} from '../constants';
const trackingMixin = InternalEvents.mixin();
export default {
PAGINATION_TYPE_KEYSET,
PAGINATION_TYPE_OFFSET,
name: 'TabView',
i18n: {
errorMessage: __(
'An error occurred loading the projects. Please refresh the page to try again.',
),
},
components: {
GlLoadingIcon,
GlKeysetPagination,
GlPagination,
},
mixins: [trackingMixin],
props: {
tab: {
required: true,
type: Object,
},
startCursor: {
type: String,
required: false,
default: null,
},
endCursor: {
type: String,
required: false,
default: null,
},
page: {
type: Number,
required: false,
default: 1,
},
sort: {
type: String,
required: true,
},
filters: {
type: Object,
required: true,
},
filteredSearchTermKey: {
type: String,
required: true,
},
timestampType: {
type: String,
required: false,
default: undefined,
validator(value) {
return TIMESTAMP_TYPES.includes(value);
},
},
programmingLanguages: {
type: Array,
required: true,
},
eventTracking: {
type: Object,
required: false,
default() {
return {};
},
},
paginationType: {
type: String,
required: true,
validator(value) {
return [PAGINATION_TYPE_KEYSET, PAGINATION_TYPE_OFFSET].includes(value);
},
},
},
data() {
return {
items: {},
};
},
apollo: {
items() {
return {
query: this.tab.query,
variables() {
const { transformVariables } = this.tab;
const variables = {
...(this.paginationType === PAGINATION_TYPE_KEYSET ? this.keysetPagination : {}),
...(this.paginationType === PAGINATION_TYPE_OFFSET ? this.offsetPagination : {}),
...this.tab.variables,
sort: this.sort,
programmingLanguageName: this.programmingLanguageName,
minAccessLevel: this.minAccessLevel,
search: this.search,
};
const transformedVariables = transformVariables
? transformVariables(variables)
: variables;
return transformedVariables;
},
update(response) {
const { nodes, pageInfo, count } = get(response, this.tab.queryPath);
return {
nodes: this.tab.formatter(nodes),
pageInfo,
count,
};
},
result() {
this.$emit('query-complete');
},
error(error) {
createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
},
};
},
},
computed: {
nodes() {
return this.items.nodes || [];
},
pageInfo() {
return this.items.pageInfo || {};
},
keysetPagination() {
if (!this.startCursor && !this.endCursor) {
return {
first: DEFAULT_PER_PAGE,
after: null,
last: null,
before: null,
};
}
return {
first: this.endCursor && DEFAULT_PER_PAGE,
after: this.endCursor,
last: this.startCursor && DEFAULT_PER_PAGE,
before: this.startCursor,
};
},
offsetPagination() {
return { page: this.page };
},
isLoading() {
return this.$apollo.queries.items.loading;
},
search() {
return this.filters[this.filteredSearchTermKey];
},
minAccessLevel() {
const { [FILTERED_SEARCH_TOKEN_MIN_ACCESS_LEVEL]: minAccessLevelInteger } = this.filters;
return minAccessLevelInteger && ACCESS_LEVELS_INTEGER_TO_STRING[minAccessLevelInteger];
},
programmingLanguageName() {
const { [FILTERED_SEARCH_TOKEN_LANGUAGE]: programmingLanguageId } = this.filters;
return (
programmingLanguageId &&
this.programmingLanguages.find(({ id }) => id === parseInt(programmingLanguageId, 10))?.name
);
},
apolloClient() {
return this.$apollo.provider.defaultClient;
},
emptyStateComponentProps() {
return {
search: this.search,
...this.tab.emptyStateComponentProps,
};
},
listComponentProps() {
const baseProps = {
items: this.nodes,
timestampType: this.timestampType,
...this.tab.listComponentProps,
};
if (this.tab.listComponent.name === NESTED_GROUPS_PROJECTS_LIST_COMPONENT_NAME) {
return {
...baseProps,
initialExpanded: Boolean(this.search),
};
}
return baseProps;
},
},
watch: {
'items.count': function watchCount(newCount) {
this.$emit('update-count', this.tab, newCount);
},
},
methods: {
async onRefetch() {
await this.apolloClient.clearStore();
this.$apollo.queries.items.refetch();
this.$emit('refetch');
},
onKeysetNext(endCursor) {
this.$emit('keyset-page-change', {
endCursor,
startCursor: null,
});
},
onKeysetPrev(startCursor) {
this.$emit('keyset-page-change', {
endCursor: null,
startCursor,
});
},
findItemById(items, id) {
if (!items?.length) {
return null;
}
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
// Check if current item has the ID we're looking for
if (item.id === id) {
return item;
}
// If item has children, recursively search its children
if (item.children?.length) {
const childItem = this.findItemById(item.children, id);
if (childItem !== null) {
return childItem;
}
}
}
// Item not found at any level
return null;
},
async onLoadChildren(parentId) {
const item = this.findItemById(this.nodes, parentId);
if (!item) {
return;
}
item.childrenLoading = true;
try {
const response = await this.$apollo.query({
query: this.tab.query,
variables: { parentId },
});
const { nodes } = get(response.data, this.tab.queryPath);
item.children = this.tab.formatter(nodes);
} catch (error) {
createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true });
} finally {
item.childrenLoading = false;
}
},
onHoverVisibility(visibility) {
if (!this.eventTracking?.hoverVisibility) {
return;
}
this.trackEvent(this.eventTracking.hoverVisibility, { label: visibility });
},
onHoverStat(stat) {
if (!this.eventTracking?.hoverStat) {
return;
}
this.trackEvent(this.eventTracking.hoverStat, { label: stat });
},
onClickStat(stat) {
if (!this.eventTracking?.clickStat) {
return;
}
this.trackEvent(this.eventTracking.clickStat, { label: stat });
},
onClickTopic() {
if (!this.eventTracking?.clickTopic) {
return;
}
this.trackEvent(this.eventTracking.clickTopic);
},
onOffsetInput(page) {
this.$emit('offset-page-change', page);
},
onClickAvatar() {
if (!this.eventTracking?.clickItemAfterFilter) {
return;
}
const activeFilters = Object.entries(this.filters).reduce((accumulator, [key, value]) => {
// Exclude filters that have no value.
if (!value) {
return accumulator;
}
if (key === this.filteredSearchTermKey) {
// For privacy reasons, don't keep track of user provided values
// eslint-disable-next-line @gitlab/require-i18n-strings
return { ...accumulator, search: 'user provided value' };
}
return { ...accumulator, [key]: value };
}, {});
if (!Object.keys(activeFilters).length) {
return;
}
this.trackEvent(this.eventTracking.clickItemAfterFilter, {
label: this.tab.value,
property: JSON.stringify(activeFilters),
});
},
},
};
</script>
<template>
<gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" />
<div v-else-if="nodes.length">
<component
:is="tab.listComponent"
v-bind="listComponentProps"
@refetch="onRefetch"
@load-children="onLoadChildren"
@hover-visibility="onHoverVisibility"
@hover-stat="onHoverStat"
@click-stat="onClickStat"
@click-avatar="onClickAvatar"
@click-topic="onClickTopic"
/>
<template v-if="paginationType === $options.PAGINATION_TYPE_OFFSET">
<div v-if="pageInfo.nextPage || pageInfo.previousPage" class="gl-mt-5">
<gl-pagination
:value="page"
:per-page="pageInfo.perPage"
:total-items="pageInfo.total"
align="center"
@input="onOffsetInput"
/>
</div>
</template>
<template v-else-if="paginationType === $options.PAGINATION_TYPE_KEYSET">
<div v-if="pageInfo.hasNextPage || pageInfo.hasPreviousPage" class="gl-mt-5 gl-text-center">
<gl-keyset-pagination v-bind="pageInfo" @prev="onKeysetPrev" @next="onKeysetNext" />
</div>
</template>
</div>
<component :is="tab.emptyStateComponent" v-else v-bind="emptyStateComponentProps" />
</template>