Commit 490ea078 authored by Dhiraj Bodicherla's avatar Dhiraj Bodicherla Committed by Denys Mishunov

Usage quotas page table pagination

The projects table in usage quotas page currently
returns the first 99 items without the ability to
fetch items beyond that. This MR adds pagination
to the table and limits list to first 20 items
parent fdef227c
<script>
import { GlLink, GlSprintf, GlModalDirective, GlButton, GlIcon } from '@gitlab/ui';
import {
GlLink,
GlSprintf,
GlModalDirective,
GlButton,
GlIcon,
GlKeysetPagination,
} from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ProjectsTable from './projects_table.vue';
import UsageGraph from './usage_graph.vue';
......@@ -9,18 +16,20 @@ import query from '../queries/storage.query.graphql';
import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import { formatUsageSize, parseGetStorageResults } from '../utils';
import { PROJECTS_PER_PAGE } from '../constants';
export default {
name: 'StorageCounterApp',
components: {
ProjectsTable,
GlLink,
GlIcon,
GlButton,
GlSprintf,
GlIcon,
StorageInlineAlert,
UsageGraph,
ProjectsTable,
UsageStatistics,
StorageInlineAlert,
GlKeysetPagination,
TemporaryStorageIncreaseModal,
},
directives: {
......@@ -55,20 +64,25 @@ export default {
fullPath: this.namespacePath,
searchTerm: this.searchTerm,
withExcessStorageData: this.isAdditionalStorageFlagEnabled,
first: PROJECTS_PER_PAGE,
};
},
update: parseGetStorageResults,
result() {
this.firstFetch = false;
},
},
},
data() {
return {
namespace: {},
searchTerm: '',
firstFetch: true,
};
},
computed: {
namespaceProjects() {
return this.namespace?.projects ?? [];
return this.namespace?.projects?.data ?? [];
},
isStorageIncreaseModalVisible() {
return parseBoolean(this.isTemporaryStorageIncreaseVisible);
......@@ -92,8 +106,24 @@ export default {
additionalPurchasedStorageSize: this.namespace.additionalPurchasedStorageSize,
};
},
isQueryLoading() {
return this.$apollo.queries.namespace.loading;
},
pageInfo() {
return this.namespace.projects?.pageInfo ?? {};
},
shouldShowStorageInlineAlert() {
return this.isAdditionalStorageFlagEnabled && !this.$apollo.queries.namespace.loading;
if (this.firstFetch) {
// for initial load check if the data fetch is done (isQueryLoading)
return this.isAdditionalStorageFlagEnabled && !this.isQueryLoading;
}
// for all subsequent queries the storage inline alert doesn't
// have to be re-rendered as the data from graphql will remain
// the same.
return this.isAdditionalStorageFlagEnabled;
},
showPagination() {
return Boolean(this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage);
},
},
methods: {
......@@ -103,8 +133,30 @@ export default {
this.searchTerm = input;
}
},
fetchMoreProjects(vars) {
this.$apollo.queries.namespace.fetchMore({
variables: {
fullPath: this.namespacePath,
withExcessStorageData: this.isAdditionalStorageFlagEnabled,
first: PROJECTS_PER_PAGE,
...vars,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
},
onPrev(before) {
if (this.pageInfo?.hasPreviousPage) {
this.fetchMoreProjects({ before });
}
},
onNext(after) {
if (this.pageInfo?.hasNextPage) {
this.fetchMoreProjects({ after });
}
},
},
modalId: 'temporary-increase-storage-modal',
};
</script>
......@@ -181,9 +233,13 @@ export default {
</div>
<projects-table
:projects="namespaceProjects"
:is-loading="isQueryLoading"
:additional-purchased-storage-size="namespace.additionalPurchasedStorageSize || 0"
@search="handleSearch"
/>
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination v-if="showPagination" v-bind="pageInfo" @prev="onPrev" @next="onNext" />
</div>
<temporary-storage-increase-modal
v-if="isStorageIncreaseModalVisible"
:limit="formattedNamespaceLimit"
......
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { SKELETON_LOADER_ROWS } from '../constants';
export default {
name: 'ProjectsSkeletonLoader',
components: { GlSkeletonLoader },
SKELETON_LOADER_ROWS,
};
</script>
<template>
<div class="gl-border-b-solid gl-border-b-1 gl-border-gray-100">
<div class="gl-flex-direction-column gl-display-md-none" data-testid="mobile-loader">
<div
v-for="index in $options.SKELETON_LOADER_ROWS.mobile"
:key="index"
class="gl-responsive-table-row gl-border-solid gl-border-b-1 gl-pt-3 gl-pb-3 gl-border-b-gray-100"
>
<gl-skeleton-loader :width="500" :height="172">
<rect width="480" height="20" x="10" y="15" rx="4" />
<rect width="480" height="20" x="10" y="80" rx="4" />
<rect width="480" height="20" x="10" y="145" rx="4" />
</gl-skeleton-loader>
</div>
</div>
<div
class="gl-display-none gl-display-md-flex gl-flex-direction-column"
data-testid="desktop-loader"
>
<gl-skeleton-loader
v-for="index in $options.SKELETON_LOADER_ROWS.desktop"
:key="index"
:width="1000"
:height="39"
>
<rect rx="4" width="320" height="8" x="0" y="18" />
<rect rx="4" width="60" height="8" x="500" y="18" />
<rect rx="4" width="60" height="8" x="750" y="18" />
</gl-skeleton-loader>
</div>
</div>
</template>
......@@ -3,11 +3,13 @@ import { GlSearchBoxByType } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Project from './project.vue';
import ProjectWithExcessStorage from './project_with_excess_storage.vue';
import ProjectsSkeletonLoader from './projects_skeleton_loader.vue';
import { SEARCH_DEBOUNCE_MS } from '~/ref/constants';
export default {
components: {
Project,
ProjectsSkeletonLoader,
ProjectWithExcessStorage,
GlSearchBoxByType,
},
......@@ -21,6 +23,11 @@ export default {
type: Number,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isAdditionalStorageFlagEnabled() {
......@@ -44,7 +51,7 @@ export default {
role="row"
>
<template v-if="isAdditionalStorageFlagEnabled">
<div class="table-section section-50 gl-font-weight-bold gl-pl-5" role="columnheader">
<div class="table-section section-50 gl-font-weight-bold gl-pl-5" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-15 gl-font-weight-bold" role="columnheader">
......@@ -70,13 +77,15 @@ export default {
</div>
</template>
</div>
<component
:is="projectRowComponent"
v-for="project in projects"
:key="project.id"
:project="project"
:additional-purchased-storage-size="additionalPurchasedStorageSize"
/>
<projects-skeleton-loader v-if="isAdditionalStorageFlagEnabled && isLoading" />
<template v-else>
<component
:is="projectRowComponent"
v-for="project in projects"
:key="project.id"
:project="project"
:additional-purchased-storage-size="additionalPurchasedStorageSize"
/>
</template>
</div>
</template>
......@@ -11,3 +11,10 @@ export const STORAGE_USAGE_THRESHOLDS = {
[ALERT_THRESHOLD]: 0.95,
[ERROR_THRESHOLD]: 1.0,
};
export const PROJECTS_PER_PAGE = 20;
export const SKELETON_LOADER_ROWS = {
desktop: PROJECTS_PER_PAGE,
mobile: 5,
};
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getStorageCounter(
$fullPath: ID!
$searchTerm: String = ""
$withExcessStorageData: Boolean = false
$searchTerm: String = ""
$first: Int!
$after: String
$before: String
) {
namespace(fullPath: $fullPath) {
id
......@@ -23,29 +28,37 @@ query getStorageCounter(
wikiSize
snippetsSize
}
projects(includeSubgroups: true, sort: STORAGE, search: $searchTerm) {
edges {
node {
id
fullPath
nameWithNamespace
avatarUrl
webUrl
name
repositorySizeExcess @include(if: $withExcessStorageData)
actualRepositorySizeLimit @include(if: $withExcessStorageData)
statistics {
commitCount
storageSize
repositorySize
lfsObjectsSize
buildArtifactsSize
packagesSize
wikiSize
snippetsSize
}
projects(
includeSubgroups: true
search: $searchTerm
first: $first
after: $after
before: $before
sort: STORAGE
) {
nodes {
id
fullPath
nameWithNamespace
avatarUrl
webUrl
name
repositorySizeExcess @include(if: $withExcessStorageData)
actualRepositorySizeLimit @include(if: $withExcessStorageData)
statistics {
commitCount
storageSize
repositorySize
lfsObjectsSize
buildArtifactsSize
packagesSize
wikiSize
snippetsSize
}
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -86,7 +86,7 @@ export const parseProjects = ({
additionalPurchasedStorageSize - totalRepositorySizeExcess,
);
return projects.edges.map(({ node: project }) =>
return projects.nodes.map(project =>
calculateUsedAndRemStorage(project, purchasedStorageRemaining),
);
};
......@@ -118,21 +118,26 @@ export const parseGetStorageResults = data => {
},
} = data || {};
const totalUsage = rootStorageStatistics?.storageSize
? numberToHumanSize(rootStorageStatistics.storageSize)
: 'N/A';
return {
projects: parseProjects({
projects,
additionalPurchasedStorageSize,
totalRepositorySizeExcess,
}),
projects: {
data: parseProjects({
projects,
additionalPurchasedStorageSize,
totalRepositorySizeExcess,
}),
pageInfo: projects.pageInfo,
},
additionalPurchasedStorageSize,
actualRepositorySizeLimit,
containsLockedProjects,
repositorySizeExcessProjectCount,
totalRepositorySize,
totalRepositorySizeExcess,
totalUsage: rootStorageStatistics?.storageSize
? numberToHumanSize(rootStorageStatistics.storageSize)
: 'N/A',
totalUsage,
rootStorageStatistics,
limit: storageSizeLimit,
};
......
......@@ -23,6 +23,8 @@ describe('Storage counter app', () => {
const findUsageStatistics = () => wrapper.find(UsageStatistics);
const findStorageInlineAlert = () => wrapper.find(StorageInlineAlert);
const findProjectsTable = () => wrapper.find(ProjectsTable);
const findPrevButton = () => wrapper.find('[data-testid="prevButton"]');
const findNextButton = () => wrapper.find('[data-testid="nextButton"]');
const createComponent = ({
props = {},
......@@ -257,4 +259,30 @@ describe('Storage counter app', () => {
expect(wrapper.vm.searchTerm).toBe('');
});
});
describe('renders projects table pagination component', () => {
const namespaceWithPageInfo = {
namespace: {
...withRootStorageStatistics,
projects: {
...withRootStorageStatistics.projects,
pageInfo: {
hasPreviousPage: false,
hasNextPage: true,
},
},
},
};
beforeEach(() => {
createComponent(namespaceWithPageInfo);
});
it('with disabled "Prev" button', () => {
expect(findPrevButton().attributes().disabled).toBe('disabled');
});
it('with enabled "Next" button', () => {
expect(findNextButton().attributes().disabled).toBeUndefined();
});
});
});
import { mount } from '@vue/test-utils';
import ProjectsSkeletonLoader from 'ee/storage_counter/components/projects_skeleton_loader.vue';
describe('ProjectsSkeletonLoader', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = mount(ProjectsSkeletonLoader, {
propsData: {
...props,
},
});
};
const findDesktopLoader = () => wrapper.find('[data-testid="desktop-loader"]');
const findMobileLoader = () => wrapper.find('[data-testid="mobile-loader"]');
beforeEach(createComponent);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('desktop loader', () => {
it('produces 20 rows', () => {
expect(findDesktopLoader().findAll('rect[width="1000"]')).toHaveLength(20);
});
it('has the correct classes', () => {
expect(findDesktopLoader().classes()).toEqual([
'gl-display-none',
'gl-display-md-flex',
'gl-flex-direction-column',
]);
});
});
describe('mobile loader', () => {
it('produces 5 rows', () => {
expect(findMobileLoader().findAll('rect[height="172"]')).toHaveLength(5);
});
it('has the correct classes', () => {
expect(findMobileLoader().classes()).toEqual([
'gl-flex-direction-column',
'gl-display-md-none',
]);
});
});
});
......@@ -61,7 +61,7 @@ export const projects = [
export const namespaceData = {
totalUsage: 'N/A',
limit: 10000000,
projects,
projects: { data: projects },
};
export const withRootStorageStatistics = {
......@@ -86,5 +86,5 @@ export const withRootStorageStatistics = {
};
export const mockGetStorageCounterGraphQLResponse = {
edges: projects.map(node => ({ node })),
nodes: projects.map(node => node),
};
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment