diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index e53bfea7389367f2e605c247e755c5228ff21afc..5bc855d0c18eeca39aadcce97f4815a7bdb63110 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -1,112 +1,265 @@ <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" :href="newReleasePath" :aria-describedby="shouldRenderEmptyState && 'releases-description'" category="primary" - variant="confirm" - data-testid="new-release-button" + variant="success" + >{{ $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> diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue deleted file mode 100644 index f49c44a399f2455b78cb4811cd658dee781e2b18..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/components/app_index_apollo_client.vue +++ /dev/null @@ -1,275 +0,0 @@ -<script> -import { GlButton } from '@gitlab/ui'; -import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; -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 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_pagination.vue b/app/assets/javascripts/releases/components/releases_pagination.vue index fddf85ead1e1da4b7826bdff71ababe6d437854e..52ad991d61a1847ce03497809270bdfa9d4b6b29 100644 --- a/app/assets/javascripts/releases/components/releases_pagination.vue +++ b/app/assets/javascripts/releases/components/releases_pagination.vue @@ -1,26 +1,24 @@ <script> import { GlKeysetPagination } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { isBoolean } from 'lodash'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; export default { - name: 'ReleasesPaginationGraphql', + name: 'ReleasesPagination', components: { GlKeysetPagination }, - computed: { - ...mapState('index', ['pageInfo']), - showPagination() { - return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; + props: { + pageInfo: { + type: Object, + required: true, + validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage), }, }, methods: { - ...mapActions('index', ['fetchReleases']), onPrev(before) { historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); - this.fetchReleases({ before }); }, onNext(after) { historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); - this.fetchReleases({ after }); }, }, }; @@ -28,8 +26,10 @@ export default { <template> <div class="gl-display-flex gl-justify-content-center"> <gl-keyset-pagination - v-if="showPagination" v-bind="pageInfo" + :prev-text="__('Prev')" + :next-text="__('Next')" + v-on="$listeners" @prev="onPrev($event)" @next="onNext($event)" /> diff --git a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue deleted file mode 100644 index 73339677a4b22a98729c3e7691eebc030f12ef8d..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue +++ /dev/null @@ -1,37 +0,0 @@ -<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 d4210dad19c12b02d4935e77e9ae34a0dcf1dbf0..0f14b579da09a2d3a7e805798cb92e443d8ad330 100644 --- a/app/assets/javascripts/releases/components/releases_sort.vue +++ b/app/assets/javascripts/releases/components/releases_sort.vue @@ -1,7 +1,17 @@ <script> import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { mapState, mapActions } from 'vuex'; -import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; +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: 'ReleasesSort', @@ -9,35 +19,54 @@ export default { GlSorting, GlSortingItem, }, + props: { + value: { + type: String, + required: true, + validator: (sort) => ALL_SORTS.includes(sort), + }, + }, computed: { - ...mapState('index', { - orderBy: (state) => state.sorting.orderBy, - sort: (state) => state.sorting.sort, - }), + 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() { - const option = this.sortOptions.find((s) => s.orderBy === this.orderBy); - return option.label; + return this.sortOptions.find((s) => s.orderBy === this.orderBy).label; }, - isSortAscending() { - return this.sort === ASCENDING_ORDER; + isDirectionAscending() { + return this.direction === ASCENDING_ORDER; }, }, methods: { - ...mapActions('index', ['setSorting']), onDirectionChange() { - const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER; - this.setSorting({ sort }); - this.$emit('sort:changed'); + const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER; + this.emitInputEventIfChanged(this.orderBy, direction); }, onSortItemClick(item) { - this.setSorting({ orderBy: item }); - this.$emit('sort:changed'); + this.emitInputEventIfChanged(item.orderBy, this.direction); }, isActiveSortItem(item) { - return this.orderBy === 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]); + } }, }, }; @@ -46,15 +75,15 @@ export default { <template> <gl-sorting :text="sortText" - :is-ascending="isSortAscending" + :is-ascending="isDirectionAscending" data-testid="releases-sort" @sortDirectionChange="onDirectionChange" > <gl-sorting-item - v-for="item in sortOptions" + v-for="item of sortOptions" :key="item.orderBy" - :active="isActiveSortItem(item.orderBy)" - @click="onSortItemClick(item.orderBy)" + :active="isActiveSortItem(item)" + @click="onSortItemClick(item)" > {{ item.label }} </gl-sorting-item> diff --git a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue deleted file mode 100644 index 7257b34bbf6ec3d88b1fd22dc8a558cf9a041d30..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue +++ /dev/null @@ -1,91 +0,0 @@ -<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/graphql/queries/all_releases.query.graphql b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql index 7f67f7d11a3a45e65f7273042e59bc4d549bdf8c..bda7ac52a478f958d243f2757f9c12aee1d1996b 100644 --- a/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql +++ b/app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql @@ -1,12 +1,4 @@ -#import "../fragments/release.fragment.graphql" - -# This query is identical to -# `app/graphql/queries/releases/all_releases.query.graphql`. -# These two queries should be kept in sync. -# When the `releases_index_apollo_client` feature flag is -# removed, this query should be removed entirely. - -query allReleasesDeprecated( +query allReleases( $fullPath: ID! $first: Int $last: Int @@ -20,7 +12,87 @@ query allReleasesDeprecated( releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { __typename nodes { - ...Release + __typename + name + tagName + tagPath + descriptionHtml + releasedAt + createdAt + upcomingRelease + assets { + __typename + count + sources { + __typename + nodes { + __typename + format + url + } + } + links { + __typename + nodes { + __typename + id + name + url + directAssetUrl + linkType + external + } + } + } + evidences { + __typename + nodes { + __typename + id + filepath + collectedAt + sha + } + } + links { + __typename + editUrl + selfUrl + openedIssuesUrl + closedIssuesUrl + openedMergeRequestsUrl + mergedMergeRequestsUrl + closedMergeRequestsUrl + } + commit { + __typename + id + sha + webUrl + title + } + author { + __typename + id + webUrl + avatarUrl + username + } + milestones { + __typename + nodes { + __typename + id + title + description + webPath + stats { + __typename + totalIssuesCount + closedIssuesCount + } + } + } } pageInfo { __typename diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index 86fa72d1496a6a95e799bc05a50c17e9c35d02a6..afb8ab461cd82c5096ebeddbd01833ec73f8c713 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -1,50 +1,32 @@ 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'; export default () => { const el = document.getElementById('js-releases-page'); - if (window.gon?.features?.releasesIndexApolloClient) { - Vue.use(VueApollo); + 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, - }, - ), - }); - - return new Vue({ - el, - apolloProvider, - provide: { ...el.dataset }, - render: (h) => h(ReleaseIndexApollopClientApp), - }); - } - - Vue.use(Vuex); + 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, + }, + ), + }); return new Vue({ el, - store: createStore({ - modules: { - index: createIndexModule(el.dataset), - }, - }), + apolloProvider, + provide: { ...el.dataset }, render: (h) => h(ReleaseIndexApp), }); }; diff --git a/app/assets/javascripts/releases/stores/modules/index/actions.js b/app/assets/javascripts/releases/stores/modules/index/actions.js deleted file mode 100644 index d3bb11cab30b31ff87cbd85f65f69374b8b0127c..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/stores/modules/index/actions.js +++ /dev/null @@ -1,65 +0,0 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; -import { PAGE_SIZE } from '~/releases/constants'; -import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; -import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; -import * as types from './mutation_types'; - -/** - * Gets a paginated list of releases from the GraphQL endpoint - * - * @param {Object} vuexParams - * @param {Object} actionParams - * @param {String} [actionParams.before] A GraphQL cursor. If provided, - * the items returned will proceed the provided cursor. - * @param {String} [actionParams.after] A GraphQL cursor. If provided, - * the items returned will follow the provided cursor. - */ -export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => { - commit(types.REQUEST_RELEASES); - - const { sort, orderBy } = state.sorting; - const orderByParam = orderBy === 'created_at' ? 'created' : orderBy; - const sortParams = `${orderByParam}_${sort}`.toUpperCase(); - - let paginationParams; - if (!before && !after) { - paginationParams = { first: PAGE_SIZE }; - } else if (before && !after) { - paginationParams = { last: PAGE_SIZE, before }; - } else if (!before && after) { - paginationParams = { first: PAGE_SIZE, after }; - } else { - throw new Error( - 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.', - ); - } - - gqClient - .query({ - query: allReleasesQuery, - variables: { - fullPath: state.projectPath, - sort: sortParams, - ...paginationParams, - }, - }) - .then((response) => { - const { data, paginationInfo: pageInfo } = convertAllReleasesGraphQLResponse(response); - - commit(types.RECEIVE_RELEASES_SUCCESS, { - data, - pageInfo, - }); - }) - .catch(() => dispatch('receiveReleasesError')); -}; - -export const receiveReleasesError = ({ commit }) => { - commit(types.RECEIVE_RELEASES_ERROR); - createFlash({ - message: __('An error occurred while fetching the releases. Please try again.'), - }); -}; - -export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); diff --git a/app/assets/javascripts/releases/stores/modules/index/index.js b/app/assets/javascripts/releases/stores/modules/index/index.js deleted file mode 100644 index d5ca191153a4f27487b63bd138f83eaa63be31d2..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/stores/modules/index/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import * as actions from './actions'; -import mutations from './mutations'; -import createState from './state'; - -export default (initialState) => ({ - namespaced: true, - actions, - mutations, - state: createState(initialState), -}); diff --git a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js b/app/assets/javascripts/releases/stores/modules/index/mutation_types.js deleted file mode 100644 index 669168efb889ad8d2895a94f31973d8e4274e8a1..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/stores/modules/index/mutation_types.js +++ /dev/null @@ -1,4 +0,0 @@ -export const REQUEST_RELEASES = 'REQUEST_RELEASES'; -export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS'; -export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR'; -export const SET_SORTING = 'SET_SORTING'; diff --git a/app/assets/javascripts/releases/stores/modules/index/mutations.js b/app/assets/javascripts/releases/stores/modules/index/mutations.js deleted file mode 100644 index 55a8a488be896aac18fd9be992001f10ba25ee6f..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/stores/modules/index/mutations.js +++ /dev/null @@ -1,44 +0,0 @@ -import * as types from './mutation_types'; - -export default { - /** - * Sets isLoading to true while the request is being made. - * @param {Object} state - */ - [types.REQUEST_RELEASES](state) { - state.isLoading = true; - }, - - /** - * Sets isLoading to false. - * Sets hasError to false. - * Sets the received data - * Sets the received pagination information - * @param {Object} state - * @param {Object} resp - */ - [types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) { - state.hasError = false; - state.isLoading = false; - state.releases = data; - state.pageInfo = pageInfo; - }, - - /** - * Sets isLoading to false. - * Sets hasError to true. - * Resets the data - * @param {Object} state - * @param {Object} data - */ - [types.RECEIVE_RELEASES_ERROR](state) { - state.isLoading = false; - state.releases = []; - state.hasError = true; - state.pageInfo = {}; - }, - - [types.SET_SORTING](state, sorting) { - state.sorting = { ...state.sorting, ...sorting }; - }, -}; diff --git a/app/assets/javascripts/releases/stores/modules/index/state.js b/app/assets/javascripts/releases/stores/modules/index/state.js deleted file mode 100644 index 5e1aaab7b58967cf2be51b4110ccb346e6af3e27..0000000000000000000000000000000000000000 --- a/app/assets/javascripts/releases/stores/modules/index/state.js +++ /dev/null @@ -1,24 +0,0 @@ -import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants'; - -export default ({ - projectId, - projectPath, - documentationPath, - illustrationPath, - newReleasePath = '', -}) => ({ - projectId, - projectPath, - documentationPath, - illustrationPath, - newReleasePath, - - isLoading: false, - hasError: false, - releases: [], - pageInfo: {}, - sorting: { - sort: DESCENDING_ORDER, - orderBy: RELEASED_AT, - }, -}); diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 1a2baf96020a44e253125cb70b4a1cf557fa8c65..19413d97d9d1a79dcba56d97295df85c364ad28d 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -8,9 +8,6 @@ class Projects::ReleasesController < Projects::ApplicationController before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_create_release!, only: :new before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink - before_action only: :index do - push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml) - end feature_category :release_orchestration diff --git a/app/graphql/queries/releases/all_releases.query.graphql b/app/graphql/queries/releases/all_releases.query.graphql deleted file mode 100644 index 150f59832f3d37ab7802c6df1dafbea34f00d57a..0000000000000000000000000000000000000000 --- a/app/graphql/queries/releases/all_releases.query.graphql +++ /dev/null @@ -1,109 +0,0 @@ -# This query is identical to -# `app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql`. -# These two queries should be kept in sync. -query allReleases( - $fullPath: ID! - $first: Int - $last: Int - $before: String - $after: String - $sort: ReleaseSort -) { - project(fullPath: $fullPath) { - __typename - id - releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { - __typename - nodes { - __typename - name - tagName - tagPath - descriptionHtml - releasedAt - createdAt - upcomingRelease - assets { - __typename - count - sources { - __typename - nodes { - __typename - format - url - } - } - links { - __typename - nodes { - __typename - id - name - url - directAssetUrl - linkType - external - } - } - } - evidences { - __typename - nodes { - __typename - id - filepath - collectedAt - sha - } - } - links { - __typename - editUrl - selfUrl - openedIssuesUrl - closedIssuesUrl - openedMergeRequestsUrl - mergedMergeRequestsUrl - closedMergeRequestsUrl - } - commit { - __typename - id - sha - webUrl - title - } - author { - __typename - id - webUrl - avatarUrl - username - } - milestones { - __typename - nodes { - __typename - id - title - description - webPath - stats { - __typename - totalIssuesCount - closedIssuesCount - } - } - } - } - pageInfo { - __typename - startCursor - hasPreviousPage - hasNextPage - endCursor - } - } - } -} diff --git a/config/feature_flags/development/releases_index_apollo_client.yml b/config/feature_flags/development/releases_index_apollo_client.yml deleted file mode 100644 index 072d72af5736b7636ea462adc8dca8d73ad67bb6..0000000000000000000000000000000000000000 --- a/config/feature_flags/development/releases_index_apollo_client.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: releases_index_apollo_client -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61828 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331006 -milestone: '14.0' -type: development -group: group::release -default_enabled: true diff --git a/spec/features/projects/releases/user_views_releases_spec.rb b/spec/features/projects/releases/user_views_releases_spec.rb index 98935fdf8729fdebf8085a870607e5cc0e474515..a7348b62fc0061d08a4d982353b2eef05a69a1de 100644 --- a/spec/features/projects/releases/user_views_releases_spec.rb +++ b/spec/features/projects/releases/user_views_releases_spec.rb @@ -24,129 +24,111 @@ stub_default_url_options(host: 'localhost') end - shared_examples 'releases index page' do - context('when the user is a maintainer') do - before do - sign_in(maintainer) + context('when the user is a maintainer') do + before do + sign_in(maintainer) - visit project_releases_path(project) + visit project_releases_path(project) - wait_for_requests - end + wait_for_requests + end - it 'sees the release' do - page.within("##{release_v1.tag}") do - expect(page).to have_content(release_v1.name) - expect(page).to have_content(release_v1.tag) - expect(page).not_to have_content('Upcoming Release') - end + it 'sees the release' do + page.within("##{release_v1.tag}") do + expect(page).to have_content(release_v1.name) + expect(page).to have_content(release_v1.tag) + expect(page).not_to have_content('Upcoming Release') end + end - it 'renders the correct links', :aggregate_failures do - page.within("##{release_v1.tag} .js-assets-list") do - external_link_indicator_selector = '[data-testid="external-link-indicator"]' + it 'renders the correct links', :aggregate_failures do + page.within("##{release_v1.tag} .js-assets-list") do + external_link_indicator_selector = '[data-testid="external-link-indicator"]' - expect(page).to have_link internal_link.name, href: internal_link.url - expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector) + expect(page).to have_link internal_link.name, href: internal_link.url + expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector) - expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}" - expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector) + expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}" + expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector) - expect(page).to have_link external_link.name, href: external_link.url - expect(find_link(external_link.name)).to have_css(external_link_indicator_selector) - end + expect(page).to have_link external_link.name, href: external_link.url + expect(find_link(external_link.name)).to have_css(external_link_indicator_selector) end + end - context 'with an upcoming release' do - it 'sees the upcoming tag' do - page.within("##{release_v3.tag}") do - expect(page).to have_content('Upcoming Release') - end + context 'with an upcoming release' do + it 'sees the upcoming tag' do + page.within("##{release_v3.tag}") do + expect(page).to have_content('Upcoming Release') end end + end - context 'with a tag containing a slash' do - it 'sees the release' do - page.within("##{release_v2.tag.parameterize}") do - expect(page).to have_content(release_v2.name) - expect(page).to have_content(release_v2.tag) - end + context 'with a tag containing a slash' do + it 'sees the release' do + page.within("##{release_v2.tag.parameterize}") do + expect(page).to have_content(release_v2.name) + expect(page).to have_content(release_v2.tag) end end + end - context 'sorting' do - def sort_page(by:, direction:) - within '[data-testid="releases-sort"]' do - find('.dropdown-toggle').click - - click_button(by, class: 'dropdown-item') - - find('.sorting-direction-button').click if direction == :ascending - end - end - - shared_examples 'releases sort order' do - it "sorts the releases #{description}" do - card_titles = page.all('.release-block .card-title', minimum: expected_releases.count) - - card_titles.each_with_index do |title, index| - expect(title).to have_content(expected_releases[index].name) - end - end - end + context 'sorting' do + def sort_page(by:, direction:) + within '[data-testid="releases-sort"]' do + find('.dropdown-toggle').click - context "when the page is sorted by the default sort order" do - let(:expected_releases) { [release_v3, release_v2, release_v1] } + click_button(by, class: 'dropdown-item') - it_behaves_like 'releases sort order' + find('.sorting-direction-button').click if direction == :ascending end + end - context "when the page is sorted by created_at ascending " do - let(:expected_releases) { [release_v2, release_v1, release_v3] } + shared_examples 'releases sort order' do + it "sorts the releases #{description}" do + card_titles = page.all('.release-block .card-title', minimum: expected_releases.count) - before do - sort_page by: 'Created date', direction: :ascending + card_titles.each_with_index do |title, index| + expect(title).to have_content(expected_releases[index].name) end - - it_behaves_like 'releases sort order' end end - end - context('when the user is a guest') do - before do - sign_in(guest) - end + context "when the page is sorted by the default sort order" do + let(:expected_releases) { [release_v3, release_v2, release_v1] } - it 'renders release info except for Git-related data' do - visit project_releases_path(project) + it_behaves_like 'releases sort order' + end - within('.release-block', match: :first) do - expect(page).to have_content(release_v3.description) - expect(page).to have_content(release_v3.tag) - expect(page).to have_content(release_v3.name) + context "when the page is sorted by created_at ascending " do + let(:expected_releases) { [release_v2, release_v1, release_v3] } - # The following properties (sometimes) include Git info, - # so they are not rendered for Guest users - expect(page).not_to have_content(release_v3.commit.short_id) + before do + sort_page by: 'Created date', direction: :ascending end + + it_behaves_like 'releases sort order' end end end - context 'when the releases_index_apollo_client feature flag is enabled' do + context('when the user is a guest') do before do - stub_feature_flags(releases_index_apollo_client: true) + sign_in(guest) end - it_behaves_like 'releases index page' - end + it 'renders release info except for Git-related data' do + visit project_releases_path(project) - context 'when the releases_index_apollo_client feature flag is disabled' do - before do - stub_feature_flags(releases_index_apollo_client: false) - end + within('.release-block', match: :first) do + expect(page).to have_content(release_v3.description) + expect(page).to have_content(release_v3.tag) + expect(page).to have_content(release_v3.name) - it_behaves_like 'releases index page' + # The following properties (sometimes) include Git info, + # so they are not rendered for Guest users + expect(page).not_to have_content(release_v3.commit.short_id) + end + end end end diff --git a/spec/frontend/releases/components/app_index_apollo_client_spec.js b/spec/frontend/releases/components/app_index_apollo_client_spec.js deleted file mode 100644 index 9881ef9bc9f06cdca56799f7d285f36fee3a75db..0000000000000000000000000000000000000000 --- a/spec/frontend/releases/components/app_index_apollo_client_spec.js +++ /dev/null @@ -1,398 +0,0 @@ -import { cloneDeep } from 'lodash'; -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql'; -import createFlash from '~/flash'; -import { historyPushState } from '~/lib/utils/common_utils'; -import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue'; -import ReleaseBlock from '~/releases/components/release_block.vue'; -import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; -import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; -import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; -import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; -import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; - -Vue.use(VueApollo); - -jest.mock('~/flash'); - -let mockQueryParams; -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - historyPushState: jest.fn(), -})); - -jest.mock('~/lib/utils/url_utility', () => ({ - ...jest.requireActual('~/lib/utils/url_utility'), - getParameterByName: jest - .fn() - .mockImplementation((parameterName) => mockQueryParams[parameterName]), -})); - -describe('app_index_apollo_client.vue', () => { - const projectPath = 'project/path'; - const newReleasePath = 'path/to/new/release/page'; - const before = 'beforeCursor'; - const after = 'afterCursor'; - - let wrapper; - let allReleases; - let singleRelease; - let noReleases; - let queryMock; - - const createComponent = ({ - singleResponse = Promise.resolve(singleRelease), - fullResponse = Promise.resolve(allReleases), - } = {}) => { - const apolloProvider = createMockApollo([ - [ - allReleasesQuery, - queryMock.mockImplementation((vars) => { - return vars.first === 1 ? singleResponse : fullResponse; - }), - ], - ]); - - wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, { - apolloProvider, - provide: { - newReleasePath, - projectPath, - }, - }); - }; - - beforeEach(() => { - mockQueryParams = {}; - - allReleases = cloneDeep(originalAllReleasesQueryResponse); - - singleRelease = cloneDeep(originalAllReleasesQueryResponse); - singleRelease.data.project.releases.nodes.splice( - 1, - singleRelease.data.project.releases.nodes.length, - ); - - noReleases = cloneDeep(originalAllReleasesQueryResponse); - noReleases.data.project.releases.nodes = []; - - queryMock = jest.fn(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - // Finders - const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); - const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); - const findNewReleaseButton = () => - wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease); - const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); - const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient); - const findSort = () => wrapper.findComponent(ReleasesSortApolloClient); - - // Tests - describe('component states', () => { - // These need to be defined as functions, since `singleRelease` and - // `allReleases` are generated in a `beforeEach`, and therefore - // aren't available at test definition time. - const getInProgressResponse = () => new Promise(() => {}); - const getErrorResponse = () => Promise.reject(new Error('Oops!')); - const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); - const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); - const getLoadedEmptyResponse = () => Promise.resolve(noReleases); - - const toDescription = (bool) => (bool ? 'does' : 'does not'); - - describe.each` - description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination - ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} - ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} - ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} - ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} - ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} - ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} - ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} - ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} - `( - '$description', - ({ - singleResponseFn, - fullResponseFn, - loadingIndicator, - emptyState, - flashMessage, - releaseCount, - pagination, - }) => { - beforeEach(() => { - createComponent({ - singleResponse: singleResponseFn(), - fullResponse: fullResponseFn(), - }); - }); - - it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => { - await waitForPromises(); - expect(findLoadingIndicator().exists()).toBe(loadingIndicator); - }); - - it(`${toDescription(emptyState)} render an empty state`, () => { - expect(findEmptyState().exists()).toBe(emptyState); - }); - - it(`${toDescription(flashMessage)} show a flash message`, () => { - if (flashMessage) { - expect(createFlash).toHaveBeenCalledWith({ - message: ReleasesIndexApolloClientApp.i18n.errorMessage, - captureError: true, - error: expect.any(Error), - }); - } else { - expect(createFlash).not.toHaveBeenCalled(); - } - }); - - it(`renders ${releaseCount} release(s)`, () => { - expect(findAllReleaseBlocks()).toHaveLength(releaseCount); - }); - - it(`${toDescription(pagination)} render the pagination controls`, () => { - expect(findPagination().exists()).toBe(pagination); - }); - - it('does render the "New release" button', () => { - expect(findNewReleaseButton().exists()).toBe(true); - }); - - it('does render the sort controls', () => { - expect(findSort().exists()).toBe(true); - }); - }, - ); - }); - - describe('URL parameters', () => { - describe('when the URL contains no query parameters', () => { - beforeEach(() => { - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains a "before" query parameter', () => { - beforeEach(() => { - mockQueryParams = { before }; - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(1); - - expect(queryMock).toHaveBeenCalledWith({ - before, - last: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains an "after" query parameter', () => { - beforeEach(() => { - mockQueryParams = { after }; - createComponent(); - }); - - it('makes a request with the correct GraphQL query parameters', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - - describe('when the URL contains both "before" and "after" query parameters', () => { - beforeEach(() => { - mockQueryParams = { before, after }; - createComponent(); - }); - - it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { - expect(queryMock).toHaveBeenCalledTimes(2); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: 1, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - - expect(queryMock).toHaveBeenCalledWith({ - after, - first: PAGE_SIZE, - fullPath: projectPath, - sort: DEFAULT_SORT, - }); - }); - }); - }); - - describe('New release button', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders the new release button with the correct href', () => { - expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); - }); - }); - - describe('pagination', () => { - beforeEach(() => { - mockQueryParams = { before }; - createComponent(); - }); - - it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { - expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); - - mockQueryParams = { after }; - findPagination().vm.$emit('next', after); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ before })], - [expect.objectContaining({ after })], - [expect.objectContaining({ after })], - ]); - }); - }); - - describe('sorting', () => { - beforeEach(() => { - createComponent(); - }); - - it(`sorts by ${DEFAULT_SORT} by default`, () => { - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - ]); - }); - - it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { - findSort().vm.$emit('input', CREATED_ASC); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: CREATED_ASC })], - [expect.objectContaining({ sort: CREATED_ASC })], - ]); - - // URL manipulation is tested in more detail in the `describe` block below - expect(historyPushState).toHaveBeenCalled(); - }); - - it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { - findSort().vm.$emit('input', DEFAULT_SORT); - - await nextTick(); - - expect(queryMock.mock.calls).toEqual([ - [expect.objectContaining({ sort: DEFAULT_SORT })], - [expect.objectContaining({ sort: DEFAULT_SORT })], - ]); - - expect(historyPushState).not.toHaveBeenCalled(); - }); - }); - - describe('sorting + pagination interaction', () => { - const nonPaginationQueryParam = 'nonPaginationQueryParam'; - - beforeEach(() => { - historyPushState.mockImplementation((newUrl) => { - mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); - }); - }); - - describe.each` - queryParamsBefore | paramName | paramInitialValue - ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} - ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} - `( - 'when the URL contains a "$paramName" pagination cursor', - ({ queryParamsBefore, paramName, paramInitialValue }) => { - beforeEach(async () => { - mockQueryParams = queryParamsBefore; - createComponent(); - - findSort().vm.$emit('input', CREATED_ASC); - - await nextTick(); - }); - - it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { - const firstRequestVariables = queryMock.mock.calls[0][0]; - // Might be request #2 or #3, depending on the pagination direction - const mostRecentRequestVariables = - queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; - - expect(firstRequestVariables[paramName]).toBe(paramInitialValue); - expect(mostRecentRequestVariables[paramName]).toBeUndefined(); - }); - - it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { - expect(historyPushState).toHaveBeenCalledTimes(1); - - const updatedUrlQueryParams = Object.fromEntries( - new URL(historyPushState.mock.calls[0][0]).searchParams, - ); - - expect(updatedUrlQueryParams[paramName]).toBeUndefined(); - }); - }, - ); - }); -}); diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 43e88650ae3986e6c2996a1ffdd44cb699e4ad5c..0d376acf1ae7dafdac3e869970bde45baf71054a 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -1,50 +1,87 @@ -import { shallowMount } from '@vue/test-utils'; -import { merge } from 'lodash'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { getParameterByName } from '~/lib/utils/url_utility'; -import AppIndex from '~/releases/components/app_index.vue'; +import { cloneDeep } from 'lodash'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; +import createFlash from '~/flash'; +import { historyPushState } from '~/lib/utils/common_utils'; +import ReleasesIndexApp from '~/releases/components/app_index.vue'; +import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue'; +import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; import ReleasesSort from '~/releases/components/releases_sort.vue'; +import { PAGE_SIZE, CREATED_ASC, DEFAULT_SORT } from '~/releases/constants'; + +Vue.use(VueApollo); + +jest.mock('~/flash'); + +let mockQueryParams; +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), - getParameterByName: jest.fn(), + getParameterByName: jest + .fn() + .mockImplementation((parameterName) => mockQueryParams[parameterName]), })); -Vue.use(Vuex); - describe('app_index.vue', () => { + const projectPath = 'project/path'; + const newReleasePath = 'path/to/new/release/page'; + const before = 'beforeCursor'; + const after = 'afterCursor'; + let wrapper; - let fetchReleasesSpy; - let urlParams; - - const createComponent = (storeUpdates) => { - wrapper = shallowMount(AppIndex, { - store: new Vuex.Store({ - modules: { - index: merge( - { - namespaced: true, - actions: { - fetchReleases: fetchReleasesSpy, - }, - state: { - isLoading: true, - releases: [], - }, - }, - storeUpdates, - ), - }, - }), + let allReleases; + let singleRelease; + let noReleases; + let queryMock; + + const createComponent = ({ + singleResponse = Promise.resolve(singleRelease), + fullResponse = Promise.resolve(allReleases), + } = {}) => { + const apolloProvider = createMockApollo([ + [ + allReleasesQuery, + queryMock.mockImplementation((vars) => { + return vars.first === 1 ? singleResponse : fullResponse; + }), + ], + ]); + + wrapper = shallowMountExtended(ReleasesIndexApp, { + apolloProvider, + provide: { + newReleasePath, + projectPath, + }, }); }; beforeEach(() => { - fetchReleasesSpy = jest.fn(); - getParameterByName.mockImplementation((paramName) => urlParams[paramName]); + mockQueryParams = {}; + + allReleases = cloneDeep(originalAllReleasesQueryResponse); + + singleRelease = cloneDeep(originalAllReleasesQueryResponse); + singleRelease.data.project.releases.nodes.splice( + 1, + singleRelease.data.project.releases.nodes.length, + ); + + noReleases = cloneDeep(originalAllReleasesQueryResponse); + noReleases.data.project.releases.nodes = []; + + queryMock = jest.fn(); }); afterEach(() => { @@ -52,120 +89,220 @@ describe('app_index.vue', () => { }); // Finders - const findLoadingIndicator = () => wrapper.find(ReleaseSkeletonLoader); - const findEmptyState = () => wrapper.find('[data-testid="empty-state"]'); - const findSuccessState = () => wrapper.find('[data-testid="success-state"]'); - const findPagination = () => wrapper.find(ReleasesPagination); - const findSortControls = () => wrapper.find(ReleasesSort); - const findNewReleaseButton = () => wrapper.find('[data-testid="new-release-button"]'); - - // Expectations - const expectLoadingIndicator = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} a loading indicator`, () => { - expect(findLoadingIndicator().exists()).toBe(shouldExist); - }); - }; + const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader); + const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState); + const findNewReleaseButton = () => wrapper.findByText(ReleasesIndexApp.i18n.newRelease); + const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); + const findPagination = () => wrapper.findComponent(ReleasesPagination); + const findSort = () => wrapper.findComponent(ReleasesSort); - const expectEmptyState = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => { - expect(findEmptyState().exists()).toBe(shouldExist); - }); - }; + // Tests + describe('component states', () => { + // These need to be defined as functions, since `singleRelease` and + // `allReleases` are generated in a `beforeEach`, and therefore + // aren't available at test definition time. + const getInProgressResponse = () => new Promise(() => {}); + const getErrorResponse = () => Promise.reject(new Error('Oops!')); + const getSingleRequestLoadedResponse = () => Promise.resolve(singleRelease); + const getFullRequestLoadedResponse = () => Promise.resolve(allReleases); + const getLoadedEmptyResponse = () => Promise.resolve(noReleases); + + const toDescription = (bool) => (bool ? 'does' : 'does not'); + + describe.each` + description | singleResponseFn | fullResponseFn | loadingIndicator | emptyState | flashMessage | releaseCount | pagination + ${'both requests loading'} | ${getInProgressResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'both requests failed'} | ${getErrorResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${0} | ${false} + ${'both requests loaded'} | ${getSingleRequestLoadedResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'both requests loaded with no results'} | ${getLoadedEmptyResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + ${'single request loading, full request loaded'} | ${getInProgressResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loading, full request failed'} | ${getInProgressResponse} | ${getErrorResponse} | ${true} | ${false} | ${true} | ${0} | ${false} + ${'single request loaded, full request loading'} | ${getSingleRequestLoadedResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${1} | ${false} + ${'single request loaded, full request failed'} | ${getSingleRequestLoadedResponse} | ${getErrorResponse} | ${false} | ${false} | ${true} | ${1} | ${false} + ${'single request failed, full request loading'} | ${getErrorResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request failed, full request loaded'} | ${getErrorResponse} | ${getFullRequestLoadedResponse} | ${false} | ${false} | ${false} | ${2} | ${true} + ${'single request loaded with no results, full request loading'} | ${getLoadedEmptyResponse} | ${getInProgressResponse} | ${true} | ${false} | ${false} | ${0} | ${false} + ${'single request loading, full request loadied with no results'} | ${getInProgressResponse} | ${getLoadedEmptyResponse} | ${false} | ${true} | ${false} | ${0} | ${false} + `( + '$description', + ({ + singleResponseFn, + fullResponseFn, + loadingIndicator, + emptyState, + flashMessage, + releaseCount, + pagination, + }) => { + beforeEach(() => { + createComponent({ + singleResponse: singleResponseFn(), + fullResponse: fullResponseFn(), + }); + }); + + it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => { + await waitForPromises(); + expect(findLoadingIndicator().exists()).toBe(loadingIndicator); + }); + + it(`${toDescription(emptyState)} render an empty state`, () => { + expect(findEmptyState().exists()).toBe(emptyState); + }); + + it(`${toDescription(flashMessage)} show a flash message`, () => { + if (flashMessage) { + expect(createFlash).toHaveBeenCalledWith({ + message: ReleasesIndexApp.i18n.errorMessage, + captureError: true, + error: expect.any(Error), + }); + } else { + expect(createFlash).not.toHaveBeenCalled(); + } + }); + + it(`renders ${releaseCount} release(s)`, () => { + expect(findAllReleaseBlocks()).toHaveLength(releaseCount); + }); + + it(`${toDescription(pagination)} render the pagination controls`, () => { + expect(findPagination().exists()).toBe(pagination); + }); + + it('does render the "New release" button', () => { + expect(findNewReleaseButton().exists()).toBe(true); + }); + + it('does render the sort controls', () => { + expect(findSort().exists()).toBe(true); + }); + }, + ); + }); - const expectSuccessState = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => { - expect(findSuccessState().exists()).toBe(shouldExist); - }); - }; + describe('URL parameters', () => { + describe('when the URL contains no query parameters', () => { + beforeEach(() => { + createComponent(); + }); - const expectPagination = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => { - expect(findPagination().exists()).toBe(shouldExist); - }); - }; + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); - const expectNewReleaseButton = (shouldExist) => { - it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => { - expect(findNewReleaseButton().exists()).toBe(shouldExist); - }); - }; + expect(queryMock).toHaveBeenCalledWith({ + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); - // Tests - describe('on startup', () => { - it.each` - before | after - ${null} | ${null} - ${'before_param_value'} | ${null} - ${null} | ${'after_param_value'} - `( - 'calls fetchRelease with the correct parameters based on the curent query parameters: before: $before, after: $after', - ({ before, after }) => { - urlParams = { before, after }; + expect(queryMock).toHaveBeenCalledWith({ + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); + describe('when the URL contains a "before" query parameter', () => { + beforeEach(() => { + mockQueryParams = { before }; createComponent(); + }); - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); - expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); - }, - ); - }); + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(1); - describe('when the request to fetch releases has not yet completed', () => { - beforeEach(() => { - createComponent(); + expect(queryMock).toHaveBeenCalledWith({ + before, + last: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); }); - expectLoadingIndicator(true); - expectEmptyState(false); - expectSuccessState(false); - expectPagination(false); - }); + describe('when the URL contains an "after" query parameter', () => { + beforeEach(() => { + mockQueryParams = { after }; + createComponent(); + }); - describe('when the request fails', () => { - beforeEach(() => { - createComponent({ - state: { - isLoading: false, - hasError: true, - }, + it('makes a request with the correct GraphQL query parameters', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); }); }); - expectLoadingIndicator(false); - expectEmptyState(false); - expectSuccessState(false); - expectPagination(true); + describe('when the URL contains both "before" and "after" query parameters', () => { + beforeEach(() => { + mockQueryParams = { before, after }; + createComponent(); + }); + + it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => { + expect(queryMock).toHaveBeenCalledTimes(2); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: 1, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + + expect(queryMock).toHaveBeenCalledWith({ + after, + first: PAGE_SIZE, + fullPath: projectPath, + sort: DEFAULT_SORT, + }); + }); + }); }); - describe('when the request succeeds but returns no releases', () => { + describe('New release button', () => { beforeEach(() => { - createComponent({ - state: { - isLoading: false, - }, - }); + createComponent(); }); - expectLoadingIndicator(false); - expectEmptyState(true); - expectSuccessState(false); - expectPagination(true); + it('renders the new release button with the correct href', () => { + expect(findNewReleaseButton().attributes().href).toBe(newReleasePath); + }); }); - describe('when the request succeeds and includes at least one release', () => { + describe('pagination', () => { beforeEach(() => { - createComponent({ - state: { - isLoading: false, - releases: [{}], - }, - }); + mockQueryParams = { before }; + createComponent(); }); - expectLoadingIndicator(false); - expectEmptyState(false); - expectSuccessState(true); - expectPagination(true); + it('requeries the GraphQL endpoint when a pagination button is clicked', async () => { + expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]); + + mockQueryParams = { after }; + findPagination().vm.$emit('next', after); + + await nextTick(); + + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ before })], + [expect.objectContaining({ after })], + [expect.objectContaining({ after })], + ]); + }); }); describe('sorting', () => { @@ -173,59 +310,88 @@ describe('app_index.vue', () => { createComponent(); }); - it('renders the sort controls', () => { - expect(findSortControls().exists()).toBe(true); + it(`sorts by ${DEFAULT_SORT} by default`, () => { + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); }); - it('calls the fetchReleases store method when the sort is updated', () => { - fetchReleasesSpy.mockClear(); + it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => { + findSort().vm.$emit('input', CREATED_ASC); + + await nextTick(); - findSortControls().vm.$emit('sort:changed'); + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: CREATED_ASC })], + [expect.objectContaining({ sort: CREATED_ASC })], + ]); - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); + // URL manipulation is tested in more detail in the `describe` block below + expect(historyPushState).toHaveBeenCalled(); }); - }); - describe('"New release" button', () => { - describe('when the user is allowed to create releases', () => { - const newReleasePath = 'path/to/new/release/page'; + it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => { + findSort().vm.$emit('input', DEFAULT_SORT); - beforeEach(() => { - createComponent({ state: { newReleasePath } }); - }); + await nextTick(); - expectNewReleaseButton(true); + expect(queryMock.mock.calls).toEqual([ + [expect.objectContaining({ sort: DEFAULT_SORT })], + [expect.objectContaining({ sort: DEFAULT_SORT })], + ]); - it('renders the button with the correct href', () => { - expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath); - }); + expect(historyPushState).not.toHaveBeenCalled(); }); + }); - describe('when the user is not allowed to create releases', () => { - beforeEach(() => { - createComponent(); - }); + describe('sorting + pagination interaction', () => { + const nonPaginationQueryParam = 'nonPaginationQueryParam'; - expectNewReleaseButton(false); + beforeEach(() => { + historyPushState.mockImplementation((newUrl) => { + mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams); + }); }); - }); - describe("when the browser's back button is pressed", () => { - beforeEach(() => { - urlParams = { - before: 'before_param_value', - }; + describe.each` + queryParamsBefore | paramName | paramInitialValue + ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before} + ${{ after, nonPaginationQueryParam }} | ${'after'} | ${after} + `( + 'when the URL contains a "$paramName" pagination cursor', + ({ queryParamsBefore, paramName, paramInitialValue }) => { + beforeEach(async () => { + mockQueryParams = queryParamsBefore; + createComponent(); - createComponent(); + findSort().vm.$emit('input', CREATED_ASC); - fetchReleasesSpy.mockClear(); + await nextTick(); + }); - window.dispatchEvent(new PopStateEvent('popstate')); - }); + it(`resets the page's "${paramName}" pagination cursor when the sort is changed`, () => { + const firstRequestVariables = queryMock.mock.calls[0][0]; + // Might be request #2 or #3, depending on the pagination direction + const mostRecentRequestVariables = + queryMock.mock.calls[queryMock.mock.calls.length - 1][0]; - it('calls the fetchRelease store method with the parameters from the URL query', () => { - expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); - expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); - }); + expect(firstRequestVariables[paramName]).toBe(paramInitialValue); + expect(mostRecentRequestVariables[paramName]).toBeUndefined(); + }); + + it(`updates the URL to not include the "${paramName}" URL query parameter`, () => { + expect(historyPushState).toHaveBeenCalledTimes(1); + + const updatedUrlQueryParams = Object.fromEntries( + new URL(historyPushState.mock.calls[0][0]).searchParams, + ); + + expect(updatedUrlQueryParams[paramName]).toBeUndefined(); + }); + }, + ); }); }); diff --git a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js b/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js deleted file mode 100644 index a538afd5d38be980435226e1f10780138cf4cb1f..0000000000000000000000000000000000000000 --- a/spec/frontend/releases/components/releases_pagination_apollo_client_spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import { historyPushState } from '~/lib/utils/common_utils'; -import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue'; - -jest.mock('~/lib/utils/common_utils', () => ({ - ...jest.requireActual('~/lib/utils/common_utils'), - historyPushState: jest.fn(), -})); - -describe('releases_pagination_apollo_client.vue', () => { - const startCursor = 'startCursor'; - const endCursor = 'endCursor'; - let wrapper; - let onPrev; - let onNext; - - const createComponent = (pageInfo) => { - onPrev = jest.fn(); - onNext = jest.fn(); - - wrapper = mountExtended(ReleasesPaginationApolloClient, { - propsData: { - pageInfo, - }, - listeners: { - prev: onPrev, - next: onNext, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const singlePageInfo = { - hasPreviousPage: false, - hasNextPage: false, - startCursor, - endCursor, - }; - - const onlyNextPageInfo = { - hasPreviousPage: false, - hasNextPage: true, - startCursor, - endCursor, - }; - - const onlyPrevPageInfo = { - hasPreviousPage: true, - hasNextPage: false, - startCursor, - endCursor, - }; - - const prevAndNextPageInfo = { - hasPreviousPage: true, - hasNextPage: true, - startCursor, - endCursor, - }; - - const findPrevButton = () => wrapper.findByTestId('prevButton'); - const findNextButton = () => wrapper.findByTestId('nextButton'); - - describe.each` - description | pageInfo | prevEnabled | nextEnabled - ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} - ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} - ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} - ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} - `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { - describe(description, () => { - beforeEach(() => { - createComponent(pageInfo); - }); - - it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { - expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); - }); - - it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { - expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); - }); - }); - }); - - describe('button behavior', () => { - beforeEach(() => { - createComponent(prevAndNextPageInfo); - }); - - describe('next button behavior', () => { - beforeEach(() => { - findNextButton().trigger('click'); - }); - - it('emits an "next" event with the "after" cursor', () => { - expect(onNext.mock.calls).toEqual([[endCursor]]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?after=${endCursor}`)], - ]); - }); - }); - - describe('prev button behavior', () => { - beforeEach(() => { - findPrevButton().trigger('click'); - }); - - it('emits an "prev" event with the "before" cursor', () => { - expect(onPrev.mock.calls).toEqual([[startCursor]]); - }); - - it('calls historyPushState with the new URL', () => { - expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?before=${startCursor}`)], - ]); - }); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js index b8c69b0ea70b4b51ba1ceff0606814be52de5f57..59be808c802616f3aad23198a3d8947479ca728f 100644 --- a/spec/frontend/releases/components/releases_pagination_spec.js +++ b/spec/frontend/releases/components/releases_pagination_spec.js @@ -1,140 +1,94 @@ -import { GlKeysetPagination } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { historyPushState } from '~/lib/utils/common_utils'; import ReleasesPagination from '~/releases/components/releases_pagination.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), historyPushState: jest.fn(), })); -Vue.use(Vuex); - -describe('~/releases/components/releases_pagination.vue', () => { +describe('releases_pagination.vue', () => { + const startCursor = 'startCursor'; + const endCursor = 'endCursor'; let wrapper; - let indexModule; - - const cursors = { - startCursor: 'startCursor', - endCursor: 'endCursor', - }; - - const projectPath = 'my/project'; + let onPrev; + let onNext; const createComponent = (pageInfo) => { - indexModule = createIndexModule({ projectPath }); - - indexModule.state.pageInfo = pageInfo; - - indexModule.actions.fetchReleases = jest.fn(); - - wrapper = mount(ReleasesPagination, { - store: createStore({ - modules: { - index: indexModule, - }, - featureFlags: {}, - }), + onPrev = jest.fn(); + onNext = jest.fn(); + + wrapper = mountExtended(ReleasesPagination, { + propsData: { + pageInfo, + }, + listeners: { + prev: onPrev, + next: onNext, + }, }); }; afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); - const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]'); - const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]'); - - const expectDisabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe('disabled'); + const singlePageInfo = { + hasPreviousPage: false, + hasNextPage: false, + startCursor, + endCursor, }; - const expectEnabledPrev = () => { - expect(findPrevButton().attributes().disabled).toBe(undefined); + + const onlyNextPageInfo = { + hasPreviousPage: false, + hasNextPage: true, + startCursor, + endCursor, }; - const expectDisabledNext = () => { - expect(findNextButton().attributes().disabled).toBe('disabled'); + + const onlyPrevPageInfo = { + hasPreviousPage: true, + hasNextPage: false, + startCursor, + endCursor, }; - const expectEnabledNext = () => { - expect(findNextButton().attributes().disabled).toBe(undefined); + + const prevAndNextPageInfo = { + hasPreviousPage: true, + hasNextPage: true, + startCursor, + endCursor, }; - describe('when there is only one page of results', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: false, + const findPrevButton = () => wrapper.findByTestId('prevButton'); + const findNextButton = () => wrapper.findByTestId('nextButton'); + + describe.each` + description | pageInfo | prevEnabled | nextEnabled + ${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false} + ${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true} + ${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false} + ${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true} + `('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => { + describe(description, () => { + beforeEach(() => { + createComponent(pageInfo); }); - }); - - it('does not render a GlKeysetPagination', () => { - expect(findGlKeysetPagination().exists()).toBe(false); - }); - }); - describe('when there is a next page, but not a previous page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: false, - hasNextPage: true, + it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled'); }); - }); - - it('renders a disabled "Prev" button', () => { - expectDisabledPrev(); - }); - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); - }); - - describe('when there is a previous page, but not a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: false, - }); - }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an disabled "Next" button', () => { - expectDisabledNext(); - }); - }); - - describe('when there is both a previous page and a next page', () => { - beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, + it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => { + expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled'); }); }); - - it('renders a enabled "Prev" button', () => { - expectEnabledPrev(); - }); - - it('renders an enabled "Next" button', () => { - expectEnabledNext(); - }); }); describe('button behavior', () => { beforeEach(() => { - createComponent({ - hasPreviousPage: true, - hasNextPage: true, - ...cursors, - }); + createComponent(prevAndNextPageInfo); }); describe('next button behavior', () => { @@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => { findNextButton().trigger('click'); }); - it('calls fetchReleases with the correct after cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { after: cursors.endCursor }], - ]); + it('emits an "next" event with the "after" cursor', () => { + expect(onNext.mock.calls).toEqual([[endCursor]]); }); it('calls historyPushState with the new URL', () => { expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?after=${cursors.endCursor}`)], + [expect.stringContaining(`?after=${endCursor}`)], ]); }); }); - describe('previous button behavior', () => { + describe('prev button behavior', () => { beforeEach(() => { findPrevButton().trigger('click'); }); - it('calls fetchReleases with the correct before cursor', () => { - expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ - [expect.anything(), { before: cursors.startCursor }], - ]); + it('emits an "prev" event with the "before" cursor', () => { + expect(onPrev.mock.calls).toEqual([[startCursor]]); }); it('calls historyPushState with the new URL', () => { expect(historyPushState.mock.calls).toEqual([ - [expect.stringContaining(`?before=${cursors.startCursor}`)], + [expect.stringContaining(`?before=${startCursor}`)], ]); }); }); diff --git a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js b/spec/frontend/releases/components/releases_sort_apollo_client_spec.js deleted file mode 100644 index d93a932af0133f76ca0ad9e0373a0989950678f0..0000000000000000000000000000000000000000 --- a/spec/frontend/releases/components/releases_sort_apollo_client_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue'; -import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; - -describe('releases_sort_apollo_client.vue', () => { - let wrapper; - - const createComponent = (valueProp = RELEASED_AT_ASC) => { - wrapper = shallowMountExtended(ReleasesSortApolloClient, { - propsData: { - value: valueProp, - }, - stubs: { - GlSortingItem, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - const findSorting = () => wrapper.findComponent(GlSorting); - const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); - const findReleasedDateItem = () => - findSortingItems().wrappers.find((item) => item.text() === 'Released date'); - const findCreatedDateItem = () => - findSortingItems().wrappers.find((item) => item.text() === 'Created date'); - const getSortingItemsInfo = () => - findSortingItems().wrappers.map((item) => ({ - label: item.text(), - active: item.attributes().active === 'true', - })); - - describe.each` - valueProp | text | isAscending | items - ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} - ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} - ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} - ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} - `('component states', ({ valueProp, text, isAscending, items }) => { - beforeEach(() => { - createComponent(valueProp); - }); - - it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { - expect(findSorting().props()).toEqual( - expect.objectContaining({ - text, - isAscending, - }), - ); - }); - - it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { - expect(getSortingItemsInfo()).toEqual(items); - }); - }); - - const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); - const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); - const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); - - const releasedAtDropdownItemDescription = 'released at dropdown item'; - const createdAtDropdownItemDescription = 'created at dropdown item'; - const sortDirectionButtonDescription = 'sort direction button'; - - describe.each` - initialValueProp | itemClickFn | itemToClickDescription | emittedEvent - ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} - ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} - ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} - ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} - ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} - ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} - ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} - ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} - ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} - ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} - ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} - ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} - `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { - beforeEach(() => { - createComponent(initialValueProp); - itemClickFn(); - }); - - it(`emits ${ - emittedEvent || 'nothing' - } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { - expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); - }); - }); - - describe('prop validation', () => { - it('validates that the `value` prop is one of the expected sort strings', () => { - expect(() => { - createComponent('not a valid value'); - }).toThrow('Invalid prop: custom validator check failed'); - }); - }); -}); diff --git a/spec/frontend/releases/components/releases_sort_spec.js b/spec/frontend/releases/components/releases_sort_spec.js index 7774532bc122135f8aadb68468a21678bd47f800..c6e1846d252b055847ed2232e6fc073a78f56e78 100644 --- a/spec/frontend/releases/components/releases_sort_spec.js +++ b/spec/frontend/releases/components/releases_sort_spec.js @@ -1,65 +1,103 @@ import { GlSorting, GlSortingItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ReleasesSort from '~/releases/components/releases_sort.vue'; -import createStore from '~/releases/stores'; -import createIndexModule from '~/releases/stores/modules/index'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; -Vue.use(Vuex); - -describe('~/releases/components/releases_sort.vue', () => { +describe('releases_sort.vue', () => { let wrapper; - let store; - let indexModule; - const projectId = 8; - - const createComponent = () => { - indexModule = createIndexModule({ projectId }); - store = createStore({ - modules: { - index: indexModule, + const createComponent = (valueProp = RELEASED_AT_ASC) => { + wrapper = shallowMountExtended(ReleasesSort, { + propsData: { + value: valueProp, }, - }); - - store.dispatch = jest.fn(); - - wrapper = shallowMount(ReleasesSort, { - store, stubs: { GlSortingItem, }, }); }; - const findReleasesSorting = () => wrapper.find(GlSorting); - const findSortingItems = () => wrapper.findAll(GlSortingItem); - afterEach(() => { wrapper.destroy(); - wrapper = null; }); - beforeEach(() => { - createComponent(); - }); + const findSorting = () => wrapper.findComponent(GlSorting); + const findSortingItems = () => wrapper.findAllComponents(GlSortingItem); + const findReleasedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Released date'); + const findCreatedDateItem = () => + findSortingItems().wrappers.find((item) => item.text() === 'Created date'); + const getSortingItemsInfo = () => + findSortingItems().wrappers.map((item) => ({ + label: item.text(), + active: item.attributes().active === 'true', + })); + + describe.each` + valueProp | text | isAscending | items + ${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]} + ${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + ${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]} + `('component states', ({ valueProp, text, isAscending, items }) => { + beforeEach(() => { + createComponent(valueProp); + }); - it('has all the sortable items', () => { - expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length); + it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => { + expect(findSorting().props()).toEqual( + expect.objectContaining({ + text, + isAscending, + }), + ); + }); + + it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => { + expect(getSortingItemsInfo()).toEqual(items); + }); }); - it('on sort change set sorting in vuex and emit event', () => { - findReleasesSorting().vm.$emit('sortDirectionChange'); - expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' }); - expect(wrapper.emitted('sort:changed')).toBeTruthy(); + const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click'); + const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click'); + const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange'); + + const releasedAtDropdownItemDescription = 'released at dropdown item'; + const createdAtDropdownItemDescription = 'created at dropdown item'; + const sortDirectionButtonDescription = 'sort direction button'; + + describe.each` + initialValueProp | itemClickFn | itemToClickDescription | emittedEvent + ${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC} + ${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC} + ${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined} + ${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC} + ${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC} + ${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC} + ${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC} + ${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined} + ${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC} + `('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => { + beforeEach(() => { + createComponent(initialValueProp); + itemClickFn(); + }); + + it(`emits ${ + emittedEvent || 'nothing' + } when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => { + expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent); + }); }); - it('on sort item click set sorting and emit event', () => { - const item = findSortingItems().at(0); - const { orderBy } = wrapper.vm.sortOptions[0]; - item.vm.$emit('click'); - expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy }); - expect(wrapper.emitted('sort:changed')).toBeTruthy(); + describe('prop validation', () => { + it('validates that the `value` prop is one of the expected sort strings', () => { + expect(() => { + createComponent('not a valid value'); + }).toThrow('Invalid prop: custom validator check failed'); + }); }); }); diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js deleted file mode 100644 index 91406f7e2f4aff5dffb12ef8872916d371e60af4..0000000000000000000000000000000000000000 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ /dev/null @@ -1,197 +0,0 @@ -import { cloneDeep } from 'lodash'; -import originalGraphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import testAction from 'helpers/vuex_action_helper'; -import { PAGE_SIZE } from '~/releases/constants'; -import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql'; -import { - fetchReleases, - receiveReleasesError, - setSorting, -} from '~/releases/stores/modules/index/actions'; -import * as types from '~/releases/stores/modules/index/mutation_types'; -import createState from '~/releases/stores/modules/index/state'; -import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util'; - -describe('Releases State actions', () => { - let mockedState; - let graphqlReleasesResponse; - - const projectPath = 'root/test-project'; - const projectId = 19; - const before = 'testBeforeCursor'; - const after = 'testAfterCursor'; - - beforeEach(() => { - mockedState = { - ...createState({ - projectId, - projectPath, - }), - }; - - graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); - }); - - describe('fetchReleases', () => { - describe('GraphQL query variables', () => { - let vuexParams; - - beforeEach(() => { - jest.spyOn(gqClient, 'query'); - - vuexParams = { dispatch: jest.fn(), commit: jest.fn(), state: mockedState }; - }); - - describe('when neither a before nor an after parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before: undefined, after: undefined }); - }); - - it('makes a GraphQl query with a first variable', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when only a before parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before, after: undefined }); - }); - - it('makes a GraphQl query with last and before variables', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when only an after parameter is provided', () => { - beforeEach(() => { - fetchReleases(vuexParams, { before: undefined, after }); - }); - - it('makes a GraphQl query with first and after variables', () => { - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' }, - }); - }); - }); - - describe('when both before and after parameters are provided', () => { - it('throws an error', () => { - const callFetchReleases = () => { - fetchReleases(vuexParams, { before, after }); - }; - - expect(callFetchReleases).toThrowError( - 'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.', - ); - }); - }); - - describe('when the sort parameters are provided', () => { - it.each` - sort | orderBy | ReleaseSort - ${'asc'} | ${'released_at'} | ${'RELEASED_AT_ASC'} - ${'desc'} | ${'released_at'} | ${'RELEASED_AT_DESC'} - ${'asc'} | ${'created_at'} | ${'CREATED_ASC'} - ${'desc'} | ${'created_at'} | ${'CREATED_DESC'} - `( - 'correctly sets $ReleaseSort based on $sort and $orderBy', - ({ sort, orderBy, ReleaseSort }) => { - mockedState.sorting.sort = sort; - mockedState.sorting.orderBy = orderBy; - - fetchReleases(vuexParams, { before: undefined, after: undefined }); - - expect(gqClient.query).toHaveBeenCalledWith({ - query: allReleasesQuery, - variables: { fullPath: projectPath, first: PAGE_SIZE, sort: ReleaseSort }, - }); - }, - ); - }); - }); - - describe('when the request is successful', () => { - beforeEach(() => { - jest.spyOn(gqClient, 'query').mockResolvedValue(graphqlReleasesResponse); - }); - - it(`commits ${types.REQUEST_RELEASES} and ${types.RECEIVE_RELEASES_SUCCESS}`, () => { - const convertedResponse = convertAllReleasesGraphQLResponse(graphqlReleasesResponse); - - return testAction( - fetchReleases, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - { - type: types.RECEIVE_RELEASES_SUCCESS, - payload: { - data: convertedResponse.data, - pageInfo: convertedResponse.paginationInfo, - }, - }, - ], - [], - ); - }); - }); - - describe('when the request fails', () => { - beforeEach(() => { - jest.spyOn(gqClient, 'query').mockRejectedValue(new Error('Something went wrong!')); - }); - - it(`commits ${types.REQUEST_RELEASES} and dispatch receiveReleasesError`, () => { - return testAction( - fetchReleases, - {}, - mockedState, - [ - { - type: types.REQUEST_RELEASES, - }, - ], - [ - { - type: 'receiveReleasesError', - }, - ], - ); - }); - }); - }); - - describe('receiveReleasesError', () => { - it('should commit RECEIVE_RELEASES_ERROR mutation', () => { - return testAction( - receiveReleasesError, - null, - mockedState, - [{ type: types.RECEIVE_RELEASES_ERROR }], - [], - ); - }); - }); - - describe('setSorting', () => { - it('should commit SET_SORTING', () => { - return testAction( - setSorting, - { orderBy: 'released_at', sort: 'asc' }, - null, - [{ type: types.SET_SORTING, payload: { orderBy: 'released_at', sort: 'asc' } }], - [], - ); - }); - }); -}); diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js deleted file mode 100644 index 6669f44aa959383fd775e8c86c19ea5a47a65729..0000000000000000000000000000000000000000 --- a/spec/frontend/releases/stores/modules/list/helpers.js +++ /dev/null @@ -1,5 +0,0 @@ -import state from '~/releases/stores/modules/index/state'; - -export const resetStore = (store) => { - store.replaceState(state()); -}; diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js deleted file mode 100644 index 49e324c28a501b40cd9d92559be2b376b72c940a..0000000000000000000000000000000000000000 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import originalRelease from 'test_fixtures/api/releases/release.json'; -import graphqlReleasesResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import * as types from '~/releases/stores/modules/index/mutation_types'; -import mutations from '~/releases/stores/modules/index/mutations'; -import createState from '~/releases/stores/modules/index/state'; -import { convertAllReleasesGraphQLResponse } from '~/releases/util'; - -const originalReleases = [originalRelease]; - -describe('Releases Store Mutations', () => { - let stateCopy; - let pageInfo; - let releases; - - beforeEach(() => { - stateCopy = createState({}); - pageInfo = convertAllReleasesGraphQLResponse(graphqlReleasesResponse).paginationInfo; - releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); - }); - - describe('REQUEST_RELEASES', () => { - it('sets isLoading to true', () => { - mutations[types.REQUEST_RELEASES](stateCopy); - - expect(stateCopy.isLoading).toEqual(true); - }); - }); - - describe('RECEIVE_RELEASES_SUCCESS', () => { - beforeEach(() => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - pageInfo, - data: releases, - }); - }); - - it('sets is loading to false', () => { - expect(stateCopy.isLoading).toEqual(false); - }); - - it('sets hasError to false', () => { - expect(stateCopy.hasError).toEqual(false); - }); - - it('sets data', () => { - expect(stateCopy.releases).toEqual(releases); - }); - - it('sets pageInfo', () => { - expect(stateCopy.pageInfo).toEqual(pageInfo); - }); - }); - - describe('RECEIVE_RELEASES_ERROR', () => { - it('resets data', () => { - mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, { - pageInfo, - data: releases, - }); - - mutations[types.RECEIVE_RELEASES_ERROR](stateCopy); - - expect(stateCopy.isLoading).toEqual(false); - expect(stateCopy.releases).toEqual([]); - expect(stateCopy.pageInfo).toEqual({}); - }); - }); - - describe('SET_SORTING', () => { - it('should merge the sorting object with sort value', () => { - mutations[types.SET_SORTING](stateCopy, { sort: 'asc' }); - expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, sort: 'asc' }); - }); - - it('should merge the sorting object with order_by value', () => { - mutations[types.SET_SORTING](stateCopy, { orderBy: 'created_at' }); - expect(stateCopy.sorting).toEqual({ ...stateCopy.sorting, orderBy: 'created_at' }); - }); - }); -});