Commit 18cbe3da authored by Paul Slaughter's avatar Paul Slaughter

Merge branch 'afontaine/clean-up-releases-index-apollo-client' into 'master'

Remove releases_index_apollo_client Feature Flag

See merge request gitlab-org/gitlab!82934
parents 3b865fe8 fe8f5ca4
<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;
},
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);
},
shouldRenderSuccessState() {
return this.releases.length && !this.isLoading && !this.hasError;
/**
* @returns {Boolean} `true` if the `fullGraphqlResponse`
* query has finished loading without errors
*/
isFullRequestLoaded() {
return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
},
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.",
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.fetchReleases();
this.updateQueryParamsFromUrl();
window.addEventListener('popstate', this.fetchReleases);
window.addEventListener('popstate', this.updateQueryParamsFromUrl);
},
destroyed() {
window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
},
methods: {
...mapActions('index', {
fetchReleasesStoreAction: 'fetchReleases',
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,
}),
fetchReleases() {
this.fetchReleasesStoreAction({
before: getParameterByName('before'),
after: getParameterByName('after'),
});
);
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" />
<releases-empty-state v-if="shouldRenderEmptyState" />
<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"
:key="getReleaseKey(release, index)"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
</div>
<releases-pagination v-if="!isLoading" />
<release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
<releases-pagination
v-if="shouldRenderPagination"
:page-info="pageInfo"
@prev="onPaginationButtonPress"
@next="onPaginationButtonPress"
/>
</div>
</template>
<style>
......
<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>
<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)"
/>
......
<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>
<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>
......
<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>
#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
......
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);
const apolloProvider = new VueApollo({
......@@ -32,19 +27,6 @@ export default () => {
el,
apolloProvider,
provide: { ...el.dataset },
render: (h) => h(ReleaseIndexApollopClientApp),
});
}
Vue.use(Vuex);
return new Vue({
el,
store: createStore({
modules: {
index: createIndexModule(el.dataset),
},
}),
render: (h) => h(ReleaseIndexApp),
});
};
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);
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
export default (initialState) => ({
namespaced: true,
actions,
mutations,
state: createState(initialState),
});
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';
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 };
},
};
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,
},
});
......@@ -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
......
# 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
}
}
}
}
---
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
......@@ -24,7 +24,6 @@ RSpec.describe 'User views releases', :js do
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)
......@@ -132,21 +131,4 @@ RSpec.describe 'User views releases', :js do
end
end
end
end
context 'when the releases_index_apollo_client feature flag is enabled' do
before do
stub_feature_flags(releases_index_apollo_client: true)
end
it_behaves_like 'releases index page'
end
context 'when the releases_index_apollo_client feature flag is disabled' do
before do
stub_feature_flags(releases_index_apollo_client: false)
end
it_behaves_like 'releases index page'
end
end
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();
});
},
);
});
});
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,180 +89,309 @@ 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);
// 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(),
});
});
};
const expectEmptyState = (shouldExist) => {
it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => {
expect(findEmptyState().exists()).toBe(shouldExist);
it(`${toDescription(loadingIndicator)} render a loading indicator`, async () => {
await waitForPromises();
expect(findLoadingIndicator().exists()).toBe(loadingIndicator);
});
};
const expectSuccessState = (shouldExist) => {
it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => {
expect(findSuccessState().exists()).toBe(shouldExist);
it(`${toDescription(emptyState)} render an empty state`, () => {
expect(findEmptyState().exists()).toBe(emptyState);
});
};
const expectPagination = (shouldExist) => {
it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => {
expect(findPagination().exists()).toBe(shouldExist);
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();
}
});
};
const expectNewReleaseButton = (shouldExist) => {
it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => {
expect(findNewReleaseButton().exists()).toBe(shouldExist);
it(`renders ${releaseCount} release(s)`, () => {
expect(findAllReleaseBlocks()).toHaveLength(releaseCount);
});
};
// 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 };
it(`${toDescription(pagination)} render the pagination controls`, () => {
expect(findPagination().exists()).toBe(pagination);
});
createComponent();
it('does render the "New release" button', () => {
expect(findNewReleaseButton().exists()).toBe(true);
});
expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams);
it('does render the sort controls', () => {
expect(findSort().exists()).toBe(true);
});
},
);
});
describe('when the request to fetch releases has not yet completed', () => {
describe('URL parameters', () => {
describe('when the URL contains no query parameters', () => {
beforeEach(() => {
createComponent();
});
expectLoadingIndicator(true);
expectEmptyState(false);
expectSuccessState(false);
expectPagination(false);
it('makes a request with the correct GraphQL query parameters', () => {
expect(queryMock).toHaveBeenCalledTimes(2);
expect(queryMock).toHaveBeenCalledWith({
first: 1,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
describe('when the request fails', () => {
beforeEach(() => {
createComponent({
state: {
isLoading: false,
hasError: true,
},
expect(queryMock).toHaveBeenCalledWith({
first: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
});
expectLoadingIndicator(false);
expectEmptyState(false);
expectSuccessState(false);
expectPagination(true);
});
describe('when the request succeeds but returns no releases', () => {
describe('when the URL contains a "before" query parameter', () => {
beforeEach(() => {
createComponent({
state: {
isLoading: false,
},
});
mockQueryParams = { before };
createComponent();
});
expectLoadingIndicator(false);
expectEmptyState(true);
expectSuccessState(false);
expectPagination(true);
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 request succeeds and includes at least one release', () => {
describe('when the URL contains an "after" query parameter', () => {
beforeEach(() => {
createComponent({
state: {
isLoading: false,
releases: [{}],
},
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,
});
expectLoadingIndicator(false);
expectEmptyState(false);
expectSuccessState(true);
expectPagination(true);
expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
});
});
describe('sorting', () => {
describe('when the URL contains both "before" and "after" query parameters', () => {
beforeEach(() => {
mockQueryParams = { before, after };
createComponent();
});
it('renders the sort controls', () => {
expect(findSortControls().exists()).toBe(true);
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,
});
it('calls the fetchReleases store method when the sort is updated', () => {
fetchReleasesSpy.mockClear();
expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
});
});
});
findSortControls().vm.$emit('sort:changed');
describe('New release button', () => {
beforeEach(() => {
createComponent();
});
expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
it('renders the new release button with the correct href', () => {
expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
});
});
describe('"New release" button', () => {
describe('when the user is allowed to create releases', () => {
const newReleasePath = 'path/to/new/release/page';
describe('pagination', () => {
beforeEach(() => {
createComponent({ state: { newReleasePath } });
mockQueryParams = { before };
createComponent();
});
expectNewReleaseButton(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);
it('renders the button with the correct href', () => {
expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath);
await nextTick();
expect(queryMock.mock.calls).toEqual([
[expect.objectContaining({ before })],
[expect.objectContaining({ after })],
[expect.objectContaining({ after })],
]);
});
});
describe('when the user is not allowed to create releases', () => {
describe('sorting', () => {
beforeEach(() => {
createComponent();
});
expectNewReleaseButton(false);
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';
describe("when the browser's back button is pressed", () => {
beforeEach(() => {
urlParams = {
before: 'before_param_value',
};
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();
fetchReleasesSpy.mockClear();
findSort().vm.$emit('input', CREATED_ASC);
window.dispatchEvent(new PopStateEvent('popstate'));
await nextTick();
});
it('calls the fetchRelease store method with the parameters from the URL query', () => {
expect(fetchReleasesSpy).toHaveBeenCalledTimes(1);
expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams);
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();
});
},
);
});
});
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}`)],
]);
});
});
});
});
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 });
onPrev = jest.fn();
onNext = jest.fn();
indexModule.state.pageInfo = pageInfo;
indexModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPagination, {
store: createStore({
modules: {
index: indexModule,
wrapper = mountExtended(ReleasesPagination, {
propsData: {
pageInfo,
},
listeners: {
prev: onPrev,
next: onNext,
},
featureFlags: {},
}),
});
};
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 expectEnabledPrev = () => {
expect(findPrevButton().attributes().disabled).toBe(undefined);
};
const expectDisabledNext = () => {
expect(findNextButton().attributes().disabled).toBe('disabled');
};
const expectEnabledNext = () => {
expect(findNextButton().attributes().disabled).toBe(undefined);
};
describe('when there is only one page of results', () => {
beforeEach(() => {
createComponent({
const singlePageInfo = {
hasPreviousPage: false,
hasNextPage: false,
});
});
it('does not render a GlKeysetPagination', () => {
expect(findGlKeysetPagination().exists()).toBe(false);
});
});
startCursor,
endCursor,
};
describe('when there is a next page, but not a previous page', () => {
beforeEach(() => {
createComponent({
const onlyNextPageInfo = {
hasPreviousPage: false,
hasNextPage: true,
});
});
it('renders a disabled "Prev" button', () => {
expectDisabledPrev();
});
it('renders an enabled "Next" button', () => {
expectEnabledNext();
});
});
startCursor,
endCursor,
};
describe('when there is a previous page, but not a next page', () => {
beforeEach(() => {
createComponent({
const onlyPrevPageInfo = {
hasPreviousPage: true,
hasNextPage: false,
});
});
it('renders a enabled "Prev" button', () => {
expectEnabledPrev();
});
it('renders an disabled "Next" button', () => {
expectDisabledNext();
});
});
startCursor,
endCursor,
};
describe('when there is both a previous page and a next page', () => {
beforeEach(() => {
createComponent({
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 a enabled "Prev" button', () => {
expectEnabledPrev();
it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
});
it('renders an enabled "Next" button', () => {
expectEnabledNext();
it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
});
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}`)],
]);
});
});
......
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');
});
});
});
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;
});
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();
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('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();
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');
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();
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');
});
});
});
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' } }],
[],
);
});
});
});
import state from '~/releases/stores/modules/index/state';
export const resetStore = (store) => {
store.replaceState(state());
};
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' });
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment