diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
commit | a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch) | |
tree | fb69158581673816a8cd895f9d352dcb3c678b1e /app/assets/javascripts/releases | |
parent | d16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff) | |
download | gitlab-ce-a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4.tar.gz |
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/releases')
9 files changed, 516 insertions, 12 deletions
diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue new file mode 100644 index 00000000000..ea0aa409577 --- /dev/null +++ b/app/assets/javascripts/releases/components/app_index_apollo_client.vue @@ -0,0 +1,275 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import createFlash from '~/flash'; +import { historyPushState, getParameterByName } from '~/lib/utils/common_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { setUrlParams } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; +import { convertAllReleasesGraphQLResponse } from '~/releases/util'; +import ReleaseBlock from './release_block.vue'; +import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; +import ReleasesEmptyState from './releases_empty_state.vue'; +import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue'; +import ReleasesSortApolloClient from './releases_sort_apollo_client.vue'; + +export default { + name: 'ReleasesIndexApolloClientApp', + components: { + GlButton, + ReleaseBlock, + ReleaseSkeletonLoader, + ReleasesEmptyState, + ReleasesPaginationApolloClient, + ReleasesSortApolloClient, + }, + 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: { + 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; + }, + 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 []; + }, + 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.updateQueryParamsFromUrl(); + + window.addEventListener('popstate', this.updateQueryParamsFromUrl); + }, + destroyed() { + window.removeEventListener('popstate', this.updateQueryParamsFromUrl); + }, + methods: { + 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-apollo-client :value="sort" class="gl-mr-2" @input="onSortChanged" /> + + <gl-button + v-if="newReleasePath" + :href="newReleasePath" + :aria-describedby="shouldRenderEmptyState && 'releases-description'" + category="primary" + variant="success" + >{{ $options.i18n.newRelease }}</gl-button + > + </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-apollo-client + v-if="shouldRenderPagination" + :page-info="pageInfo" + @prev="onPaginationButtonPress" + @next="onPaginationButtonPress" + /> + </div> +</template> +<style> +.linked-card::after { + width: 1px; + content: ' '; + border: 1px solid #e5e5e5; + height: 17px; + top: 100%; + position: absolute; + left: 32px; +} +</style> diff --git a/app/assets/javascripts/releases/components/releases_empty_state.vue b/app/assets/javascripts/releases/components/releases_empty_state.vue new file mode 100644 index 00000000000..800497c186a --- /dev/null +++ b/app/assets/javascripts/releases/components/releases_empty_state.vue @@ -0,0 +1,44 @@ +<script> +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'ReleasesEmptyState', + components: { + GlEmptyState, + GlLink, + }, + inject: { + documentationPath: { + default: '', + }, + illustrationPath: { + default: '', + }, + }, + i18n: { + emptyStateTitle: __('Getting started with releases'), + emptyStateText: __( + "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.", + ), + releasesDocumentation: __('Releases documentation'), + moreInformation: __('More information'), + }, +}; +</script> +<template> + <gl-empty-state :title="$options.i18n.emptyStateTitle" :svg-path="illustrationPath"> + <template #description> + <span id="releases-description"> + {{ $options.i18n.emptyStateText }} + <gl-link + :href="documentationPath" + :aria-label="$options.i18n.releasesDocumentation" + target="_blank" + > + {{ $options.i18n.moreInformation }} + </gl-link> + </span> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue new file mode 100644 index 00000000000..73339677a4b --- /dev/null +++ b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue @@ -0,0 +1,37 @@ +<script> +import { GlKeysetPagination } from '@gitlab/ui'; +import { isBoolean } from 'lodash'; +import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; + +export default { + name: 'ReleasesPaginationApolloClient', + components: { GlKeysetPagination }, + props: { + pageInfo: { + type: Object, + required: true, + validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage), + }, + }, + methods: { + onPrev(before) { + historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); + }, + onNext(after) { + historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="__('Prev')" + :next-text="__('Next')" + v-on="$listeners" + @prev="onPrev($event)" + @next="onNext($event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue index 4988904a2cd..d4210dad19c 100644 --- a/app/assets/javascripts/releases/components/releases_sort.vue +++ b/app/assets/javascripts/releases/components/releases_sort.vue @@ -1,7 +1,7 @@ <script> import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; -import { ASCENDING_ODER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; +import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; export default { name: 'ReleasesSort', @@ -22,13 +22,13 @@ export default { return option.label; }, isSortAscending() { - return this.sort === ASCENDING_ODER; + return this.sort === ASCENDING_ORDER; }, }, methods: { ...mapActions('index', ['setSorting']), onDirectionChange() { - const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; + const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER; this.setSorting({ sort }); this.$emit('sort:changed'); }, diff --git a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue new file mode 100644 index 00000000000..7257b34bbf6 --- /dev/null +++ b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue @@ -0,0 +1,91 @@ +<script> +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { + ASCENDING_ORDER, + DESCENDING_ORDER, + SORT_OPTIONS, + RELEASED_AT, + CREATED_AT, + RELEASED_AT_ASC, + RELEASED_AT_DESC, + CREATED_ASC, + ALL_SORTS, + SORT_MAP, +} from '../constants'; + +export default { + name: 'ReleasesSortApolloclient', + components: { + GlSorting, + GlSortingItem, + }, + props: { + value: { + type: String, + required: true, + validator: (sort) => ALL_SORTS.includes(sort), + }, + }, + computed: { + orderBy() { + if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) { + return RELEASED_AT; + } + + return CREATED_AT; + }, + direction() { + if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) { + return ASCENDING_ORDER; + } + + return DESCENDING_ORDER; + }, + sortOptions() { + return SORT_OPTIONS; + }, + sortText() { + return this.sortOptions.find((s) => s.orderBy === this.orderBy).label; + }, + isDirectionAscending() { + return this.direction === ASCENDING_ORDER; + }, + }, + methods: { + onDirectionChange() { + const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER; + this.emitInputEventIfChanged(this.orderBy, direction); + }, + onSortItemClick(item) { + this.emitInputEventIfChanged(item.orderBy, this.direction); + }, + isActiveSortItem(item) { + return this.orderBy === item.orderBy; + }, + emitInputEventIfChanged(orderBy, direction) { + const newSort = SORT_MAP[orderBy][direction]; + if (newSort !== this.value) { + this.$emit('input', SORT_MAP[orderBy][direction]); + } + }, + }, +}; +</script> + +<template> + <gl-sorting + :text="sortText" + :is-ascending="isDirectionAscending" + data-testid="releases-sort" + @sortDirectionChange="onDirectionChange" + > + <gl-sorting-item + v-for="item of sortOptions" + :key="item.orderBy" + :active="isActiveSortItem(item)" + @click="onSortItemClick(item)" + > + {{ item.label }} + </gl-sorting-item> + </gl-sorting> +</template> diff --git a/app/assets/javascripts/releases/constants.js b/app/assets/javascripts/releases/constants.js index f9653e0befa..4f862741e11 100644 --- a/app/assets/javascripts/releases/constants.js +++ b/app/assets/javascripts/releases/constants.js @@ -15,7 +15,7 @@ export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER; export const PAGE_SIZE = 10; -export const ASCENDING_ODER = 'asc'; +export const ASCENDING_ORDER = 'asc'; export const DESCENDING_ORDER = 'desc'; export const RELEASED_AT = 'released_at'; export const CREATED_AT = 'created_at'; @@ -30,3 +30,22 @@ export const SORT_OPTIONS = [ label: __('Created date'), }, ]; + +export const RELEASED_AT_ASC = 'RELEASED_AT_ASC'; +export const RELEASED_AT_DESC = 'RELEASED_AT_DESC'; +export const CREATED_ASC = 'CREATED_ASC'; +export const CREATED_DESC = 'CREATED_DESC'; +export const ALL_SORTS = [RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC]; + +export const SORT_MAP = { + [RELEASED_AT]: { + [ASCENDING_ORDER]: RELEASED_AT_ASC, + [DESCENDING_ORDER]: RELEASED_AT_DESC, + }, + [CREATED_AT]: { + [ASCENDING_ORDER]: CREATED_ASC, + [DESCENDING_ORDER]: CREATED_DESC, + }, +}; + +export const DEFAULT_SORT = RELEASED_AT_DESC; diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index bb21ec7c43f..59f6ebfc928 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -1,14 +1,44 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import Vuex from 'vuex'; +import createDefaultClient from '~/lib/graphql'; import ReleaseIndexApp from './components/app_index.vue'; +import ReleaseIndexApollopClientApp from './components/app_index_apollo_client.vue'; import createStore from './stores'; import createIndexModule from './stores/modules/index'; -Vue.use(Vuex); - export default () => { const el = document.getElementById('js-releases-page'); + if (window.gon?.features?.releasesIndexApolloClient) { + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + // This page attempts to decrease the perceived loading time + // by sending two requests: one request for the first item only (which + // completes relatively quickly), and one for all the items (which is slower). + // By default, Apollo Client batches these requests together, which defeats + // the purpose of making separate requests. So we explicitly + // disable batching on this page. + batchMax: 1, + assumeImmutableResults: true, + }, + ), + }); + + return new Vue({ + el, + apolloProvider, + provide: { ...el.dataset }, + render: (h) => h(ReleaseIndexApollopClientApp), + }); + } + + Vue.use(Vuex); + return new Vue({ el, store: createStore({ diff --git a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js index b312c2a7506..5955ec3352e 100644 --- a/app/assets/javascripts/releases/stores/modules/edit_new/actions.js +++ b/app/assets/javascripts/releases/stores/modules/edit_new/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import createReleaseMutation from '~/releases/graphql/mutations/create_release.mutation.graphql'; @@ -39,7 +39,9 @@ export const fetchRelease = async ({ commit, state }) => { commit(types.RECEIVE_RELEASE_SUCCESS, release); } catch (error) { commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details.')); + createFlash({ + message: s__('Release|Something went wrong while getting the release details.'), + }); } }; @@ -124,7 +126,9 @@ export const createRelease = async ({ commit, dispatch, state, getters }) => { dispatch('receiveSaveReleaseSuccess', response.data.releaseCreate.release.links.selfUrl); } catch (error) { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while creating a new release.')); + createFlash({ + message: s__('Release|Something went wrong while creating a new release.'), + }); } }; @@ -214,6 +218,8 @@ export const updateRelease = async ({ commit, dispatch, state, getters }) => { dispatch('receiveSaveReleaseSuccess', state.release._links.self); } catch (error) { commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while saving the release details.')); + createFlash({ + message: s__('Release|Something went wrong while saving the release details.'), + }); } }; diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js index 00be25f089b..d3bb11cab30 100644 --- a/app/assets/javascripts/releases/stores/modules/index/actions.js +++ b/app/assets/javascripts/releases/stores/modules/index/actions.js @@ -1,4 +1,4 @@ -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import { __ } from '~/locale'; import { PAGE_SIZE } from '~/releases/constants'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; @@ -57,7 +57,9 @@ export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); - createFlash(__('An error occurred while fetching the releases. Please try again.')); + createFlash({ + message: __('An error occurred while fetching the releases. Please try again.'), + }); }; export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); |