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> <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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ProjectsTable from './projects_table.vue'; import ProjectsTable from './projects_table.vue';
import UsageGraph from './usage_graph.vue'; import UsageGraph from './usage_graph.vue';
...@@ -9,18 +16,20 @@ import query from '../queries/storage.query.graphql'; ...@@ -9,18 +16,20 @@ import query from '../queries/storage.query.graphql';
import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue'; import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import { formatUsageSize, parseGetStorageResults } from '../utils'; import { formatUsageSize, parseGetStorageResults } from '../utils';
import { PROJECTS_PER_PAGE } from '../constants';
export default { export default {
name: 'StorageCounterApp', name: 'StorageCounterApp',
components: { components: {
ProjectsTable,
GlLink, GlLink,
GlIcon,
GlButton, GlButton,
GlSprintf, GlSprintf,
GlIcon,
StorageInlineAlert,
UsageGraph, UsageGraph,
ProjectsTable,
UsageStatistics, UsageStatistics,
StorageInlineAlert,
GlKeysetPagination,
TemporaryStorageIncreaseModal, TemporaryStorageIncreaseModal,
}, },
directives: { directives: {
...@@ -55,20 +64,25 @@ export default { ...@@ -55,20 +64,25 @@ export default {
fullPath: this.namespacePath, fullPath: this.namespacePath,
searchTerm: this.searchTerm, searchTerm: this.searchTerm,
withExcessStorageData: this.isAdditionalStorageFlagEnabled, withExcessStorageData: this.isAdditionalStorageFlagEnabled,
first: PROJECTS_PER_PAGE,
}; };
}, },
update: parseGetStorageResults, update: parseGetStorageResults,
result() {
this.firstFetch = false;
},
}, },
}, },
data() { data() {
return { return {
namespace: {}, namespace: {},
searchTerm: '', searchTerm: '',
firstFetch: true,
}; };
}, },
computed: { computed: {
namespaceProjects() { namespaceProjects() {
return this.namespace?.projects ?? []; return this.namespace?.projects?.data ?? [];
}, },
isStorageIncreaseModalVisible() { isStorageIncreaseModalVisible() {
return parseBoolean(this.isTemporaryStorageIncreaseVisible); return parseBoolean(this.isTemporaryStorageIncreaseVisible);
...@@ -92,8 +106,24 @@ export default { ...@@ -92,8 +106,24 @@ export default {
additionalPurchasedStorageSize: this.namespace.additionalPurchasedStorageSize, additionalPurchasedStorageSize: this.namespace.additionalPurchasedStorageSize,
}; };
}, },
isQueryLoading() {
return this.$apollo.queries.namespace.loading;
},
pageInfo() {
return this.namespace.projects?.pageInfo ?? {};
},
shouldShowStorageInlineAlert() { 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: { methods: {
...@@ -103,8 +133,30 @@ export default { ...@@ -103,8 +133,30 @@ export default {
this.searchTerm = input; 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', modalId: 'temporary-increase-storage-modal',
}; };
</script> </script>
...@@ -181,9 +233,13 @@ export default { ...@@ -181,9 +233,13 @@ export default {
</div> </div>
<projects-table <projects-table
:projects="namespaceProjects" :projects="namespaceProjects"
:is-loading="isQueryLoading"
:additional-purchased-storage-size="namespace.additionalPurchasedStorageSize || 0" :additional-purchased-storage-size="namespace.additionalPurchasedStorageSize || 0"
@search="handleSearch" @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 <temporary-storage-increase-modal
v-if="isStorageIncreaseModalVisible" v-if="isStorageIncreaseModalVisible"
:limit="formattedNamespaceLimit" :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'; ...@@ -3,11 +3,13 @@ import { GlSearchBoxByType } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Project from './project.vue'; import Project from './project.vue';
import ProjectWithExcessStorage from './project_with_excess_storage.vue'; import ProjectWithExcessStorage from './project_with_excess_storage.vue';
import ProjectsSkeletonLoader from './projects_skeleton_loader.vue';
import { SEARCH_DEBOUNCE_MS } from '~/ref/constants'; import { SEARCH_DEBOUNCE_MS } from '~/ref/constants';
export default { export default {
components: { components: {
Project, Project,
ProjectsSkeletonLoader,
ProjectWithExcessStorage, ProjectWithExcessStorage,
GlSearchBoxByType, GlSearchBoxByType,
}, },
...@@ -21,6 +23,11 @@ export default { ...@@ -21,6 +23,11 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
isLoading: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
isAdditionalStorageFlagEnabled() { isAdditionalStorageFlagEnabled() {
...@@ -70,7 +77,8 @@ export default { ...@@ -70,7 +77,8 @@ export default {
</div> </div>
</template> </template>
</div> </div>
<projects-skeleton-loader v-if="isAdditionalStorageFlagEnabled && isLoading" />
<template v-else>
<component <component
:is="projectRowComponent" :is="projectRowComponent"
v-for="project in projects" v-for="project in projects"
...@@ -78,5 +86,6 @@ export default { ...@@ -78,5 +86,6 @@ export default {
:project="project" :project="project"
:additional-purchased-storage-size="additionalPurchasedStorageSize" :additional-purchased-storage-size="additionalPurchasedStorageSize"
/> />
</template>
</div> </div>
</template> </template>
...@@ -11,3 +11,10 @@ export const STORAGE_USAGE_THRESHOLDS = { ...@@ -11,3 +11,10 @@ export const STORAGE_USAGE_THRESHOLDS = {
[ALERT_THRESHOLD]: 0.95, [ALERT_THRESHOLD]: 0.95,
[ERROR_THRESHOLD]: 1.0, [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( query getStorageCounter(
$fullPath: ID! $fullPath: ID!
$searchTerm: String = ""
$withExcessStorageData: Boolean = false $withExcessStorageData: Boolean = false
$searchTerm: String = ""
$first: Int!
$after: String
$before: String
) { ) {
namespace(fullPath: $fullPath) { namespace(fullPath: $fullPath) {
id id
...@@ -23,9 +28,15 @@ query getStorageCounter( ...@@ -23,9 +28,15 @@ query getStorageCounter(
wikiSize wikiSize
snippetsSize snippetsSize
} }
projects(includeSubgroups: true, sort: STORAGE, search: $searchTerm) { projects(
edges { includeSubgroups: true
node { search: $searchTerm
first: $first
after: $after
before: $before
sort: STORAGE
) {
nodes {
id id
fullPath fullPath
nameWithNamespace nameWithNamespace
...@@ -45,6 +56,8 @@ query getStorageCounter( ...@@ -45,6 +56,8 @@ query getStorageCounter(
snippetsSize snippetsSize
} }
} }
pageInfo {
...PageInfo
} }
} }
} }
......
...@@ -86,7 +86,7 @@ export const parseProjects = ({ ...@@ -86,7 +86,7 @@ export const parseProjects = ({
additionalPurchasedStorageSize - totalRepositorySizeExcess, additionalPurchasedStorageSize - totalRepositorySizeExcess,
); );
return projects.edges.map(({ node: project }) => return projects.nodes.map(project =>
calculateUsedAndRemStorage(project, purchasedStorageRemaining), calculateUsedAndRemStorage(project, purchasedStorageRemaining),
); );
}; };
...@@ -118,21 +118,26 @@ export const parseGetStorageResults = data => { ...@@ -118,21 +118,26 @@ export const parseGetStorageResults = data => {
}, },
} = data || {}; } = data || {};
const totalUsage = rootStorageStatistics?.storageSize
? numberToHumanSize(rootStorageStatistics.storageSize)
: 'N/A';
return { return {
projects: parseProjects({ projects: {
data: parseProjects({
projects, projects,
additionalPurchasedStorageSize, additionalPurchasedStorageSize,
totalRepositorySizeExcess, totalRepositorySizeExcess,
}), }),
pageInfo: projects.pageInfo,
},
additionalPurchasedStorageSize, additionalPurchasedStorageSize,
actualRepositorySizeLimit, actualRepositorySizeLimit,
containsLockedProjects, containsLockedProjects,
repositorySizeExcessProjectCount, repositorySizeExcessProjectCount,
totalRepositorySize, totalRepositorySize,
totalRepositorySizeExcess, totalRepositorySizeExcess,
totalUsage: rootStorageStatistics?.storageSize totalUsage,
? numberToHumanSize(rootStorageStatistics.storageSize)
: 'N/A',
rootStorageStatistics, rootStorageStatistics,
limit: storageSizeLimit, limit: storageSizeLimit,
}; };
......
...@@ -23,6 +23,8 @@ describe('Storage counter app', () => { ...@@ -23,6 +23,8 @@ describe('Storage counter app', () => {
const findUsageStatistics = () => wrapper.find(UsageStatistics); const findUsageStatistics = () => wrapper.find(UsageStatistics);
const findStorageInlineAlert = () => wrapper.find(StorageInlineAlert); const findStorageInlineAlert = () => wrapper.find(StorageInlineAlert);
const findProjectsTable = () => wrapper.find(ProjectsTable); const findProjectsTable = () => wrapper.find(ProjectsTable);
const findPrevButton = () => wrapper.find('[data-testid="prevButton"]');
const findNextButton = () => wrapper.find('[data-testid="nextButton"]');
const createComponent = ({ const createComponent = ({
props = {}, props = {},
...@@ -257,4 +259,30 @@ describe('Storage counter app', () => { ...@@ -257,4 +259,30 @@ describe('Storage counter app', () => {
expect(wrapper.vm.searchTerm).toBe(''); 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 = [ ...@@ -61,7 +61,7 @@ export const projects = [
export const namespaceData = { export const namespaceData = {
totalUsage: 'N/A', totalUsage: 'N/A',
limit: 10000000, limit: 10000000,
projects, projects: { data: projects },
}; };
export const withRootStorageStatistics = { export const withRootStorageStatistics = {
...@@ -86,5 +86,5 @@ export const withRootStorageStatistics = { ...@@ -86,5 +86,5 @@ export const withRootStorageStatistics = {
}; };
export const mockGetStorageCounterGraphQLResponse = { 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