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

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

Remove releases_index_apollo_client Feature Flag

See merge request gitlab-org/gitlab!82934
parents 3b865fe8 fe8f5ca4
<script>
import { GlButton } from '@gitlab/ui';
import allReleasesQuery from 'shared_queries/releases/all_releases.query.graphql';
import createFlash from '~/flash';
import { historyPushState } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams, getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
import { convertAllReleasesGraphQLResponse } from '~/releases/util';
import ReleaseBlock from './release_block.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
import ReleasesEmptyState from './releases_empty_state.vue';
import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue';
import ReleasesSortApolloClient from './releases_sort_apollo_client.vue';
export default {
name: 'ReleasesIndexApolloClientApp',
components: {
GlButton,
ReleaseBlock,
ReleaseSkeletonLoader,
ReleasesEmptyState,
ReleasesPaginationApolloClient,
ReleasesSortApolloClient,
},
inject: {
projectPath: {
default: '',
},
newReleasePath: {
default: '',
},
},
apollo: {
/**
* The same query as `fullGraphqlResponse`, except that it limits its
* results to a single item. This causes this request to complete much more
* quickly than `fullGraphqlResponse`, which allows the page to show
* meaningful content to the user much earlier.
*/
singleGraphqlResponse: {
query: allReleasesQuery,
// This trick only works when paginating _forward_.
// When paginating backwards, limiting the query to a single item loads
// the _last_ item in the page, which is not useful for our purposes.
skip() {
return !this.includeSingleQuery;
},
variables() {
return {
...this.queryVariables,
first: 1,
};
},
update(data) {
return { data };
},
error() {
this.singleRequestError = true;
},
},
fullGraphqlResponse: {
query: allReleasesQuery,
variables() {
return this.queryVariables;
},
update(data) {
return { data };
},
error(error) {
this.fullRequestError = true;
createFlash({
message: this.$options.i18n.errorMessage,
captureError: true,
error,
});
},
},
},
data() {
return {
singleRequestError: false,
fullRequestError: false,
cursors: {
before: getParameterByName('before'),
after: getParameterByName('after'),
},
sort: DEFAULT_SORT,
};
},
computed: {
queryVariables() {
let paginationParams = { first: PAGE_SIZE };
if (this.cursors.after) {
paginationParams = {
after: this.cursors.after,
first: PAGE_SIZE,
};
} else if (this.cursors.before) {
paginationParams = {
before: this.cursors.before,
last: PAGE_SIZE,
};
}
return {
fullPath: this.projectPath,
...paginationParams,
sort: this.sort,
};
},
/**
* @returns {Boolean} Whether or not to request/include
* the results of the single-item query
*/
includeSingleQuery() {
return Boolean(!this.cursors.before || this.cursors.after);
},
isSingleRequestLoading() {
return this.$apollo.queries.singleGraphqlResponse.loading;
},
isFullRequestLoading() {
return this.$apollo.queries.fullGraphqlResponse.loading;
},
/**
* @returns {Boolean} `true` if the `singleGraphqlResponse`
* query has finished loading without errors
*/
isSingleRequestLoaded() {
return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
},
/**
* @returns {Boolean} `true` if the `fullGraphqlResponse`
* query has finished loading without errors
*/
isFullRequestLoaded() {
return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
},
releases() {
if (this.isFullRequestLoaded) {
return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
}
if (this.isSingleRequestLoaded && this.includeSingleQuery) {
return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
}
return [];
},
pageInfo() {
if (!this.isFullRequestLoaded) {
return {
hasPreviousPage: false,
hasNextPage: false,
};
}
return this.fullGraphqlResponse.data.project.releases.pageInfo;
},
shouldRenderEmptyState() {
return this.isFullRequestLoaded && this.releases.length === 0;
},
shouldRenderLoadingIndicator() {
return (
(this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
(this.isFullRequestLoading && !this.fullRequestError)
);
},
shouldRenderPagination() {
return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
},
},
created() {
this.updateQueryParamsFromUrl();
window.addEventListener('popstate', this.updateQueryParamsFromUrl);
},
destroyed() {
window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
},
methods: {
getReleaseKey(release, index) {
return [release.tagName, release.name, index].join('|');
},
updateQueryParamsFromUrl() {
this.cursors.before = getParameterByName('before');
this.cursors.after = getParameterByName('after');
},
onPaginationButtonPress() {
this.updateQueryParamsFromUrl();
// In some cases, Apollo Client is able to pull its results from the cache instead of making
// a new network request. In these cases, the page's content gets swapped out immediately without
// changing the page's scroll, leaving the user looking at the bottom of the new page.
// To make the experience consistent, regardless of how the data is sourced, we manually
// scroll to the top of the page every time a pagination button is pressed.
scrollUp();
},
onSortChanged(newSort) {
if (this.sort === newSort) {
return;
}
// Remove the "before" and "after" query parameters from the URL,
// effectively placing the user back on page 1 of the results.
// This prevents the frontend from requesting the results sorted
// by one field (e.g. `released_at`) while using a pagination cursor
// intended for a different field (e.g.) `created_at`).
// For more details, see the MR that introduced this change:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434
historyPushState(
setUrlParams({
before: null,
after: null,
}),
);
this.updateQueryParamsFromUrl();
this.sort = newSort;
},
},
i18n: {
newRelease: __('New release'),
errorMessage: __('An error occurred while fetching the releases. Please try again.'),
},
};
</script>
<template>
<div class="flex flex-column mt-2">
<div class="gl-align-self-end gl-mb-3">
<releases-sort-apollo-client :value="sort" class="gl-mr-2" @input="onSortChanged" />
<gl-button
v-if="newReleasePath"
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
variant="success"
>{{ $options.i18n.newRelease }}</gl-button
>
</div>
<releases-empty-state v-if="shouldRenderEmptyState" />
<release-block
v-for="(release, index) in releases"
:key="getReleaseKey(release, index)"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
<release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
<releases-pagination-apollo-client
v-if="shouldRenderPagination"
:page-info="pageInfo"
@prev="onPaginationButtonPress"
@next="onPaginationButtonPress"
/>
</div>
</template>
<style>
.linked-card::after {
width: 1px;
content: ' ';
border: 1px solid #e5e5e5;
height: 17px;
top: 100%;
position: absolute;
left: 32px;
}
</style>
<script>
import { GlKeysetPagination } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { isBoolean } from 'lodash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
name: 'ReleasesPaginationGraphql',
name: 'ReleasesPagination',
components: { GlKeysetPagination },
computed: {
...mapState('index', ['pageInfo']),
showPagination() {
return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
props: {
pageInfo: {
type: Object,
required: true,
validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
},
},
methods: {
...mapActions('index', ['fetchReleases']),
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
this.fetchReleases({ before });
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
this.fetchReleases({ after });
},
},
};
......@@ -28,8 +26,10 @@ export default {
<template>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
v-bind="pageInfo"
:prev-text="__('Prev')"
:next-text="__('Next')"
v-on="$listeners"
@prev="onPrev($event)"
@next="onNext($event)"
/>
......
<script>
import { GlKeysetPagination } from '@gitlab/ui';
import { isBoolean } from 'lodash';
import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
export default {
name: 'ReleasesPaginationApolloClient',
components: { GlKeysetPagination },
props: {
pageInfo: {
type: Object,
required: true,
validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
},
},
methods: {
onPrev(before) {
historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
},
onNext(after) {
historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
},
},
};
</script>
<template>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-bind="pageInfo"
:prev-text="__('Prev')"
:next-text="__('Next')"
v-on="$listeners"
@prev="onPrev($event)"
@next="onNext($event)"
/>
</div>
</template>
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
import {
ASCENDING_ORDER,
DESCENDING_ORDER,
SORT_OPTIONS,
RELEASED_AT,
CREATED_AT,
RELEASED_AT_ASC,
RELEASED_AT_DESC,
CREATED_ASC,
ALL_SORTS,
SORT_MAP,
} from '../constants';
export default {
name: 'ReleasesSort',
......@@ -9,35 +19,54 @@ export default {
GlSorting,
GlSortingItem,
},
props: {
value: {
type: String,
required: true,
validator: (sort) => ALL_SORTS.includes(sort),
},
},
computed: {
...mapState('index', {
orderBy: (state) => state.sorting.orderBy,
sort: (state) => state.sorting.sort,
}),
orderBy() {
if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
return RELEASED_AT;
}
return CREATED_AT;
},
direction() {
if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
return ASCENDING_ORDER;
}
return DESCENDING_ORDER;
},
sortOptions() {
return SORT_OPTIONS;
},
sortText() {
const option = this.sortOptions.find((s) => s.orderBy === this.orderBy);
return option.label;
return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
},
isSortAscending() {
return this.sort === ASCENDING_ORDER;
isDirectionAscending() {
return this.direction === ASCENDING_ORDER;
},
},
methods: {
...mapActions('index', ['setSorting']),
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
this.setSorting({ sort });
this.$emit('sort:changed');
const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
this.emitInputEventIfChanged(this.orderBy, direction);
},
onSortItemClick(item) {
this.setSorting({ orderBy: item });
this.$emit('sort:changed');
this.emitInputEventIfChanged(item.orderBy, this.direction);
},
isActiveSortItem(item) {
return this.orderBy === item;
return this.orderBy === item.orderBy;
},
emitInputEventIfChanged(orderBy, direction) {
const newSort = SORT_MAP[orderBy][direction];
if (newSort !== this.value) {
this.$emit('input', SORT_MAP[orderBy][direction]);
}
},
},
};
......@@ -46,15 +75,15 @@ export default {
<template>
<gl-sorting
:text="sortText"
:is-ascending="isSortAscending"
:is-ascending="isDirectionAscending"
data-testid="releases-sort"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
v-for="item in sortOptions"
v-for="item of sortOptions"
:key="item.orderBy"
:active="isActiveSortItem(item.orderBy)"
@click="onSortItemClick(item.orderBy)"
:active="isActiveSortItem(item)"
@click="onSortItemClick(item)"
>
{{ item.label }}
</gl-sorting-item>
......
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import {
ASCENDING_ORDER,
DESCENDING_ORDER,
SORT_OPTIONS,
RELEASED_AT,
CREATED_AT,
RELEASED_AT_ASC,
RELEASED_AT_DESC,
CREATED_ASC,
ALL_SORTS,
SORT_MAP,
} from '../constants';
export default {
name: 'ReleasesSortApolloclient',
components: {
GlSorting,
GlSortingItem,
},
props: {
value: {
type: String,
required: true,
validator: (sort) => ALL_SORTS.includes(sort),
},
},
computed: {
orderBy() {
if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
return RELEASED_AT;
}
return CREATED_AT;
},
direction() {
if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
return ASCENDING_ORDER;
}
return DESCENDING_ORDER;
},
sortOptions() {
return SORT_OPTIONS;
},
sortText() {
return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
},
isDirectionAscending() {
return this.direction === ASCENDING_ORDER;
},
},
methods: {
onDirectionChange() {
const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
this.emitInputEventIfChanged(this.orderBy, direction);
},
onSortItemClick(item) {
this.emitInputEventIfChanged(item.orderBy, this.direction);
},
isActiveSortItem(item) {
return this.orderBy === item.orderBy;
},
emitInputEventIfChanged(orderBy, direction) {
const newSort = SORT_MAP[orderBy][direction];
if (newSort !== this.value) {
this.$emit('input', SORT_MAP[orderBy][direction]);
}
},
},
};
</script>
<template>
<gl-sorting
:text="sortText"
:is-ascending="isDirectionAscending"
data-testid="releases-sort"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
v-for="item of sortOptions"
:key="item.orderBy"
:active="isActiveSortItem(item)"
@click="onSortItemClick(item)"
>
{{ item.label }}
</gl-sorting-item>
</gl-sorting>
</template>
#import "../fragments/release.fragment.graphql"
# This query is identical to
# `app/graphql/queries/releases/all_releases.query.graphql`.
# These two queries should be kept in sync.
# When the `releases_index_apollo_client` feature flag is
# removed, this query should be removed entirely.
query allReleasesDeprecated(
query allReleases(
$fullPath: ID!
$first: Int
$last: Int
......@@ -20,7 +12,87 @@ query allReleasesDeprecated(
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
__typename
nodes {
...Release
__typename
name
tagName
tagPath
descriptionHtml
releasedAt
createdAt
upcomingRelease
assets {
__typename
count
sources {
__typename
nodes {
__typename
format
url
}
}
links {
__typename
nodes {
__typename
id
name
url
directAssetUrl
linkType
external
}
}
}
evidences {
__typename
nodes {
__typename
id
filepath
collectedAt
sha
}
}
links {
__typename
editUrl
selfUrl
openedIssuesUrl
closedIssuesUrl
openedMergeRequestsUrl
mergedMergeRequestsUrl
closedMergeRequestsUrl
}
commit {
__typename
id
sha
webUrl
title
}
author {
__typename
id
webUrl
avatarUrl
username
}
milestones {
__typename
nodes {
__typename
id
title
description
webPath
stats {
__typename
totalIssuesCount
closedIssuesCount
}
}
}
}
pageInfo {
__typename
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import createDefaultClient from '~/lib/graphql';
import ReleaseIndexApp from './components/app_index.vue';
import ReleaseIndexApollopClientApp from './components/app_index_apollo_client.vue';
import createStore from './stores';
import createIndexModule from './stores/modules/index';
export default () => {
const el = document.getElementById('js-releases-page');
if (window.gon?.features?.releasesIndexApolloClient) {
Vue.use(VueApollo);
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
// This page attempts to decrease the perceived loading time
// by sending two requests: one request for the first item only (which
// completes relatively quickly), and one for all the items (which is slower).
// By default, Apollo Client batches these requests together, which defeats
// the purpose of making separate requests. So we explicitly
// disable batching on this page.
batchMax: 1,
},
),
});
return new Vue({
el,
apolloProvider,
provide: { ...el.dataset },
render: (h) => h(ReleaseIndexApollopClientApp),
});
}
Vue.use(Vuex);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
// This page attempts to decrease the perceived loading time
// by sending two requests: one request for the first item only (which
// completes relatively quickly), and one for all the items (which is slower).
// By default, Apollo Client batches these requests together, which defeats
// the purpose of making separate requests. So we explicitly
// disable batching on this page.
batchMax: 1,
},
),
});
return new Vue({
el,
store: createStore({
modules: {
index: createIndexModule(el.dataset),
},
}),
apolloProvider,
provide: { ...el.dataset },
render: (h) => h(ReleaseIndexApp),
});
};
import createFlash from '~/flash';
import { __ } from '~/locale';
import { PAGE_SIZE } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
import { gqClient, convertAllReleasesGraphQLResponse } from '~/releases/util';
import * as types from './mutation_types';
/**
* Gets a paginated list of releases from the GraphQL endpoint
*
* @param {Object} vuexParams
* @param {Object} actionParams
* @param {String} [actionParams.before] A GraphQL cursor. If provided,
* the items returned will proceed the provided cursor.
* @param {String} [actionParams.after] A GraphQL cursor. If provided,
* the items returned will follow the provided cursor.
*/
export const fetchReleases = ({ dispatch, commit, state }, { before, after }) => {
commit(types.REQUEST_RELEASES);
const { sort, orderBy } = state.sorting;
const orderByParam = orderBy === 'created_at' ? 'created' : orderBy;
const sortParams = `${orderByParam}_${sort}`.toUpperCase();
let paginationParams;
if (!before && !after) {
paginationParams = { first: PAGE_SIZE };
} else if (before && !after) {
paginationParams = { last: PAGE_SIZE, before };
} else if (!before && after) {
paginationParams = { first: PAGE_SIZE, after };
} else {
throw new Error(
'Both a `before` and an `after` parameter were provided to fetchReleases. These parameters cannot be used together.',
);
}
gqClient
.query({
query: allReleasesQuery,
variables: {
fullPath: state.projectPath,
sort: sortParams,
...paginationParams,
},
})
.then((response) => {
const { data, paginationInfo: pageInfo } = convertAllReleasesGraphQLResponse(response);
commit(types.RECEIVE_RELEASES_SUCCESS, {
data,
pageInfo,
});
})
.catch(() => dispatch('receiveReleasesError'));
};
export const receiveReleasesError = ({ commit }) => {
commit(types.RECEIVE_RELEASES_ERROR);
createFlash({
message: __('An error occurred while fetching the releases. Please try again.'),
});
};
export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data);
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
export default (initialState) => ({
namespaced: true,
actions,
mutations,
state: createState(initialState),
});
export const REQUEST_RELEASES = 'REQUEST_RELEASES';
export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';
export const SET_SORTING = 'SET_SORTING';
import * as types from './mutation_types';
export default {
/**
* Sets isLoading to true while the request is being made.
* @param {Object} state
*/
[types.REQUEST_RELEASES](state) {
state.isLoading = true;
},
/**
* Sets isLoading to false.
* Sets hasError to false.
* Sets the received data
* Sets the received pagination information
* @param {Object} state
* @param {Object} resp
*/
[types.RECEIVE_RELEASES_SUCCESS](state, { data, pageInfo }) {
state.hasError = false;
state.isLoading = false;
state.releases = data;
state.pageInfo = pageInfo;
},
/**
* Sets isLoading to false.
* Sets hasError to true.
* Resets the data
* @param {Object} state
* @param {Object} data
*/
[types.RECEIVE_RELEASES_ERROR](state) {
state.isLoading = false;
state.releases = [];
state.hasError = true;
state.pageInfo = {};
},
[types.SET_SORTING](state, sorting) {
state.sorting = { ...state.sorting, ...sorting };
},
};
import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants';
export default ({
projectId,
projectPath,
documentationPath,
illustrationPath,
newReleasePath = '',
}) => ({
projectId,
projectPath,
documentationPath,
illustrationPath,
newReleasePath,
isLoading: false,
hasError: false,
releases: [],
pageInfo: {},
sorting: {
sort: DESCENDING_ORDER,
orderBy: RELEASED_AT,
},
});
......@@ -8,9 +8,6 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
before_action :validate_suffix_path, :fetch_latest_tag, only: :latest_permalink
before_action only: :index do
push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml)
end
feature_category :release_orchestration
......
# This query is identical to
# `app/assets/javascripts/releases/graphql/queries/all_releases.query.graphql`.
# These two queries should be kept in sync.
query allReleases(
$fullPath: ID!
$first: Int
$last: Int
$before: String
$after: String
$sort: ReleaseSort
) {
project(fullPath: $fullPath) {
__typename
id
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
__typename
nodes {
__typename
name
tagName
tagPath
descriptionHtml
releasedAt
createdAt
upcomingRelease
assets {
__typename
count
sources {
__typename
nodes {
__typename
format
url
}
}
links {
__typename
nodes {
__typename
id
name
url
directAssetUrl
linkType
external
}
}
}
evidences {
__typename
nodes {
__typename
id
filepath
collectedAt
sha
}
}
links {
__typename
editUrl
selfUrl
openedIssuesUrl
closedIssuesUrl
openedMergeRequestsUrl
mergedMergeRequestsUrl
closedMergeRequestsUrl
}
commit {
__typename
id
sha
webUrl
title
}
author {
__typename
id
webUrl
avatarUrl
username
}
milestones {
__typename
nodes {
__typename
id
title
description
webPath
stats {
__typename
totalIssuesCount
closedIssuesCount
}
}
}
}
pageInfo {
__typename
startCursor
hasPreviousPage
hasNextPage
endCursor
}
}
}
}
---
name: releases_index_apollo_client
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61828
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331006
milestone: '14.0'
type: development
group: group::release
default_enabled: true
......@@ -24,129 +24,111 @@ RSpec.describe 'User views releases', :js do
stub_default_url_options(host: 'localhost')
end
shared_examples 'releases index page' do
context('when the user is a maintainer') do
before do
sign_in(maintainer)
context('when the user is a maintainer') do
before do
sign_in(maintainer)
visit project_releases_path(project)
visit project_releases_path(project)
wait_for_requests
end
wait_for_requests
end
it 'sees the release' do
page.within("##{release_v1.tag}") do
expect(page).to have_content(release_v1.name)
expect(page).to have_content(release_v1.tag)
expect(page).not_to have_content('Upcoming Release')
end
it 'sees the release' do
page.within("##{release_v1.tag}") do
expect(page).to have_content(release_v1.name)
expect(page).to have_content(release_v1.tag)
expect(page).not_to have_content('Upcoming Release')
end
end
it 'renders the correct links', :aggregate_failures do
page.within("##{release_v1.tag} .js-assets-list") do
external_link_indicator_selector = '[data-testid="external-link-indicator"]'
it 'renders the correct links', :aggregate_failures do
page.within("##{release_v1.tag} .js-assets-list") do
external_link_indicator_selector = '[data-testid="external-link-indicator"]'
expect(page).to have_link internal_link.name, href: internal_link.url
expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector)
expect(page).to have_link internal_link.name, href: internal_link.url
expect(find_link(internal_link.name)).not_to have_css(external_link_indicator_selector)
expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}"
expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector)
expect(page).to have_link internal_link_with_redirect.name, href: Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << "/downloads#{internal_link_with_redirect.filepath}"
expect(find_link(internal_link_with_redirect.name)).not_to have_css(external_link_indicator_selector)
expect(page).to have_link external_link.name, href: external_link.url
expect(find_link(external_link.name)).to have_css(external_link_indicator_selector)
end
expect(page).to have_link external_link.name, href: external_link.url
expect(find_link(external_link.name)).to have_css(external_link_indicator_selector)
end
end
context 'with an upcoming release' do
it 'sees the upcoming tag' do
page.within("##{release_v3.tag}") do
expect(page).to have_content('Upcoming Release')
end
context 'with an upcoming release' do
it 'sees the upcoming tag' do
page.within("##{release_v3.tag}") do
expect(page).to have_content('Upcoming Release')
end
end
end
context 'with a tag containing a slash' do
it 'sees the release' do
page.within("##{release_v2.tag.parameterize}") do
expect(page).to have_content(release_v2.name)
expect(page).to have_content(release_v2.tag)
end
context 'with a tag containing a slash' do
it 'sees the release' do
page.within("##{release_v2.tag.parameterize}") do
expect(page).to have_content(release_v2.name)
expect(page).to have_content(release_v2.tag)
end
end
end
context 'sorting' do
def sort_page(by:, direction:)
within '[data-testid="releases-sort"]' do
find('.dropdown-toggle').click
click_button(by, class: 'dropdown-item')
find('.sorting-direction-button').click if direction == :ascending
end
end
shared_examples 'releases sort order' do
it "sorts the releases #{description}" do
card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
card_titles.each_with_index do |title, index|
expect(title).to have_content(expected_releases[index].name)
end
end
end
context 'sorting' do
def sort_page(by:, direction:)
within '[data-testid="releases-sort"]' do
find('.dropdown-toggle').click
context "when the page is sorted by the default sort order" do
let(:expected_releases) { [release_v3, release_v2, release_v1] }
click_button(by, class: 'dropdown-item')
it_behaves_like 'releases sort order'
find('.sorting-direction-button').click if direction == :ascending
end
end
context "when the page is sorted by created_at ascending " do
let(:expected_releases) { [release_v2, release_v1, release_v3] }
shared_examples 'releases sort order' do
it "sorts the releases #{description}" do
card_titles = page.all('.release-block .card-title', minimum: expected_releases.count)
before do
sort_page by: 'Created date', direction: :ascending
card_titles.each_with_index do |title, index|
expect(title).to have_content(expected_releases[index].name)
end
it_behaves_like 'releases sort order'
end
end
end
context('when the user is a guest') do
before do
sign_in(guest)
end
context "when the page is sorted by the default sort order" do
let(:expected_releases) { [release_v3, release_v2, release_v1] }
it 'renders release info except for Git-related data' do
visit project_releases_path(project)
it_behaves_like 'releases sort order'
end
within('.release-block', match: :first) do
expect(page).to have_content(release_v3.description)
expect(page).to have_content(release_v3.tag)
expect(page).to have_content(release_v3.name)
context "when the page is sorted by created_at ascending " do
let(:expected_releases) { [release_v2, release_v1, release_v3] }
# The following properties (sometimes) include Git info,
# so they are not rendered for Guest users
expect(page).not_to have_content(release_v3.commit.short_id)
before do
sort_page by: 'Created date', direction: :ascending
end
it_behaves_like 'releases sort order'
end
end
end
context 'when the releases_index_apollo_client feature flag is enabled' do
context('when the user is a guest') do
before do
stub_feature_flags(releases_index_apollo_client: true)
sign_in(guest)
end
it_behaves_like 'releases index page'
end
it 'renders release info except for Git-related data' do
visit project_releases_path(project)
context 'when the releases_index_apollo_client feature flag is disabled' do
before do
stub_feature_flags(releases_index_apollo_client: false)
end
within('.release-block', match: :first) do
expect(page).to have_content(release_v3.description)
expect(page).to have_content(release_v3.tag)
expect(page).to have_content(release_v3.name)
it_behaves_like 'releases index page'
# The following properties (sometimes) include Git info,
# so they are not rendered for Guest users
expect(page).not_to have_content(release_v3.commit.short_id)
end
end
end
end
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.vue';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
historyPushState: jest.fn(),
}));
describe('releases_pagination_apollo_client.vue', () => {
const startCursor = 'startCursor';
const endCursor = 'endCursor';
let wrapper;
let onPrev;
let onNext;
const createComponent = (pageInfo) => {
onPrev = jest.fn();
onNext = jest.fn();
wrapper = mountExtended(ReleasesPaginationApolloClient, {
propsData: {
pageInfo,
},
listeners: {
prev: onPrev,
next: onNext,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const singlePageInfo = {
hasPreviousPage: false,
hasNextPage: false,
startCursor,
endCursor,
};
const onlyNextPageInfo = {
hasPreviousPage: false,
hasNextPage: true,
startCursor,
endCursor,
};
const onlyPrevPageInfo = {
hasPreviousPage: true,
hasNextPage: false,
startCursor,
endCursor,
};
const prevAndNextPageInfo = {
hasPreviousPage: true,
hasNextPage: true,
startCursor,
endCursor,
};
const findPrevButton = () => wrapper.findByTestId('prevButton');
const findNextButton = () => wrapper.findByTestId('nextButton');
describe.each`
description | pageInfo | prevEnabled | nextEnabled
${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
`('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
describe(description, () => {
beforeEach(() => {
createComponent(pageInfo);
});
it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
});
it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
});
describe('button behavior', () => {
beforeEach(() => {
createComponent(prevAndNextPageInfo);
});
describe('next button behavior', () => {
beforeEach(() => {
findNextButton().trigger('click');
});
it('emits an "next" event with the "after" cursor', () => {
expect(onNext.mock.calls).toEqual([[endCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?after=${endCursor}`)],
]);
});
});
describe('prev button behavior', () => {
beforeEach(() => {
findPrevButton().trigger('click');
});
it('emits an "prev" event with the "before" cursor', () => {
expect(onPrev.mock.calls).toEqual([[startCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?before=${startCursor}`)],
]);
});
});
});
});
import { GlKeysetPagination } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { historyPushState } from '~/lib/utils/common_utils';
import ReleasesPagination from '~/releases/components/releases_pagination.vue';
import createStore from '~/releases/stores';
import createIndexModule from '~/releases/stores/modules/index';
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
historyPushState: jest.fn(),
}));
Vue.use(Vuex);
describe('~/releases/components/releases_pagination.vue', () => {
describe('releases_pagination.vue', () => {
const startCursor = 'startCursor';
const endCursor = 'endCursor';
let wrapper;
let indexModule;
const cursors = {
startCursor: 'startCursor',
endCursor: 'endCursor',
};
const projectPath = 'my/project';
let onPrev;
let onNext;
const createComponent = (pageInfo) => {
indexModule = createIndexModule({ projectPath });
indexModule.state.pageInfo = pageInfo;
indexModule.actions.fetchReleases = jest.fn();
wrapper = mount(ReleasesPagination, {
store: createStore({
modules: {
index: indexModule,
},
featureFlags: {},
}),
onPrev = jest.fn();
onNext = jest.fn();
wrapper = mountExtended(ReleasesPagination, {
propsData: {
pageInfo,
},
listeners: {
prev: onPrev,
next: onNext,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findPrevButton = () => findGlKeysetPagination().find('[data-testid="prevButton"]');
const findNextButton = () => findGlKeysetPagination().find('[data-testid="nextButton"]');
const expectDisabledPrev = () => {
expect(findPrevButton().attributes().disabled).toBe('disabled');
const singlePageInfo = {
hasPreviousPage: false,
hasNextPage: false,
startCursor,
endCursor,
};
const expectEnabledPrev = () => {
expect(findPrevButton().attributes().disabled).toBe(undefined);
const onlyNextPageInfo = {
hasPreviousPage: false,
hasNextPage: true,
startCursor,
endCursor,
};
const expectDisabledNext = () => {
expect(findNextButton().attributes().disabled).toBe('disabled');
const onlyPrevPageInfo = {
hasPreviousPage: true,
hasNextPage: false,
startCursor,
endCursor,
};
const expectEnabledNext = () => {
expect(findNextButton().attributes().disabled).toBe(undefined);
const prevAndNextPageInfo = {
hasPreviousPage: true,
hasNextPage: true,
startCursor,
endCursor,
};
describe('when there is only one page of results', () => {
beforeEach(() => {
createComponent({
hasPreviousPage: false,
hasNextPage: false,
const findPrevButton = () => wrapper.findByTestId('prevButton');
const findNextButton = () => wrapper.findByTestId('nextButton');
describe.each`
description | pageInfo | prevEnabled | nextEnabled
${'when there is only one page of results'} | ${singlePageInfo} | ${false} | ${false}
${'when there is a next page, but not a previous page'} | ${onlyNextPageInfo} | ${false} | ${true}
${'when there is a previous page, but not a next page'} | ${onlyPrevPageInfo} | ${true} | ${false}
${'when there is both a previous and next page'} | ${prevAndNextPageInfo} | ${true} | ${true}
`('component states', ({ description, pageInfo, prevEnabled, nextEnabled }) => {
describe(description, () => {
beforeEach(() => {
createComponent(pageInfo);
});
});
it('does not render a GlKeysetPagination', () => {
expect(findGlKeysetPagination().exists()).toBe(false);
});
});
describe('when there is a next page, but not a previous page', () => {
beforeEach(() => {
createComponent({
hasPreviousPage: false,
hasNextPage: true,
it(`renders the "Prev" button as ${prevEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findPrevButton().attributes().disabled).toBe(prevEnabled ? undefined : 'disabled');
});
});
it('renders a disabled "Prev" button', () => {
expectDisabledPrev();
});
it('renders an enabled "Next" button', () => {
expectEnabledNext();
});
});
describe('when there is a previous page, but not a next page', () => {
beforeEach(() => {
createComponent({
hasPreviousPage: true,
hasNextPage: false,
});
});
it('renders a enabled "Prev" button', () => {
expectEnabledPrev();
});
it('renders an disabled "Next" button', () => {
expectDisabledNext();
});
});
describe('when there is both a previous page and a next page', () => {
beforeEach(() => {
createComponent({
hasPreviousPage: true,
hasNextPage: true,
it(`renders the "Next" button as ${nextEnabled ? 'enabled' : 'disabled'}`, () => {
expect(findNextButton().attributes().disabled).toBe(nextEnabled ? undefined : 'disabled');
});
});
it('renders a enabled "Prev" button', () => {
expectEnabledPrev();
});
it('renders an enabled "Next" button', () => {
expectEnabledNext();
});
});
describe('button behavior', () => {
beforeEach(() => {
createComponent({
hasPreviousPage: true,
hasNextPage: true,
...cursors,
});
createComponent(prevAndNextPageInfo);
});
describe('next button behavior', () => {
......@@ -142,33 +96,29 @@ describe('~/releases/components/releases_pagination.vue', () => {
findNextButton().trigger('click');
});
it('calls fetchReleases with the correct after cursor', () => {
expect(indexModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { after: cursors.endCursor }],
]);
it('emits an "next" event with the "after" cursor', () => {
expect(onNext.mock.calls).toEqual([[endCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?after=${cursors.endCursor}`)],
[expect.stringContaining(`?after=${endCursor}`)],
]);
});
});
describe('previous button behavior', () => {
describe('prev button behavior', () => {
beforeEach(() => {
findPrevButton().trigger('click');
});
it('calls fetchReleases with the correct before cursor', () => {
expect(indexModule.actions.fetchReleases.mock.calls).toEqual([
[expect.anything(), { before: cursors.startCursor }],
]);
it('emits an "prev" event with the "before" cursor', () => {
expect(onPrev.mock.calls).toEqual([[startCursor]]);
});
it('calls historyPushState with the new URL', () => {
expect(historyPushState.mock.calls).toEqual([
[expect.stringContaining(`?before=${cursors.startCursor}`)],
[expect.stringContaining(`?before=${startCursor}`)],
]);
});
});
......
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue';
import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
describe('releases_sort_apollo_client.vue', () => {
let wrapper;
const createComponent = (valueProp = RELEASED_AT_ASC) => {
wrapper = shallowMountExtended(ReleasesSortApolloClient, {
propsData: {
value: valueProp,
},
stubs: {
GlSortingItem,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findSorting = () => wrapper.findComponent(GlSorting);
const findSortingItems = () => wrapper.findAllComponents(GlSortingItem);
const findReleasedDateItem = () =>
findSortingItems().wrappers.find((item) => item.text() === 'Released date');
const findCreatedDateItem = () =>
findSortingItems().wrappers.find((item) => item.text() === 'Created date');
const getSortingItemsInfo = () =>
findSortingItems().wrappers.map((item) => ({
label: item.text(),
active: item.attributes().active === 'true',
}));
describe.each`
valueProp | text | isAscending | items
${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
`('component states', ({ valueProp, text, isAscending, items }) => {
beforeEach(() => {
createComponent(valueProp);
});
it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => {
expect(findSorting().props()).toEqual(
expect.objectContaining({
text,
isAscending,
}),
);
});
it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => {
expect(getSortingItemsInfo()).toEqual(items);
});
});
const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click');
const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click');
const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange');
const releasedAtDropdownItemDescription = 'released at dropdown item';
const createdAtDropdownItemDescription = 'created at dropdown item';
const sortDirectionButtonDescription = 'sort direction button';
describe.each`
initialValueProp | itemClickFn | itemToClickDescription | emittedEvent
${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC}
${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC}
${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC}
${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC}
${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC}
${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC}
${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC}
${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC}
`('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => {
beforeEach(() => {
createComponent(initialValueProp);
itemClickFn();
});
it(`emits ${
emittedEvent || 'nothing'
} when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => {
expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent);
});
});
describe('prop validation', () => {
it('validates that the `value` prop is one of the expected sort strings', () => {
expect(() => {
createComponent('not a valid value');
}).toThrow('Invalid prop: custom validator check failed');
});
});
});
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ReleasesSort from '~/releases/components/releases_sort.vue';
import createStore from '~/releases/stores';
import createIndexModule from '~/releases/stores/modules/index';
import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants';
Vue.use(Vuex);
describe('~/releases/components/releases_sort.vue', () => {
describe('releases_sort.vue', () => {
let wrapper;
let store;
let indexModule;
const projectId = 8;
const createComponent = () => {
indexModule = createIndexModule({ projectId });
store = createStore({
modules: {
index: indexModule,
const createComponent = (valueProp = RELEASED_AT_ASC) => {
wrapper = shallowMountExtended(ReleasesSort, {
propsData: {
value: valueProp,
},
});
store.dispatch = jest.fn();
wrapper = shallowMount(ReleasesSort, {
store,
stubs: {
GlSortingItem,
},
});
};
const findReleasesSorting = () => wrapper.find(GlSorting);
const findSortingItems = () => wrapper.findAll(GlSortingItem);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
beforeEach(() => {
createComponent();
});
const findSorting = () => wrapper.findComponent(GlSorting);
const findSortingItems = () => wrapper.findAllComponents(GlSortingItem);
const findReleasedDateItem = () =>
findSortingItems().wrappers.find((item) => item.text() === 'Released date');
const findCreatedDateItem = () =>
findSortingItems().wrappers.find((item) => item.text() === 'Created date');
const getSortingItemsInfo = () =>
findSortingItems().wrappers.map((item) => ({
label: item.text(),
active: item.attributes().active === 'true',
}));
describe.each`
valueProp | text | isAscending | items
${RELEASED_AT_ASC} | ${'Released date'} | ${true} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
${RELEASED_AT_DESC} | ${'Released date'} | ${false} | ${[{ label: 'Released date', active: true }, { label: 'Created date', active: false }]}
${CREATED_ASC} | ${'Created date'} | ${true} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
${CREATED_DESC} | ${'Created date'} | ${false} | ${[{ label: 'Released date', active: false }, { label: 'Created date', active: true }]}
`('component states', ({ valueProp, text, isAscending, items }) => {
beforeEach(() => {
createComponent(valueProp);
});
it('has all the sortable items', () => {
expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length);
it(`when the sort is ${valueProp}, provides the GlSorting with the props text="${text}" and isAscending=${isAscending}`, () => {
expect(findSorting().props()).toEqual(
expect.objectContaining({
text,
isAscending,
}),
);
});
it(`when the sort is ${valueProp}, renders the expected dropdown items`, () => {
expect(getSortingItemsInfo()).toEqual(items);
});
});
it('on sort change set sorting in vuex and emit event', () => {
findReleasesSorting().vm.$emit('sortDirectionChange');
expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { sort: 'asc' });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
const clickReleasedDateItem = () => findReleasedDateItem().vm.$emit('click');
const clickCreatedDateItem = () => findCreatedDateItem().vm.$emit('click');
const clickSortDirectionButton = () => findSorting().vm.$emit('sortDirectionChange');
const releasedAtDropdownItemDescription = 'released at dropdown item';
const createdAtDropdownItemDescription = 'created at dropdown item';
const sortDirectionButtonDescription = 'sort direction button';
describe.each`
initialValueProp | itemClickFn | itemToClickDescription | emittedEvent
${RELEASED_AT_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
${RELEASED_AT_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_ASC}
${RELEASED_AT_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_DESC}
${RELEASED_AT_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${undefined}
${RELEASED_AT_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${CREATED_DESC}
${RELEASED_AT_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${RELEASED_AT_ASC}
${CREATED_ASC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_ASC}
${CREATED_ASC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
${CREATED_ASC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_DESC}
${CREATED_DESC} | ${clickReleasedDateItem} | ${releasedAtDropdownItemDescription} | ${RELEASED_AT_DESC}
${CREATED_DESC} | ${clickCreatedDateItem} | ${createdAtDropdownItemDescription} | ${undefined}
${CREATED_DESC} | ${clickSortDirectionButton} | ${sortDirectionButtonDescription} | ${CREATED_ASC}
`('input event', ({ initialValueProp, itemClickFn, itemToClickDescription, emittedEvent }) => {
beforeEach(() => {
createComponent(initialValueProp);
itemClickFn();
});
it(`emits ${
emittedEvent || 'nothing'
} when value prop is ${initialValueProp} and the ${itemToClickDescription} is clicked`, () => {
expect(wrapper.emitted().input?.[0]?.[0]).toEqual(emittedEvent);
});
});
it('on sort item click set sorting and emit event', () => {
const item = findSortingItems().at(0);
const { orderBy } = wrapper.vm.sortOptions[0];
item.vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('index/setSorting', { orderBy });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
describe('prop validation', () => {
it('validates that the `value` prop is one of the expected sort strings', () => {
expect(() => {
createComponent('not a valid value');
}).toThrow('Invalid prop: custom validator check failed');
});
});
});
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