Commit 6f44f06a authored by Nathan Friend's avatar Nathan Friend Committed by Miguel Rincon

Add sort controls to new Releases page

This commit updates the Apollo Client version of the Releases index page
to include the same sort controls that are on the current page.
parent 546267fd
...@@ -4,13 +4,14 @@ import createFlash from '~/flash'; ...@@ -4,13 +4,14 @@ import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/common_utils';
import { scrollUp } from '~/lib/utils/scroll_utils'; import { scrollUp } from '~/lib/utils/scroll_utils';
import { __ } from '~/locale'; 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 allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
import { convertAllReleasesGraphQLResponse } from '~/releases/util'; import { convertAllReleasesGraphQLResponse } from '~/releases/util';
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 ReleasesEmptyState from './releases_empty_state.vue';
import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue'; import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue';
import ReleasesSortApolloClient from './releases_sort_apollo_client.vue';
export default { export default {
name: 'ReleasesIndexApolloClientApp', name: 'ReleasesIndexApolloClientApp',
...@@ -20,6 +21,7 @@ export default { ...@@ -20,6 +21,7 @@ export default {
ReleaseSkeletonLoader, ReleaseSkeletonLoader,
ReleasesEmptyState, ReleasesEmptyState,
ReleasesPaginationApolloClient, ReleasesPaginationApolloClient,
ReleasesSortApolloClient,
}, },
inject: { inject: {
projectPath: { projectPath: {
...@@ -56,6 +58,7 @@ export default { ...@@ -56,6 +58,7 @@ export default {
before: getParameterByName('before'), before: getParameterByName('before'),
after: getParameterByName('after'), after: getParameterByName('after'),
}, },
sort: RELEASED_AT_DESC,
}; };
}, },
computed: { computed: {
...@@ -76,6 +79,7 @@ export default { ...@@ -76,6 +79,7 @@ export default {
return { return {
fullPath: this.projectPath, fullPath: this.projectPath,
...paginationParams, ...paginationParams,
sort: this.sort,
}; };
}, },
isLoading() { isLoading() {
...@@ -124,6 +128,9 @@ export default { ...@@ -124,6 +128,9 @@ export default {
window.removeEventListener('popstate', this.updateQueryParamsFromUrl); window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
}, },
methods: { methods: {
getReleaseKey(release, index) {
return [release.tagNamerstrs, release.name, index].join('|');
},
updateQueryParamsFromUrl() { updateQueryParamsFromUrl() {
this.cursors.before = getParameterByName('before'); this.cursors.before = getParameterByName('before');
this.cursors.after = getParameterByName('after'); this.cursors.after = getParameterByName('after');
...@@ -148,6 +155,8 @@ export default { ...@@ -148,6 +155,8 @@ export default {
<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-apollo-client v-model="sort" class="gl-mr-2" />
<gl-button <gl-button
v-if="newReleasePath" v-if="newReleasePath"
:href="newReleasePath" :href="newReleasePath"
...@@ -165,7 +174,7 @@ export default { ...@@ -165,7 +174,7 @@ export default {
<div v-else-if="shouldRenderSuccessState"> <div v-else-if="shouldRenderSuccessState">
<release-block <release-block
v-for="(release, index) in releases" v-for="(release, index) in releases"
:key="index" :key="getReleaseKey(release, index)"
:release="release" :release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/> />
......
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import {
ASCENDING_ODER,
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_ODER;
}
return DESCENDING_ORDER;
},
sortOptions() {
return SORT_OPTIONS;
},
sortText() {
return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
},
isDirectionAscending() {
return this.direction === ASCENDING_ODER;
},
},
methods: {
onDirectionChange() {
const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ODER;
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>
...@@ -30,3 +30,20 @@ export const SORT_OPTIONS = [ ...@@ -30,3 +30,20 @@ export const SORT_OPTIONS = [
label: __('Created date'), 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_ODER]: RELEASED_AT_ASC,
[DESCENDING_ORDER]: RELEASED_AT_DESC,
},
[CREATED_AT]: {
[ASCENDING_ODER]: CREATED_ASC,
[DESCENDING_ORDER]: CREATED_DESC,
},
};
...@@ -9,7 +9,8 @@ import ReleaseBlock from '~/releases/components/release_block.vue'; ...@@ -9,7 +9,8 @@ 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 ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import ReleasesPaginationApolloClient from '~/releases/components/releases_pagination_apollo_client.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'; import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -68,6 +69,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -68,6 +69,7 @@ describe('app_index_apollo_client.vue', () => {
wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease); wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease);
const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock); const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient); const findPagination = () => wrapper.findComponent(ReleasesPaginationApolloClient);
const findSort = () => wrapper.findComponent(ReleasesSortApolloClient);
// Expectations // Expectations
const expectLoadingIndicator = () => { const expectLoadingIndicator = () => {
...@@ -135,6 +137,12 @@ describe('app_index_apollo_client.vue', () => { ...@@ -135,6 +137,12 @@ describe('app_index_apollo_client.vue', () => {
}); });
}; };
const expectSort = () => {
it('renders the sort controls', () => {
expect(findSort().exists()).toBe(true);
});
};
// Tests // Tests
describe('when the component is loading data', () => { describe('when the component is loading data', () => {
beforeEach(() => { beforeEach(() => {
...@@ -147,6 +155,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -147,6 +155,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(0); expectReleases(0);
expectNoPagination(); expectNoPagination();
expectSort();
}); });
describe('when the data has successfully loaded, but there are no releases', () => { describe('when the data has successfully loaded, but there are no releases', () => {
...@@ -161,6 +170,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -161,6 +170,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(0); expectReleases(0);
expectNoPagination(); expectNoPagination();
expectSort();
}); });
describe('when an error occurs while loading data', () => { describe('when an error occurs while loading data', () => {
...@@ -174,6 +184,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -174,6 +184,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(0); expectReleases(0);
expectNoPagination(); expectNoPagination();
expectSort();
}); });
describe('when the data has successfully loaded with a single page of results', () => { describe('when the data has successfully loaded with a single page of results', () => {
...@@ -201,6 +212,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -201,6 +212,7 @@ describe('app_index_apollo_client.vue', () => {
expectNewReleaseButton(); expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length); expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
expectPagination(); expectPagination();
expectSort();
}); });
describe('URL parameters', () => { describe('URL parameters', () => {
...@@ -213,6 +225,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -213,6 +225,7 @@ describe('app_index_apollo_client.vue', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({ expect(allReleasesQueryMock).toHaveBeenCalledWith({
first: PAGE_SIZE, first: PAGE_SIZE,
fullPath: projectPath, fullPath: projectPath,
sort: RELEASED_AT_DESC,
}); });
}); });
}); });
...@@ -228,6 +241,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -228,6 +241,7 @@ describe('app_index_apollo_client.vue', () => {
before, before,
last: PAGE_SIZE, last: PAGE_SIZE,
fullPath: projectPath, fullPath: projectPath,
sort: RELEASED_AT_DESC,
}); });
}); });
}); });
...@@ -243,6 +257,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -243,6 +257,7 @@ describe('app_index_apollo_client.vue', () => {
after, after,
first: PAGE_SIZE, first: PAGE_SIZE,
fullPath: projectPath, fullPath: projectPath,
sort: RELEASED_AT_DESC,
}); });
}); });
}); });
...@@ -258,6 +273,7 @@ describe('app_index_apollo_client.vue', () => { ...@@ -258,6 +273,7 @@ describe('app_index_apollo_client.vue', () => {
after, after,
first: PAGE_SIZE, first: PAGE_SIZE,
fullPath: projectPath, fullPath: projectPath,
sort: RELEASED_AT_DESC,
}); });
}); });
}); });
...@@ -298,4 +314,27 @@ describe('app_index_apollo_client.vue', () => { ...@@ -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 } 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';
const GlSortingItemStub = {
template: '<div><slot></slot></div>',
};
describe('releases_sort_apollo_client.vue', () => {
let wrapper;
const createComponent = (valueProp = RELEASED_AT_ASC) => {
wrapper = shallowMountExtended(ReleasesSortApolloclient, {
propsData: {
value: valueProp,
},
stubs: {
GlSortingItem: GlSortingItemStub,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findSorting = () => wrapper.findComponent(GlSorting);
const findSortingItems = () => wrapper.findAllComponents(GlSortingItemStub);
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);
});
});
});
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