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> <script>
import { GlEmptyState, GlLink, GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/url_utility'; 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 { __ } 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 ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue'; import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
import ReleasesEmptyState from './releases_empty_state.vue';
import ReleasesPagination from './releases_pagination.vue'; import ReleasesPagination from './releases_pagination.vue';
import ReleasesSort from './releases_sort.vue'; import ReleasesSort from './releases_sort.vue';
export default { export default {
name: 'ReleasesApp', name: 'ReleasesIndexApp',
components: { components: {
GlEmptyState,
GlLink,
GlButton, GlButton,
ReleaseBlock, ReleaseBlock,
ReleasesPagination,
ReleaseSkeletonLoader, ReleaseSkeletonLoader,
ReleasesEmptyState,
ReleasesPagination,
ReleasesSort, 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: { computed: {
...mapState('index', [ queryVariables() {
'documentationPath', let paginationParams = { first: PAGE_SIZE };
'illustrationPath', if (this.cursors.after) {
'newReleasePath', paginationParams = {
'isLoading', after: this.cursors.after,
'releases', first: PAGE_SIZE,
'hasError', };
]), } else if (this.cursors.before) {
shouldRenderEmptyState() { paginationParams = {
return !this.releases.length && !this.hasError && !this.isLoading; 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() { isFullRequestLoading() {
return this.releases.length && !this.isLoading && !this.hasError; 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() { pageInfo() {
return __( if (!this.isFullRequestLoaded) {
"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.", 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() { created() {
this.fetchReleases(); this.updateQueryParamsFromUrl();
window.addEventListener('popstate', this.fetchReleases); window.addEventListener('popstate', this.updateQueryParamsFromUrl);
},
destroyed() {
window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
}, },
methods: { methods: {
...mapActions('index', { getReleaseKey(release, index) {
fetchReleasesStoreAction: 'fetchReleases', return [release.tagName, release.name, index].join('|');
}), },
fetchReleases() { updateQueryParamsFromUrl() {
this.fetchReleasesStoreAction({ this.cursors.before = getParameterByName('before');
before: getParameterByName('before'), this.cursors.after = getParameterByName('after');
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> </script>
<template> <template>
<div class="flex flex-column mt-2"> <div class="flex flex-column mt-2">
<div class="gl-align-self-end gl-mb-3"> <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 <gl-button
v-if="newReleasePath" v-if="newReleasePath"
:href="newReleasePath" :href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'" :aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary" category="primary"
variant="confirm" variant="success"
data-testid="new-release-button" >{{ $options.i18n.newRelease }}</gl-button
> >
{{ __('New release') }}
</gl-button>
</div> </div>
<release-skeleton-loader v-if="isLoading" /> <releases-empty-state v-if="shouldRenderEmptyState" />
<gl-empty-state <release-block
v-else-if="shouldRenderEmptyState" v-for="(release, index) in releases"
data-testid="empty-state" :key="getReleaseKey(release, index)"
:title="__('Getting started with releases')" :release="release"
:svg-path="illustrationPath" :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
> />
<template #description>
<span id="releases-description"> <release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
{{ 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-pagination v-if="!isLoading" /> <releases-pagination
v-if="shouldRenderPagination"
:page-info="pageInfo"
@prev="onPaginationButtonPress"
@next="onPaginationButtonPress"
/>
</div> </div>
</template> </template>
<style> <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> <script>
import { GlKeysetPagination } from '@gitlab/ui'; import { GlKeysetPagination } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex'; import { isBoolean } from 'lodash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default { export default {
name: 'ReleasesPaginationGraphql', name: 'ReleasesPagination',
components: { GlKeysetPagination }, components: { GlKeysetPagination },
computed: { props: {
...mapState('index', ['pageInfo']), pageInfo: {
showPagination() { type: Object,
return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage; required: true,
validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
}, },
}, },
methods: { methods: {
...mapActions('index', ['fetchReleases']),
onPrev(before) { onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`)); historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
this.fetchReleases({ before });
}, },
onNext(after) { onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`)); historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
this.fetchReleases({ after });
}, },
}, },
}; };
...@@ -28,8 +26,10 @@ export default { ...@@ -28,8 +26,10 @@ export default {
<template> <template>
<div class="gl-display-flex gl-justify-content-center"> <div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination <gl-keyset-pagination
v-if="showPagination"
v-bind="pageInfo" v-bind="pageInfo"
:prev-text="__('Prev')"
:next-text="__('Next')"
v-on="$listeners"
@prev="onPrev($event)" @prev="onPrev($event)"
@next="onNext($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> <script>
import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import {
import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants'; 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 { export default {
name: 'ReleasesSort', name: 'ReleasesSort',
...@@ -9,35 +19,54 @@ export default { ...@@ -9,35 +19,54 @@ export default {
GlSorting, GlSorting,
GlSortingItem, GlSortingItem,
}, },
props: {
value: {
type: String,
required: true,
validator: (sort) => ALL_SORTS.includes(sort),
},
},
computed: { computed: {
...mapState('index', { orderBy() {
orderBy: (state) => state.sorting.orderBy, if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
sort: (state) => state.sorting.sort, return RELEASED_AT;
}), }
return CREATED_AT;
},
direction() {
if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
return ASCENDING_ORDER;
}
return DESCENDING_ORDER;
},
sortOptions() { sortOptions() {
return SORT_OPTIONS; return SORT_OPTIONS;
}, },
sortText() { sortText() {
const option = this.sortOptions.find((s) => s.orderBy === this.orderBy); return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
return option.label;
}, },
isSortAscending() { isDirectionAscending() {
return this.sort === ASCENDING_ORDER; return this.direction === ASCENDING_ORDER;
}, },
}, },
methods: { methods: {
...mapActions('index', ['setSorting']),
onDirectionChange() { onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER; const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
this.setSorting({ sort }); this.emitInputEventIfChanged(this.orderBy, direction);
this.$emit('sort:changed');
}, },
onSortItemClick(item) { onSortItemClick(item) {
this.setSorting({ orderBy: item }); this.emitInputEventIfChanged(item.orderBy, this.direction);
this.$emit('sort:changed');
}, },
isActiveSortItem(item) { 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 { ...@@ -46,15 +75,15 @@ export default {
<template> <template>
<gl-sorting <gl-sorting
:text="sortText" :text="sortText"
:is-ascending="isSortAscending" :is-ascending="isDirectionAscending"
data-testid="releases-sort" data-testid="releases-sort"
@sortDirectionChange="onDirectionChange" @sortDirectionChange="onDirectionChange"
> >
<gl-sorting-item <gl-sorting-item
v-for="item in sortOptions" v-for="item of sortOptions"
:key="item.orderBy" :key="item.orderBy"
:active="isActiveSortItem(item.orderBy)" :active="isActiveSortItem(item)"
@click="onSortItemClick(item.orderBy)" @click="onSortItemClick(item)"
> >
{{ item.label }} {{ item.label }}
</gl-sorting-item> </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" query allReleases(
# 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(
$fullPath: ID! $fullPath: ID!
$first: Int $first: Int
$last: Int $last: Int
...@@ -20,7 +12,87 @@ query allReleasesDeprecated( ...@@ -20,7 +12,87 @@ query allReleasesDeprecated(
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) { releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
__typename __typename
nodes { 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 { pageInfo {
__typename __typename
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import ReleaseIndexApp from './components/app_index.vue'; 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 () => { export default () => {
const el = document.getElementById('js-releases-page'); const el = document.getElementById('js-releases-page');
if (window.gon?.features?.releasesIndexApolloClient) { Vue.use(VueApollo);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient( defaultClient: createDefaultClient(
{}, {},
{ {
// This page attempts to decrease the perceived loading time // This page attempts to decrease the perceived loading time
// by sending two requests: one request for the first item only (which // by sending two requests: one request for the first item only (which
// completes relatively quickly), and one for all the items (which is slower). // completes relatively quickly), and one for all the items (which is slower).
// By default, Apollo Client batches these requests together, which defeats // By default, Apollo Client batches these requests together, which defeats
// the purpose of making separate requests. So we explicitly // the purpose of making separate requests. So we explicitly
// disable batching on this page. // disable batching on this page.
batchMax: 1, batchMax: 1,
}, },
), ),
}); });
return new Vue({
el,
apolloProvider,
provide: { ...el.dataset },
render: (h) => h(ReleaseIndexApollopClientApp),
});
}
Vue.use(Vuex);
return new Vue({ return new Vue({
el, el,
store: createStore({ apolloProvider,
modules: { provide: { ...el.dataset },
index: createIndexModule(el.dataset),
},
}),
render: (h) => h(ReleaseIndexApp), 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 ...@@ -8,9 +8,6 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :authorize_update_release!, only: %i[edit update] before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new before_action :authorize_create_release!, only: :new
before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink 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 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,129 +24,111 @@ RSpec.describe 'User views releases', :js do ...@@ -24,129 +24,111 @@ RSpec.describe 'User views releases', :js do
stub_default_url_options(host: 'localhost') stub_default_url_options(host: 'localhost')
end end
shared_examples 'releases index page' do context('when the user is a maintainer') do
context('when the user is a maintainer') do before do
before do sign_in(maintainer)
sign_in(maintainer)
visit project_releases_path(project) visit project_releases_path(project)
wait_for_requests wait_for_requests
end end
it 'sees the release' do it 'sees the release' do
page.within("##{release_v1.tag}") do page.within("##{release_v1.tag}") do
expect(page).to have_content(release_v1.name) expect(page).to have_content(release_v1.name)
expect(page).to have_content(release_v1.tag) expect(page).to have_content(release_v1.tag)
expect(page).not_to have_content('Upcoming Release') expect(page).not_to have_content('Upcoming Release')
end
end end
end
it 'renders the correct links', :aggregate_failures do it 'renders the correct links', :aggregate_failures do
page.within("##{release_v1.tag} .js-assets-list") do page.within("##{release_v1.tag} .js-assets-list") do
external_link_indicator_selector = '[data-testid="external-link-indicator"]' external_link_indicator_selector = '[data-testid="external-link-indicator"]'
expect(page).to have_link internal_link.name, href: internal_link.url 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(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(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(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(page).to have_link external_link.name, href: external_link.url
expect(find_link(external_link.name)).to have_css(external_link_indicator_selector) expect(find_link(external_link.name)).to have_css(external_link_indicator_selector)
end
end end
end
context 'with an upcoming release' do context 'with an upcoming release' do
it 'sees the upcoming tag' do it 'sees the upcoming tag' do
page.within("##{release_v3.tag}") do page.within("##{release_v3.tag}") do
expect(page).to have_content('Upcoming Release') expect(page).to have_content('Upcoming Release')
end
end end
end end
end
context 'with a tag containing a slash' do context 'with a tag containing a slash' do
it 'sees the release' do it 'sees the release' do
page.within("##{release_v2.tag.parameterize}") do page.within("##{release_v2.tag.parameterize}") do
expect(page).to have_content(release_v2.name) expect(page).to have_content(release_v2.name)
expect(page).to have_content(release_v2.tag) expect(page).to have_content(release_v2.tag)
end
end end
end end
end
context 'sorting' do context 'sorting' do
def sort_page(by:, direction:) def sort_page(by:, direction:)
within '[data-testid="releases-sort"]' do within '[data-testid="releases-sort"]' do
find('.dropdown-toggle').click 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 "when the page is sorted by the default sort order" do click_button(by, class: 'dropdown-item')
let(:expected_releases) { [release_v3, release_v2, release_v1] }
it_behaves_like 'releases sort order' find('.sorting-direction-button').click if direction == :ascending
end end
end
context "when the page is sorted by created_at ascending " do shared_examples 'releases sort order' do
let(:expected_releases) { [release_v2, release_v1, release_v3] } it "sorts the releases #{description}" do
card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
before do card_titles.each_with_index do |title, index|
sort_page by: 'Created date', direction: :ascending expect(title).to have_content(expected_releases[index].name)
end end
it_behaves_like 'releases sort order'
end end
end end
end
context('when the user is a guest') do context "when the page is sorted by the default sort order" do
before do let(:expected_releases) { [release_v3, release_v2, release_v1] }
sign_in(guest)
end
it 'renders release info except for Git-related data' do it_behaves_like 'releases sort order'
visit project_releases_path(project) end
within('.release-block', match: :first) do context "when the page is sorted by created_at ascending " do
expect(page).to have_content(release_v3.description) let(:expected_releases) { [release_v2, release_v1, release_v3] }
expect(page).to have_content(release_v3.tag)
expect(page).to have_content(release_v3.name)
# The following properties (sometimes) include Git info, before do
# so they are not rendered for Guest users sort_page by: 'Created date', direction: :ascending
expect(page).not_to have_content(release_v3.commit.short_id)
end end
it_behaves_like 'releases sort order'
end end
end 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 before do
stub_feature_flags(releases_index_apollo_client: true) sign_in(guest)
end end
it_behaves_like 'releases index page' it 'renders release info except for Git-related data' do
end visit project_releases_path(project)
context 'when the releases_index_apollo_client feature flag is disabled' do within('.release-block', match: :first) do
before do expect(page).to have_content(release_v3.description)
stub_feature_flags(releases_index_apollo_client: false) expect(page).to have_content(release_v3.tag)
end 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
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 { cloneDeep } from 'lodash';
import { merge } from 'lodash'; import Vue, { nextTick } from 'vue';
import Vue from 'vue'; import VueApollo from 'vue-apollo';
import Vuex from 'vuex'; import originalAllReleasesQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/all_releases.query.graphql.json';
import { getParameterByName } from '~/lib/utils/url_utility'; import createMockApollo from 'helpers/mock_apollo_helper';
import AppIndex from '~/releases/components/app_index.vue'; 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 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 ReleasesPagination from '~/releases/components/releases_pagination.vue';
import ReleasesSort from '~/releases/components/releases_sort.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.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/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', () => { describe('app_index.vue', () => {
const projectPath = 'project/path';
const newReleasePath = 'path/to/new/release/page';
const before = 'beforeCursor';
const after = 'afterCursor';
let wrapper; let wrapper;
let fetchReleasesSpy; let allReleases;
let urlParams; let singleRelease;
let noReleases;
const createComponent = (storeUpdates) => { let queryMock;
wrapper = shallowMount(AppIndex, {
store: new Vuex.Store({ const createComponent = ({
modules: { singleResponse = Promise.resolve(singleRelease),
index: merge( fullResponse = Promise.resolve(allReleases),
{ } = {}) => {
namespaced: true, const apolloProvider = createMockApollo([
actions: { [
fetchReleases: fetchReleasesSpy, allReleasesQuery,
}, queryMock.mockImplementation((vars) => {
state: { return vars.first === 1 ? singleResponse : fullResponse;
isLoading: true, }),
releases: [], ],
}, ]);
},
storeUpdates, wrapper = shallowMountExtended(ReleasesIndexApp, {
), apolloProvider,
}, provide: {
}), newReleasePath,
projectPath,
},
}); });
}; };
beforeEach(() => { beforeEach(() => {
fetchReleasesSpy = jest.fn(); mockQueryParams = {};
getParameterByName.mockImplementation((paramName) => urlParams[paramName]);
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(() => { afterEach(() => {
...@@ -52,120 +89,220 @@ describe('app_index.vue', () => { ...@@ -52,120 +89,220 @@ describe('app_index.vue', () => {
}); });
// Finders // Finders
const findLoadingIndicator = () => wrapper.find(ReleaseSkeletonLoader); const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader);
const findEmptyState = () => wrapper.find('[data-testid="empty-state"]'); const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState);
const findSuccessState = () => wrapper.find('[data-testid="success-state"]'); const findNewReleaseButton = () => wrapper.findByText(ReleasesIndexApp.i18n.newRelease);
const findPagination = () => wrapper.find(ReleasesPagination); const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
const findSortControls = () => wrapper.find(ReleasesSort); const findPagination = () => wrapper.findComponent(ReleasesPagination);
const findNewReleaseButton = () => wrapper.find('[data-testid="new-release-button"]'); const findSort = () => wrapper.findComponent(ReleasesSort);
// Expectations
const expectLoadingIndicator = (shouldExist) => {
it(`${shouldExist ? 'renders' : 'does not render'} a loading indicator`, () => {
expect(findLoadingIndicator().exists()).toBe(shouldExist);
});
};
const expectEmptyState = (shouldExist) => { // Tests
it(`${shouldExist ? 'renders' : 'does not render'} an empty state`, () => { describe('component states', () => {
expect(findEmptyState().exists()).toBe(shouldExist); // 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) => { describe('URL parameters', () => {
it(`${shouldExist ? 'renders' : 'does not render'} the success state`, () => { describe('when the URL contains no query parameters', () => {
expect(findSuccessState().exists()).toBe(shouldExist); beforeEach(() => {
}); createComponent();
}; });
const expectPagination = (shouldExist) => { it('makes a request with the correct GraphQL query parameters', () => {
it(`${shouldExist ? 'renders' : 'does not render'} the pagination controls`, () => { expect(queryMock).toHaveBeenCalledTimes(2);
expect(findPagination().exists()).toBe(shouldExist);
});
};
const expectNewReleaseButton = (shouldExist) => { expect(queryMock).toHaveBeenCalledWith({
it(`${shouldExist ? 'renders' : 'does not render'} the "New release" button`, () => { first: 1,
expect(findNewReleaseButton().exists()).toBe(shouldExist); fullPath: projectPath,
}); sort: DEFAULT_SORT,
}; });
// Tests expect(queryMock).toHaveBeenCalledWith({
describe('on startup', () => { first: PAGE_SIZE,
it.each` fullPath: projectPath,
before | after sort: DEFAULT_SORT,
${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 };
describe('when the URL contains a "before" query parameter', () => {
beforeEach(() => {
mockQueryParams = { before };
createComponent(); createComponent();
});
expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); it('makes a request with the correct GraphQL query parameters', () => {
expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); expect(queryMock).toHaveBeenCalledTimes(1);
},
);
});
describe('when the request to fetch releases has not yet completed', () => { expect(queryMock).toHaveBeenCalledWith({
beforeEach(() => { before,
createComponent(); last: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
});
}); });
expectLoadingIndicator(true); describe('when the URL contains an "after" query parameter', () => {
expectEmptyState(false); beforeEach(() => {
expectSuccessState(false); mockQueryParams = { after };
expectPagination(false); createComponent();
}); });
describe('when the request fails', () => { it('makes a request with the correct GraphQL query parameters', () => {
beforeEach(() => { expect(queryMock).toHaveBeenCalledTimes(2);
createComponent({
state: { expect(queryMock).toHaveBeenCalledWith({
isLoading: false, after,
hasError: true, first: 1,
}, fullPath: projectPath,
sort: DEFAULT_SORT,
});
expect(queryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
sort: DEFAULT_SORT,
});
}); });
}); });
expectLoadingIndicator(false); describe('when the URL contains both "before" and "after" query parameters', () => {
expectEmptyState(false); beforeEach(() => {
expectSuccessState(false); mockQueryParams = { before, after };
expectPagination(true); 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(() => { beforeEach(() => {
createComponent({ createComponent();
state: {
isLoading: false,
},
});
}); });
expectLoadingIndicator(false); it('renders the new release button with the correct href', () => {
expectEmptyState(true); expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
expectSuccessState(false); });
expectPagination(true);
}); });
describe('when the request succeeds and includes at least one release', () => { describe('pagination', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ mockQueryParams = { before };
state: { createComponent();
isLoading: false,
releases: [{}],
},
});
}); });
expectLoadingIndicator(false); it('requeries the GraphQL endpoint when a pagination button is clicked', async () => {
expectEmptyState(false); expect(queryMock.mock.calls).toEqual([[expect.objectContaining({ before })]]);
expectSuccessState(true);
expectPagination(true); 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', () => { describe('sorting', () => {
...@@ -173,59 +310,88 @@ describe('app_index.vue', () => { ...@@ -173,59 +310,88 @@ describe('app_index.vue', () => {
createComponent(); createComponent();
}); });
it('renders the sort controls', () => { it(`sorts by ${DEFAULT_SORT} by default`, () => {
expect(findSortControls().exists()).toBe(true); 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', () => { it('requeries the GraphQL endpoint and updates the URL when the sort is changed', async () => {
fetchReleasesSpy.mockClear(); 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', () => { it('does not requery the GraphQL endpoint or update the URL if the sort is updated to the same value', async () => {
describe('when the user is allowed to create releases', () => { findSort().vm.$emit('input', DEFAULT_SORT);
const newReleasePath = 'path/to/new/release/page';
beforeEach(() => { await nextTick();
createComponent({ state: { newReleasePath } });
});
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(historyPushState).not.toHaveBeenCalled();
expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath);
});
}); });
});
describe('when the user is not allowed to create releases', () => { describe('sorting + pagination interaction', () => {
beforeEach(() => { const nonPaginationQueryParam = 'nonPaginationQueryParam';
createComponent();
});
expectNewReleaseButton(false); beforeEach(() => {
historyPushState.mockImplementation((newUrl) => {
mockQueryParams = Object.fromEntries(new URL(newUrl).searchParams);
});
}); });
});
describe("when the browser's back button is pressed", () => { describe.each`
beforeEach(() => { queryParamsBefore | paramName | paramInitialValue
urlParams = { ${{ before, nonPaginationQueryParam }} | ${'before'} | ${before}
before: 'before_param_value', ${{ 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(firstRequestVariables[paramName]).toBe(paramInitialValue);
expect(fetchReleasesSpy).toHaveBeenCalledTimes(1); expect(mostRecentRequestVariables[paramName]).toBeUndefined();
expect(fetchReleasesSpy).toHaveBeenCalledWith(expect.anything(), urlParams); });
});
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 { mountExtended } from 'helpers/vue_test_utils_helper';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { historyPushState } from '~/lib/utils/common_utils'; import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesPagination from '~/releases/components/releases_pagination.vue'; 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.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'), ...jest.requireActual('~/lib/utils/common_utils'),
historyPushState: jest.fn(), historyPushState: jest.fn(),
})); }));
Vue.use(Vuex); describe('releases_pagination.vue', () => {
const startCursor = 'startCursor';
describe('~/releases/components/releases_pagination.vue', () => { const endCursor = 'endCursor';
let wrapper; let wrapper;
let indexModule; let onPrev;
let onNext;
const cursors = {
startCursor: 'startCursor',
endCursor: 'endCursor',
};
const projectPath = 'my/project';
const createComponent = (pageInfo) => { const createComponent = (pageInfo) => {
indexModule = createIndexModule({ projectPath }); onPrev = jest.fn();
onNext = jest.fn();
indexModule.state.pageInfo = pageInfo;
wrapper = mountExtended(ReleasesPagination, {
indexModule.actions.fetchReleases = jest.fn(); propsData: {
pageInfo,
wrapper = mount(ReleasesPagination, { },
store: createStore({ listeners: {
modules: { prev: onPrev,
index: indexModule, next: onNext,
}, },
featureFlags: {},
}),
}); });
}; };
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); const singlePageInfo = {
const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]'); hasPreviousPage: false,
const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]'); hasNextPage: false,
startCursor,
const expectDisabledPrev = () => { endCursor,
expect(findPrevButton().attributes().disabled).toBe('disabled');
}; };
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', () => { const findPrevButton = () => wrapper.findByTestId('prevButton');
beforeEach(() => { const findNextButton = () => wrapper.findByTestId('nextButton');
createComponent({
hasPreviousPage: false, describe.each`
hasNextPage: false, 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', () => { it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
beforeEach(() => { expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
createComponent({
hasPreviousPage: false,
hasNextPage: true,
}); });
});
it('renders a disabled "Prev" button', () => {
expectDisabledPrev();
});
it('renders an enabled "Next" button', () => { it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
expectEnabledNext(); expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
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 a enabled "Prev" button', () => {
expectEnabledPrev();
});
it('renders an enabled "Next" button', () => {
expectEnabledNext();
});
}); });
describe('button behavior', () => { describe('button behavior', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent(prevAndNextPageInfo);
hasPreviousPage: true,
hasNextPage: true,
...cursors,
});
}); });
describe('next button behavior', () => { describe('next button behavior', () => {
...@@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => { ...@@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => {
findNextButton().trigger('click'); findNextButton().trigger('click');
}); });
it('calls fetchReleases with the correct after cursor', () => { it('emits an "next" event with the "after" cursor', () => {
expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ expect(onNext.mock.calls).toEqual([[endCursor]]);
[expect.anything(), { after: cursors.endCursor }],
]);
}); });
it('calls historyPushState with the new URL', () => { it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([ expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?after=${cursors.endCursor}`)], [expect.stringContaining(`?after=${endCursor}`)],
]); ]);
}); });
}); });
describe('previous button behavior', () => { describe('prev button behavior', () => {
beforeEach(() => { beforeEach(() => {
findPrevButton().trigger('click'); findPrevButton().trigger('click');
}); });
it('calls fetchReleases with the correct before cursor', () => { it('emits an "prev" event with the "before" cursor', () => {
expect(indexModule.actions.fetchReleases.mock.calls).toEqual([ expect(onPrev.mock.calls).toEqual([[startCursor]]);
[expect.anything(), { before: cursors.startCursor }],
]);
}); });
it('calls historyPushState with the new URL', () => { it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([ 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 { GlSorting, GlSortingItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import Vue from 'vue';
import Vuex from 'vuex';
import ReleasesSort from '~/releases/components/releases_sort.vue'; import ReleasesSort from '~/releases/components/releases_sort.vue';
import createStore from '~/releases/stores'; import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
import createIndexModule from '~/releases/stores/modules/index';
Vue.use(Vuex); describe('releases_sort.vue', () => {
describe('~/releases/components/releases_sort.vue', () => {
let wrapper; let wrapper;
let store;
let indexModule;
const projectId = 8;
const createComponent = () => {
indexModule = createIndexModule({ projectId });
store = createStore({ const createComponent = (valueProp = RELEASED_AT_ASC) => {
modules: { wrapper = shallowMountExtended(ReleasesSort, {
index: indexModule, propsData: {
value: valueProp,
}, },
});
store.dispatch = jest.fn();
wrapper = shallowMount(ReleasesSort, {
store,
stubs: { stubs: {
GlSortingItem, GlSortingItem,
}, },
}); });
}; };
const findReleasesSorting = () => wrapper.find(GlSorting);
const findSortingItems = () => wrapper.findAll(GlSortingItem);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
beforeEach(() => { const findSorting = () => wrapper.findComponent(GlSorting);
createComponent(); 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', () => { it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => {
expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length); 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', () => { const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click');
findReleasesSorting().vm.$emit('sortDirectionChange'); const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' }); const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange');
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);
});
}); });
it('on sort item click set sorting and emit event', () => { describe('prop validation', () => {
const item = findSortingItems().at(0); it('validates that the `value` prop is one of the expected sort strings', () => {
const { orderBy } = wrapper.vm.sortOptions[0]; expect(() => {
item.vm.$emit('click'); createComponent('not a valid value');
expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy }); }).toThrow('Invalid prop: custom validator check failed');
expect(wrapper.emitted('sort:changed')).toBeTruthy(); });
}); });
}); });
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