diff options
Diffstat (limited to 'app/assets/javascripts/releases/components/app_index.vue')
-rw-r--r-- | app/assets/javascripts/releases/components/app_index.vue | 287 |
1 files changed, 220 insertions, 67 deletions
diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index e53bfea7389..59fa2fca736 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,67 +1,237 @@ <script> -import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { getParameterByName } from '~/lib/utils/url_utility'; +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { historyPushState } from '~/lib/utils/common_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; +import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; +import { convertAllReleasesGraphQLResponse } from '~/releases/util'; +import allReleasesQuery from '../graphql/queries/all_releases.query.graphql'; import ReleaseBlock from './release_block.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; +import ReleasesEmptyState from './releases_empty_state.vue'; import ReleasesPagination from './releases_pagination.vue'; import ReleasesSort from './releases_sort.vue'; export default { - name: 'ReleasesApp', + name: 'ReleasesIndexApp', components: { - GlEmptyState, - GlLink, GlButton, ReleaseBlock, - ReleasesPagination, ReleaseSkeletonLoader, + ReleasesEmptyState, + ReleasesPagination, ReleasesSort, }, + inject: { + projectPath: { + default: '', + }, + newReleasePath: { + default: '', + }, + }, + apollo: { + /** + * The same query as `fullGraphqlResponse`, except that it limits its + * results to a single item. This causes this request to complete much more + * quickly than `fullGraphqlResponse`, which allows the page to show + * meaningful content to the user much earlier. + */ + singleGraphqlResponse: { + query: allReleasesQuery, + // This trick only works when paginating _forward_. + // When paginating backwards, limiting the query to a single item loads + // the _last_ item in the page, which is not useful for our purposes. + skip() { + return !this.includeSingleQuery; + }, + variables() { + return { + ...this.queryVariables, + first: 1, + }; + }, + update(data) { + return { data }; + }, + error() { + this.singleRequestError = true; + }, + }, + fullGraphqlResponse: { + query: allReleasesQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return { data }; + }, + error(error) { + this.fullRequestError = true; + + createFlash({ + message: this.$options.i18n.errorMessage, + captureError: true, + error, + }); + }, + }, + }, + data() { + return { + singleRequestError: false, + fullRequestError: false, + cursors: { + before: getParameterByName('before'), + after: getParameterByName('after'), + }, + sort: DEFAULT_SORT, + }; + }, computed: { - ...mapState('index', [ - 'documentationPath', - 'illustrationPath', - 'newReleasePath', - 'isLoading', - 'releases', - 'hasError', - ]), - shouldRenderEmptyState() { - return !this.releases.length && !this.hasError && !this.isLoading; + queryVariables() { + let paginationParams = { first: PAGE_SIZE }; + if (this.cursors.after) { + paginationParams = { + after: this.cursors.after, + first: PAGE_SIZE, + }; + } else if (this.cursors.before) { + paginationParams = { + before: this.cursors.before, + last: PAGE_SIZE, + }; + } + + return { + fullPath: this.projectPath, + ...paginationParams, + sort: this.sort, + }; + }, + /** + * @returns {Boolean} Whether or not to request/include + * the results of the single-item query + */ + includeSingleQuery() { + return Boolean(!this.cursors.before || this.cursors.after); + }, + isSingleRequestLoading() { + return this.$apollo.queries.singleGraphqlResponse.loading; }, - shouldRenderSuccessState() { - return this.releases.length && !this.isLoading && !this.hasError; + isFullRequestLoading() { + return this.$apollo.queries.fullGraphqlResponse.loading; + }, + /** + * @returns {Boolean} `true` if the `singleGraphqlResponse` + * query has finished loading without errors + */ + isSingleRequestLoaded() { + return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project); + }, + /** + * @returns {Boolean} `true` if the `fullGraphqlResponse` + * query has finished loading without errors + */ + isFullRequestLoaded() { + return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project); + }, + releases() { + if (this.isFullRequestLoaded) { + return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data; + } + + if (this.isSingleRequestLoaded && this.includeSingleQuery) { + return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data; + } + + return []; }, - emptyStateText() { - return __( - "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.", + pageInfo() { + if (!this.isFullRequestLoaded) { + return { + hasPreviousPage: false, + hasNextPage: false, + }; + } + + return this.fullGraphqlResponse.data.project.releases.pageInfo; + }, + shouldRenderEmptyState() { + return this.isFullRequestLoaded && this.releases.length === 0; + }, + shouldRenderLoadingIndicator() { + return ( + (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) || + (this.isFullRequestLoading && !this.fullRequestError) ); }, + shouldRenderPagination() { + return this.isFullRequestLoaded && !this.shouldRenderEmptyState; + }, }, created() { - this.fetchReleases(); + this.updateQueryParamsFromUrl(); - window.addEventListener('popstate', this.fetchReleases); + window.addEventListener('popstate', this.updateQueryParamsFromUrl); + }, + destroyed() { + window.removeEventListener('popstate', this.updateQueryParamsFromUrl); }, methods: { - ...mapActions('index', { - fetchReleasesStoreAction: 'fetchReleases', - }), - fetchReleases() { - this.fetchReleasesStoreAction({ - before: getParameterByName('before'), - after: getParameterByName('after'), - }); + getReleaseKey(release, index) { + return [release.tagName, release.name, index].join('|'); + }, + updateQueryParamsFromUrl() { + this.cursors.before = getParameterByName('before'); + this.cursors.after = getParameterByName('after'); + }, + onPaginationButtonPress() { + this.updateQueryParamsFromUrl(); + + // In some cases, Apollo Client is able to pull its results from the cache instead of making + // a new network request. In these cases, the page's content gets swapped out immediately without + // changing the page's scroll, leaving the user looking at the bottom of the new page. + // To make the experience consistent, regardless of how the data is sourced, we manually + // scroll to the top of the page every time a pagination button is pressed. + scrollUp(); + }, + onSortChanged(newSort) { + if (this.sort === newSort) { + return; + } + + // Remove the "before" and "after" query parameters from the URL, + // effectively placing the user back on page 1 of the results. + // This prevents the frontend from requesting the results sorted + // by one field (e.g. `released_at`) while using a pagination cursor + // intended for a different field (e.g.) `created_at`). + // For more details, see the MR that introduced this change: + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434 + historyPushState( + setUrlParams({ + before: null, + after: null, + }), + ); + + this.updateQueryParamsFromUrl(); + + this.sort = newSort; }, }, + i18n: { + newRelease: __('New release'), + errorMessage: __('An error occurred while fetching the releases. Please try again.'), + }, }; </script> <template> <div class="flex flex-column mt-2"> <div class="gl-align-self-end gl-mb-3"> - <releases-sort class="gl-mr-2" @sort:changed="fetchReleases" /> + <releases-sort :value="sort" class="gl-mr-2" @input="onSortChanged" /> <gl-button v-if="newReleasePath" @@ -69,44 +239,27 @@ export default { :aria-describedby="shouldRenderEmptyState && 'releases-description'" category="primary" variant="confirm" - data-testid="new-release-button" + >{{ $options.i18n.newRelease }}</gl-button > - {{ __('New release') }} - </gl-button> </div> - <release-skeleton-loader v-if="isLoading" /> - - <gl-empty-state - v-else-if="shouldRenderEmptyState" - data-testid="empty-state" - :title="__('Getting started with releases')" - :svg-path="illustrationPath" - > - <template #description> - <span id="releases-description"> - {{ emptyStateText }} - <gl-link - :href="documentationPath" - :aria-label="__('Releases documentation')" - target="_blank" - > - {{ __('More information') }} - </gl-link> - </span> - </template> - </gl-empty-state> - - <div v-else-if="shouldRenderSuccessState" data-testid="success-state"> - <release-block - v-for="(release, index) in releases" - :key="index" - :release="release" - :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" - /> - </div> + <releases-empty-state v-if="shouldRenderEmptyState" /> + + <release-block + v-for="(release, index) in releases" + :key="getReleaseKey(release, index)" + :release="release" + :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" + /> + + <release-skeleton-loader v-if="shouldRenderLoadingIndicator" /> - <releases-pagination v-if="!isLoading" /> + <releases-pagination + v-if="shouldRenderPagination" + :page-info="pageInfo" + @prev="onPaginationButtonPress" + @next="onPaginationButtonPress" + /> </div> </template> <style> |