Commit f16fc45c authored by Miguel Rincon's avatar Miguel Rincon

Merge branch 'nfriend-add-sort-to-apollo-client-releases-page' into 'master'

Add sort controls to new Releases page

See merge request gitlab-org/gitlab!62848
parents c5628234 bd618ae2
......@@ -4,13 +4,14 @@ import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { __ } from '~/locale';
import { PAGE_SIZE } from '~/releases/constants';
import { PAGE_SIZE, RELEASED_AT_DESC } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
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',
......@@ -20,6 +21,7 @@ export default {
ReleaseSkeletonLoader,
ReleasesEmptyState,
ReleasesPaginationApolloClient,
ReleasesSortApolloClient,
},
inject: {
projectPath: {
......@@ -56,6 +58,7 @@ export default {
before: getParameterByName('before'),
after: getParameterByName('after'),
},
sort: RELEASED_AT_DESC,
};
},
computed: {
......@@ -76,6 +79,7 @@ export default {
return {
fullPath: this.projectPath,
...paginationParams,
sort: this.sort,
};
},
isLoading() {
......@@ -124,6 +128,9 @@ export default {
window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
},
methods: {
getReleaseKey(release, index) {
return [release.tagNamerstrs, release.name, index].join('|');
},
updateQueryParamsFromUrl() {
this.cursors.before = getParameterByName('before');
this.cursors.after = getParameterByName('after');
......@@ -148,6 +155,8 @@ export default {
<template>
<div class="flex flex-column mt-2">
<div class="gl-align-self-end gl-mb-3">
<releases-sort-apollo-client v-model="sort" class="gl-mr-2" />
<gl-button
v-if="newReleasePath"
:href="newReleasePath"
......@@ -165,7 +174,7 @@ export default {
<div v-else-if="shouldRenderSuccessState">
<release-block
v-for="(release, index) in releases"
:key="index"
:key="getReleaseKey(release, index)"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
......
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { ASCENDING_ODER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
export default {
name: 'ReleasesSort',
......@@ -22,13 +22,13 @@ export default {
return option.label;
},
isSortAscending() {
return this.sort === ASCENDING_ODER;
return this.sort === ASCENDING_ORDER;
},
},
methods: {
...mapActions('index', ['setSorting']),
onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
this.setSorting({ sort });
this.$emit('sort:changed');
},
......
<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"
@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>
......@@ -15,7 +15,7 @@ export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
export const PAGE_SIZE = 10;
export const ASCENDING_ODER = 'asc';
export const ASCENDING_ORDER = 'asc';
export const DESCENDING_ORDER = 'desc';
export const RELEASED_AT = 'released_at';
export const CREATED_AT = 'created_at';
......@@ -30,3 +30,20 @@ export const SORT_OPTIONS = [
label: __('Created date'),
},
];
export const RELEASED_AT_ASC = 'RELEASED_AT_ASC';
export const RELEASED_AT_DESC = 'RELEASED_AT_DESC';
export const CREATED_ASC = 'CREATED_ASC';
export const CREATED_DESC = 'CREATED_DESC';
export const ALL_SORTS = [RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC];
export const SORT_MAP = {
[RELEASED_AT]: {
[ASCENDING_ORDER]: RELEASED_AT_ASC,
[DESCENDING_ORDER]: RELEASED_AT_DESC,
},
[CREATED_AT]: {
[ASCENDING_ORDER]: CREATED_ASC,
[DESCENDING_ORDER]: CREATED_DESC,
},
};
......@@ -9,7 +9,8 @@ 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 { PAGE_SIZE } from '~/releases/constants';
import ReleasesSortApolloClient from '~/releases/components/releases_sort_apollo_client.vue';
import { PAGE_SIZE, RELEASED_AT_DESC, CREATED_ASC } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
Vue.use(VueApollo);
......@@ -68,6 +69,7 @@ describe('app_index_apollo_client.vue', () => {
wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease);
const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
const findSort = () => wrapper.findComponent(ReleasesSortApolloClient);
// Expectations
const expectLoadingIndicator = () => {
......@@ -135,6 +137,12 @@ describe('app_index_apollo_client.vue', () => {
});
};
const expectSort = () => {
it('renders the sort controls', () => {
expect(findSort().exists()).toBe(true);
});
};
// Tests
describe('when the component is loading data', () => {
beforeEach(() => {
......@@ -147,6 +155,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
describe('when the data has successfully loaded, but there are no releases', () => {
......@@ -161,6 +170,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
describe('when an error occurs while loading data', () => {
......@@ -174,6 +184,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton();
expectReleases(0);
expectNoPagination();
expectSort();
});
describe('when the data has successfully loaded with a single page of results', () => {
......@@ -201,6 +212,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectPagination();
expectSort();
});
describe('URL parameters', () => {
......@@ -213,6 +225,7 @@ describe('app_index_apollo_client.vue', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
first: PAGE_SIZE,
fullPath: projectPath,
sort: RELEASED_AT_DESC,
});
});
});
......@@ -228,6 +241,7 @@ describe('app_index_apollo_client.vue', () => {
before,
last: PAGE_SIZE,
fullPath: projectPath,
sort: RELEASED_AT_DESC,
});
});
});
......@@ -243,6 +257,7 @@ describe('app_index_apollo_client.vue', () => {
after,
first: PAGE_SIZE,
fullPath: projectPath,
sort: RELEASED_AT_DESC,
});
});
});
......@@ -258,6 +273,7 @@ describe('app_index_apollo_client.vue', () => {
after,
first: PAGE_SIZE,
fullPath: projectPath,
sort: RELEASED_AT_DESC,
});
});
});
......@@ -298,4 +314,27 @@ describe('app_index_apollo_client.vue', () => {
]);
});
});
describe('sorting', () => {
beforeEach(() => {
createComponent();
});
it(`sorts by ${RELEASED_AT_DESC} by default`, () => {
expect(allReleasesQueryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: RELEASED_AT_DESC })],
]);
});
it('requeries the GraphQL endpoint when the sort is changed', async () => {
findSort().vm.$emit('input', CREATED_ASC);
await wrapper.vm.$nextTick();
expect(allReleasesQueryMock.mock.calls).toEqual([
[expect.objectContaining({ sort: RELEASED_AT_DESC })],
[expect.objectContaining({ sort: CREATED_ASC })],
]);
});
});
});
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');
});
});
});
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