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'; ...@@ -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> <script>
import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; 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 { export default {
name: 'ReleasesSort', name: 'ReleasesSort',
...@@ -22,13 +22,13 @@ export default { ...@@ -22,13 +22,13 @@ export default {
return option.label; return option.label;
}, },
isSortAscending() { isSortAscending() {
return this.sort === ASCENDING_ODER; return this.sort === ASCENDING_ORDER;
}, },
}, },
methods: { methods: {
...mapActions('index', ['setSorting']), ...mapActions('index', ['setSorting']),
onDirectionChange() { onDirectionChange() {
const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
this.setSorting({ sort }); this.setSorting({ sort });
this.$emit('sort:changed'); 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; ...@@ -15,7 +15,7 @@ export const DEFAULT_ASSET_LINK_TYPE = ASSET_LINK_TYPE.OTHER;
export const PAGE_SIZE = 10; export const PAGE_SIZE = 10;
export const ASCENDING_ODER = 'asc'; export const ASCENDING_ORDER = 'asc';
export const DESCENDING_ORDER = 'desc'; export const DESCENDING_ORDER = 'desc';
export const RELEASED_AT = 'released_at'; export const RELEASED_AT = 'released_at';
export const CREATED_AT = 'created_at'; export const CREATED_AT = 'created_at';
...@@ -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_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'; ...@@ -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, 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