Commit d545a0cd authored by Nathan Friend's avatar Nathan Friend

Merge branch '221101-implement-gitlab-ui-component-for-sorting-releases' into 'master'

Resolve "Implement gitlab-ui component for sorting Releases"

See merge request gitlab-org/gitlab!43963
parents 58735e25 15fd4688
......@@ -6,6 +6,7 @@ import { __ } from '~/locale';
import ReleaseBlock from './release_block.vue';
import ReleasesPagination from './releases_pagination.vue';
import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
import ReleasesSort from './releases_sort.vue';
export default {
name: 'ReleasesApp',
......@@ -16,6 +17,7 @@ export default {
ReleaseBlock,
ReleasesPagination,
ReleaseSkeletonLoader,
ReleasesSort,
},
computed: {
...mapState('list', [
......@@ -62,16 +64,20 @@ export default {
</script>
<template>
<div class="flex flex-column mt-2">
<gl-button
v-if="newReleasePath"
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
variant="success"
class="align-self-end mb-2 js-new-release-btn"
>
{{ __('New release') }}
</gl-button>
<div class="gl-align-self-end gl-mb-3">
<releases-sort class="gl-mr-2" @sort:changed="fetchReleases" />
<gl-button
v-if="newReleasePath"
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
variant="success"
class="js-new-release-btn"
>
{{ __('New release') }}
</gl-button>
</div>
<release-skeleton-loader v-if="isLoading" class="js-loading" />
......
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { ASCENDING_ODER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
export default {
name: 'ReleasesSort',
components: {
GlSorting,
GlSortingItem,
},
computed: {
...mapState('list', {
orderBy: state => state.sorting.orderBy,
sort: state => state.sorting.sort,
}),
sortOptions() {
return SORT_OPTIONS;
},
sortText() {
const option = this.sortOptions.find(s => s.orderBy === this.orderBy);
return option.label;
},
isSortAscending() {
return this.sort === ASCENDING_ODER;
},
},
methods: {
...mapActions('list', ['setSorting']),
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
this.setSorting({ sort });
this.$emit('sort:changed');
},
onSortItemClick(item) {
this.setSorting({ orderBy: item });
this.$emit('sort:changed');
},
isActiveSortItem(item) {
return this.orderBy === item;
},
},
};
</script>
<template>
<gl-sorting
:text="sortText"
:is-ascending="isSortAscending"
data-testid="releases-sort"
@sortDirectionChange="onDirectionChange"
>
<gl-sorting-item
v-for="item in sortOptions"
:key="item.orderBy"
:active="isActiveSortItem(item.orderBy)"
@click="onSortItemClick(item.orderBy)"
>
{{ item.label }}
</gl-sorting-item>
</gl-sorting>
</template>
import { __ } from '~/locale';
export const MAX_MILESTONES_TO_DISPLAY = 5;
export const BACK_URL_PARAM = 'back_url';
......@@ -12,3 +14,19 @@ export const ASSET_LINK_TYPE = Object.freeze({
export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
export const PAGE_SIZE = 20;
export const ASCENDING_ODER = 'asc';
export const DESCENDING_ORDER = 'desc';
export const RELEASED_AT = 'released_at';
export const CREATED_AT = 'created_at';
export const SORT_OPTIONS = [
{
orderBy: RELEASED_AT,
label: __('Released date'),
},
{
orderBy: CREATED_AT,
label: __('Created date'),
},
];
#import "./release.fragment.graphql"
query allReleases($fullPath: ID!, $first: Int, $last: Int, $before: String, $after: String) {
query allReleases(
$fullPath: ID!
$first: Int
$last: Int
$before: String
$after: String
$sort: ReleaseSort
) {
project(fullPath: $fullPath) {
releases(first: $first, last: $last, before: $before, after: $after) {
releases(first: $first, last: $last, before: $before, after: $after, sort: $sort) {
nodes {
...Release
}
......
......@@ -4,6 +4,7 @@ fragment Release on Release {
tagPath
descriptionHtml
releasedAt
createdAt
upcomingRelease
assets {
count
......
......@@ -42,6 +42,10 @@ export const fetchReleasesGraphQl = (
) => {
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 };
......@@ -60,6 +64,7 @@ export const fetchReleasesGraphQl = (
query: allReleasesQuery,
variables: {
fullPath: state.projectPath,
sort: sortParams,
...paginationParams,
},
})
......@@ -80,8 +85,10 @@ export const fetchReleasesGraphQl = (
export const fetchReleasesRest = ({ dispatch, commit, state }, { page }) => {
commit(types.REQUEST_RELEASES);
const { sort, orderBy } = state.sorting;
api
.releases(state.projectId, { page })
.releases(state.projectId, { page, sort, order_by: orderBy })
.then(({ data, headers }) => {
const restPageInfo = parseIntPagination(normalizeHeaders(headers));
const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
......@@ -98,3 +105,5 @@ export const receiveReleasesError = ({ commit }) => {
commit(types.RECEIVE_RELEASES_ERROR);
createFlash(__('An error occurred while fetching the releases. Please try again.'));
};
export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data);
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';
......@@ -39,4 +39,8 @@ export default {
state.restPageInfo = {};
state.graphQlPageInfo = {};
},
[types.SET_SORTING](state, sorting) {
state.sorting = { ...state.sorting, ...sorting };
},
};
import { DESCENDING_ORDER, RELEASED_AT } from '../../../constants';
export default ({
projectId,
projectPath,
......@@ -16,4 +18,8 @@ export default ({
releases: [],
restPageInfo: {},
graphQlPageInfo: {},
sorting: {
sort: DESCENDING_ORDER,
orderBy: RELEASED_AT,
},
});
---
title: Add ability to sort releases on Releases page
merge_request: 43963
author:
type: added
......@@ -43,6 +43,13 @@ To view a list of releases:
- On private projects, this number is visible to users with Reporter
[permissions](../../permissions.md#project-members-permissions) or higher.
### Sort Releases
On the top right of the **Releases** page, you can use the sorting button to order releases by
**Released date** or **Created date**. You can sort releases in ascending or descending order.
![Sort Releases dropdown button](img/releases_sort_v13_6.png)
## Create a release
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/32812) in GitLab 12.9. Releases can be created directly in the GitLab UI.
......
......@@ -22328,6 +22328,9 @@ msgstr ""
msgid "ReleaseAssetLinkType|Runbooks"
msgstr ""
msgid "Released date"
msgstr ""
msgid "Releases"
msgstr ""
......
......@@ -3,8 +3,14 @@
require 'spec_helper'
RSpec.describe 'User views releases', :js do
let_it_be(:today) { Time.zone.now }
let_it_be(:yesterday) { today - 1.day }
let_it_be(:tomorrow) { today + 1.day }
let_it_be(:project) { create(:project, :repository, :private) }
let_it_be(:release) { create(:release, project: project, name: 'The first release' ) }
let_it_be(:release_v1) { create(:release, project: project, tag: 'v1', name: 'The first release', released_at: yesterday, created_at: today) }
let_it_be(:release_v2) { create(:release, project: project, tag: 'v2-with-a/slash', name: 'The second release', released_at: today, created_at: yesterday) }
let_it_be(:release_v3) { create(:release, project: project, tag: 'v3', name: 'The third release', released_at: tomorrow, created_at: tomorrow) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:guest) { create(:user) }
......@@ -17,38 +23,36 @@ RSpec.describe 'User views releases', :js do
context('when the user is a maintainer') do
before do
sign_in(maintainer)
end
it 'sees the release' do
visit project_releases_path(project)
end
expect(page).to have_content(release.name)
expect(page).to have_content(release.tag)
expect(page).not_to have_content('Upcoming Release')
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
context 'when there is a link as an asset' do
let!(:release_link) { create(:release_link, release: release, url: url ) }
let!(:release_link) { create(:release_link, release: release_v1, url: url ) }
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release) << release_link.filepath }
let(:direct_asset_link) { Gitlab::Routing.url_helpers.project_release_url(project, release_v1) << release_link.filepath }
it 'sees the link' do
visit project_releases_path(project)
page.within('.js-assets-list') do
page.within("##{release_v1.tag} .js-assets-list") do
expect(page).to have_link release_link.name, href: direct_asset_link
expect(page).not_to have_css('[data-testid="external-link-indicator"]')
end
end
context 'when there is a link redirect' do
let!(:release_link) { create(:release_link, release: release, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
let!(:release_link) { create(:release_link, release: release_v1, name: 'linux-amd64 binaries', filepath: '/binaries/linux-amd64', url: url) }
let(:url) { "#{project.web_url}/-/jobs/1/artifacts/download" }
it 'sees the link' do
visit project_releases_path(project)
page.within('.js-assets-list') do
page.within("##{release_v1.tag} .js-assets-list") do
expect(page).to have_link release_link.name, href: direct_asset_link
expect(page).not_to have_css('[data-testid="external-link-indicator"]')
end
......@@ -59,9 +63,7 @@ RSpec.describe 'User views releases', :js do
let(:url) { 'http://google.com/download' }
it 'sees that the link is external resource' do
visit project_releases_path(project)
page.within('.js-assets-list') do
page.within("##{release_v1.tag} .js-assets-list") do
expect(page).to have_css('[data-testid="external-link-indicator"]')
end
end
......@@ -69,23 +71,57 @@ RSpec.describe 'User views releases', :js do
end
context 'with an upcoming release' do
let(:tomorrow) { Time.zone.now + 1.day }
let!(:release) { create(:release, project: project, released_at: tomorrow ) }
it 'sees the upcoming tag' do
visit project_releases_path(project)
expect(page).to have_content('Upcoming Release')
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
release = create :release, project: project, tag: 'debian/2.4.0-1'
visit project_releases_path(project)
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 "when the page is sorted by the default sort order" do
let(:expected_releases) { [release_v3, release_v2, release_v1] }
it_behaves_like 'releases sort order'
end
context "when the page is sorted by created_at ascending " do
let(:expected_releases) { [release_v2, release_v1, release_v3] }
before do
sort_page by: 'Created date', direction: :ascending
end
expect(page).to have_content(release.name)
expect(page).to have_content(release.tag)
it_behaves_like 'releases sort order'
end
end
end
......@@ -98,14 +134,14 @@ RSpec.describe 'User views releases', :js do
it 'renders release info except for Git-related data' do
visit project_releases_path(project)
within('.release-block') do
expect(page).to have_content(release.description)
within('.release-block', match: :first) do
expect(page).to have_content(release_v3.description)
# The following properties (sometimes) include Git info,
# so they are not rendered for Guest users
expect(page).not_to have_content(release.name)
expect(page).not_to have_content(release.tag)
expect(page).not_to have_content(release.commit.short_id)
expect(page).not_to have_content(release_v3.name)
expect(page).not_to have_content(release_v3.tag)
expect(page).not_to have_content(release_v3.commit.short_id)
end
end
end
......
import Vuex from 'vuex';
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import ReleasesSort from '~/releases/components/releases_sort.vue';
import createStore from '~/releases/stores';
import createListModule from '~/releases/stores/modules/list';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('~/releases/components/releases_sort.vue', () => {
let wrapper;
let store;
let listModule;
const projectId = 8;
const createComponent = () => {
listModule = createListModule({ projectId });
store = createStore({
modules: {
list: listModule,
},
});
store.dispatch = jest.fn();
wrapper = shallowMount(ReleasesSort, {
store,
stubs: {
GlSortingItem,
},
localVue,
});
};
const findReleasesSorting = () => wrapper.find(GlSorting);
const findSortingItems = () => wrapper.findAll(GlSortingItem);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
beforeEach(() => {
createComponent();
});
it('has all the sortable items', () => {
expect(findSortingItems()).toHaveLength(wrapper.vm.sortOptions.length);
});
it('on sort change set sorting in vuex and emit event', () => {
findReleasesSorting().vm.$emit('sortDirectionChange');
expect(store.dispatch).toHaveBeenCalledWith('list/setSorting', { sort: 'asc' });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
});
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('list/setSorting', { orderBy });
expect(wrapper.emitted('sort:changed')).toBeTruthy();
});
});
......@@ -6,6 +6,7 @@ import {
fetchReleasesGraphQl,
fetchReleasesRest,
receiveReleasesError,
setSorting,
} from '~/releases/stores/modules/list/actions';
import createState from '~/releases/stores/modules/list/state';
import * as types from '~/releases/stores/modules/list/mutation_types';
......@@ -114,7 +115,7 @@ describe('Releases State actions', () => {
it('makes a GraphQl query with a first variable', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
variables: { fullPath: projectPath, first: PAGE_SIZE },
variables: { fullPath: projectPath, first: PAGE_SIZE, sort: 'RELEASED_AT_DESC' },
});
});
});
......@@ -127,7 +128,7 @@ describe('Releases State actions', () => {
it('makes a GraphQl query with last and before variables', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
variables: { fullPath: projectPath, last: PAGE_SIZE, before },
variables: { fullPath: projectPath, last: PAGE_SIZE, before, sort: 'RELEASED_AT_DESC' },
});
});
});
......@@ -140,7 +141,7 @@ describe('Releases State actions', () => {
it('makes a GraphQl query with first and after variables', () => {
expect(gqClient.query).toHaveBeenCalledWith({
query: allReleasesQuery,
variables: { fullPath: projectPath, first: PAGE_SIZE, after },
variables: { fullPath: projectPath, first: PAGE_SIZE, after, sort: 'RELEASED_AT_DESC' },
});
});
});
......@@ -156,6 +157,29 @@ describe('Releases State actions', () => {
);
});
});
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;
fetchReleasesGraphQl(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', () => {
......@@ -230,7 +254,11 @@ describe('Releases State actions', () => {
});
it('makes a REST query with a page query parameter', () => {
expect(api.releases).toHaveBeenCalledWith(projectId, { page });
expect(api.releases).toHaveBeenCalledWith(projectId, {
page,
order_by: 'released_at',
sort: 'desc',
});
});
});
});
......@@ -302,4 +330,16 @@ describe('Releases State actions', () => {
);
});
});
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' } }],
[],
);
});
});
});
......@@ -80,4 +80,16 @@ describe('Releases Store Mutations', () => {
expect(stateCopy.graphQlPageInfo).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