Skip to content
Snippets Groups Projects
Commit f257619d authored by Andrew Fontaine's avatar Andrew Fontaine
Browse files

Merge branch 'cngo-sort-dashboard' into 'master'

Add sort options to dashboard issues refactor

See merge request !105713



Merged-by: Andrew Fontaine's avatarAndrew Fontaine <afontaine@gitlab.com>
Approved-by: default avatarBojan Marjanovic <bmarjanovic@gitlab.com>
Approved-by: default avatarTomas Bulva <tbulva@gitlab.com>
Approved-by: Andrew Fontaine's avatarAndrew Fontaine <afontaine@gitlab.com>
Co-authored-by: default avatarCoung Ngo <cngo@gitlab.com>
parents 40b4d8ce 1dcba8b2
No related branches found
No related tags found
1 merge request!105713Add sort options to dashboard issues refactor
Pipeline #715228825 passed
......@@ -5,9 +5,17 @@ import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import { IssuableStatus } from '~/issues/constants';
import { PAGE_SIZE } from '~/issues/list/constants';
import { getInitialPageParams } from '~/issues/list/utils';
import {
CREATED_DESC,
PAGE_SIZE,
PARAM_STATE,
UPDATED_DESC,
urlSortParams,
} from '~/issues/list/constants';
import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
import { getInitialPageParams, getSortKey, getSortOptions, isSortKey } from '~/issues/list/utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants';
......@@ -17,13 +25,10 @@ export default {
calendarButtonText: __('Subscribe to calendar'),
closed: __('CLOSED'),
closedMoved: __('CLOSED (MOVED)'),
downvotes: __('Downvotes'),
emptyStateTitle: __('Please select at least one filter to see results'),
errorFetchingIssues: __('An error occurred while loading issues'),
relatedMergeRequests: __('Related merge requests'),
rssButtonText: __('Subscribe to RSS feed'),
searchInputPlaceholder: __('Search or filter results...'),
upvotes: __('Upvotes'),
},
IssuableListTabs,
components: {
......@@ -39,20 +44,35 @@ export default {
inject: [
'calendarPath',
'emptyStateSvgPath',
'hasBlockedIssuesFeature',
'hasIssuableHealthStatusFeature',
'hasIssueWeightsFeature',
'hasScopedLabelsFeature',
'initialSort',
'isPublicVisibilityRestricted',
'isSignedIn',
'rssPath',
],
data() {
const state = getParameterByName(PARAM_STATE);
const defaultSortKey = state === IssuableStates.Closed ? UPDATED_DESC : CREATED_DESC;
const dashboardSortKey = getSortKey(this.initialSort);
const graphQLSortKey =
isSortKey(this.initialSort?.toUpperCase()) && this.initialSort.toUpperCase();
// The initial sort is an old enum value when it is saved on the dashboard issues page.
// The initial sort is a GraphQL enum value when it is saved on the Vue issues list page.
const sortKey = dashboardSortKey || graphQLSortKey || defaultSortKey;
return {
issues: [],
issuesError: null,
pageInfo: {},
pageParams: getInitialPageParams(),
searchTokens: [],
sortOptions: [],
state: IssuableStates.Opened,
sortKey,
state: state || IssuableStates.Opened,
};
},
apollo: {
......@@ -62,6 +82,7 @@ export default {
return {
hideUsers: this.isPublicVisibilityRestricted && !this.isSignedIn,
isSignedIn: this.isSignedIn,
sort: this.sortKey,
state: this.state,
...this.pageParams,
};
......@@ -82,6 +103,19 @@ export default {
showPaginationControls() {
return this.issues.length > 0 && (this.pageInfo.hasNextPage || this.pageInfo.hasPreviousPage);
},
sortOptions() {
return getSortOptions({
hasBlockedIssuesFeature: this.hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature: this.hasIssuableHealthStatusFeature,
hasIssueWeightsFeature: this.hasIssueWeightsFeature,
});
},
urlParams() {
return {
sort: urlSortParams[this.sortKey],
state: this.state,
};
},
},
methods: {
getStatus(issue) {
......@@ -117,6 +151,33 @@ export default {
};
scrollUp();
},
handleSort(sortKey) {
if (this.sortKey === sortKey) {
return;
}
this.pageParams = getInitialPageParams();
this.sortKey = sortKey;
if (this.isSignedIn) {
this.saveSortPreference(sortKey);
}
},
saveSortPreference(sortKey) {
this.$apollo
.mutate({
mutation: setSortPreferenceMutation,
variables: { input: { issuesSort: sortKey } },
})
.then(({ data }) => {
if (data.userPreferencesUpdate.errors.length) {
throw new Error(data.userPreferencesUpdate.errors);
}
})
.catch((error) => {
Sentry.captureException(error);
});
},
},
};
</script>
......@@ -128,6 +189,7 @@ export default {
:has-next-page="pageInfo.hasNextPage"
:has-previous-page="pageInfo.hasPreviousPage"
:has-scoped-labels-feature="hasScopedLabelsFeature"
:initial-sort-by="sortKey"
:issuables="issues"
:issuables-loading="$apollo.queries.issues.loading"
namespace="dashboard"
......@@ -137,11 +199,13 @@ export default {
:show-pagination-controls="showPaginationControls"
:sort-options="sortOptions"
:tabs="$options.IssuableListTabs"
:url-params="urlParams"
use-keyset-pagination
@click-tab="handleClickTab"
@dismiss-alert="handleDismissAlert"
@next-page="handleNextPage"
@previous-page="handlePreviousPage"
@sort="handleSort"
>
<template #nav-actions>
<gl-button :href="rssPath" icon="rss">
......
......@@ -20,6 +20,7 @@ export function mountIssuesDashboardApp() {
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
hasScopedLabelsFeature,
initialSort,
isPublicVisibilityRestricted,
isSignedIn,
rssPath,
......@@ -38,6 +39,7 @@ export function mountIssuesDashboardApp() {
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasScopedLabelsFeature: parseBoolean(hasScopedLabelsFeature),
initialSort,
isPublicVisibilityRestricted: parseBoolean(isPublicVisibilityRestricted),
isSignedIn: parseBoolean(isSignedIn),
rssPath,
......
......@@ -4,6 +4,7 @@
query getDashboardIssues(
$hideUsers: Boolean = false
$isSignedIn: Boolean = false
$sort: IssueSort
$state: IssuableState
$afterCursor: String
$beforeCursor: String
......@@ -11,6 +12,7 @@ query getDashboardIssues(
$lastPageSize: Int
) {
issues(
sort: $sort
state: $state
after: $afterCursor
before: $beforeCursor
......
......@@ -123,8 +123,8 @@ export const urlSortParams = {
[CREATED_DESC]: 'created_date',
[UPDATED_ASC]: 'updated_asc',
[UPDATED_DESC]: 'updated_desc',
[CLOSED_AT_ASC]: 'closed_asc',
[CLOSED_AT_DESC]: 'closed_desc',
[CLOSED_AT_ASC]: 'closed_at',
[CLOSED_AT_DESC]: 'closed_at_desc',
[MILESTONE_DUE_ASC]: 'milestone',
[MILESTONE_DUE_DESC]: 'milestone_due_desc',
[DUE_DATE_ASC]: 'due_date',
......
......@@ -260,6 +260,7 @@ def dashboard_issues_list_data(current_user)
{
calendar_path: url_for(safe_params.merge(calendar_url_options)),
empty_state_svg_path: image_path('illustrations/issue-dashboard_results-without-filter.svg'),
initial_sort: current_user&.user_preference&.issues_sort,
is_public_visibility_restricted:
Gitlab::CurrentSettings.restricted_visibility_levels&.include?(Gitlab::VisibilityLevel::PUBLIC).to_s,
is_signed_in: current_user.present?.to_s,
......
......@@ -4,6 +4,7 @@
query getDashboardIssuesEE(
$hideUsers: Boolean = false
$isSignedIn: Boolean = false
$sort: IssueSort
$state: IssuableState
$afterCursor: String
$beforeCursor: String
......@@ -11,6 +12,7 @@ query getDashboardIssuesEE(
$lastPageSize: Int
) {
issues(
sort: $sort
state: $state
after: $afterCursor
before: $beforeCursor
......
......@@ -7,10 +7,17 @@ import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query
import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue';
import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
setSortPreferenceMutationResponse,
setSortPreferenceMutationResponseWithErrors,
} from 'jest/issues/list/mock_data';
import IssuesDashboardApp from '~/issues/dashboard/components/issues_dashboard_app.vue';
import { i18n } from '~/issues/list/constants';
import { CREATED_DESC, i18n, UPDATED_DESC, urlSortParams } from '~/issues/list/constants';
import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
import { getSortKey, getSortOptions } from '~/issues/list/utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import { IssuableStates } from '~/vue_shared/issuable/list/constants';
......@@ -31,6 +38,7 @@ describe('IssuesDashboardApp component', () => {
hasIssuableHealthStatusFeature: true,
hasIssueWeightsFeature: true,
hasScopedLabelsFeature: true,
initialSort: CREATED_DESC,
isPublicVisibilityRestricted: false,
isSignedIn: true,
rssPath: 'rss/path',
......@@ -54,11 +62,19 @@ describe('IssuesDashboardApp component', () => {
wrapper.findByRole('link', { name: IssuesDashboardApp.i18n.rssButtonText });
const mountComponent = ({
provide = {},
issuesQueryHandler = jest.fn().mockResolvedValue(defaultQueryResponse),
sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse),
} = {}) => {
wrapper = mountExtended(IssuesDashboardApp, {
apolloProvider: createMockApollo([[getIssuesQuery, issuesQueryHandler]]),
provide: defaultProvide,
apolloProvider: createMockApollo([
[getIssuesQuery, issuesQueryHandler],
[setSortPreferenceMutation, sortPreferenceMutationResponse],
]),
provide: {
...defaultProvide,
...provide,
},
});
};
......@@ -71,11 +87,23 @@ describe('IssuesDashboardApp component', () => {
hasNextPage: true,
hasPreviousPage: false,
hasScopedLabelsFeature: defaultProvide.hasScopedLabelsFeature,
initialSortBy: CREATED_DESC,
issuables: issuesQueryResponse.data.issues.nodes,
issuablesLoading: false,
namespace: 'dashboard',
recentSearchesStorageKey: 'issues',
searchInputPlaceholder: IssuesDashboardApp.i18n.searchInputPlaceholder,
showPaginationControls: true,
sortOptions: getSortOptions({
hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature,
hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature,
}),
tabs: IssuesDashboardApp.IssuableListTabs,
urlParams: {
sort: urlSortParams[CREATED_DESC],
state: IssuableStates.Opened,
},
useKeysetPagination: true,
});
});
......@@ -118,6 +146,51 @@ describe('IssuesDashboardApp component', () => {
});
});
describe('initial url params', () => {
describe('sort', () => {
describe('when initial sort value uses old enum values', () => {
const oldEnumSortValues = Object.values(urlSortParams);
it.each(oldEnumSortValues)('initial sort is set with value %s', (sort) => {
mountComponent({ provide: { initialSort: sort } });
expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort));
});
});
describe('when initial sort value uses new GraphQL enum values', () => {
const graphQLEnumSortValues = Object.keys(urlSortParams);
it.each(graphQLEnumSortValues)('initial sort is set with value %s', (sort) => {
mountComponent({ provide: { initialSort: sort.toLowerCase() } });
expect(findIssuableList().props('initialSortBy')).toBe(sort);
});
});
describe('when initial sort value is invalid', () => {
it.each(['', 'asdf', null, undefined])(
'initial sort is set to value CREATED_DESC',
(sort) => {
mountComponent({ provide: { initialSort: sort } });
expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC);
},
);
});
});
describe('state', () => {
it('is set from the url params', () => {
const initialState = IssuableStates.All;
setWindowLocation(`?state=${initialState}`);
mountComponent();
expect(findIssuableList().props('currentTab')).toBe(initialState);
});
});
});
describe('when there is an error fetching issues', () => {
beforeEach(() => {
mountComponent({ issuesQueryHandler: jest.fn().mockRejectedValue(new Error('ERROR')) });
......@@ -148,6 +221,12 @@ describe('IssuesDashboardApp component', () => {
it('updates ui to the new tab', () => {
expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
});
it('updates url to the new tab', () => {
expect(findIssuableList().props('urlParams')).toMatchObject({
state: IssuableStates.Closed,
});
});
});
describe.each(['next-page', 'previous-page'])(
......@@ -164,5 +243,63 @@ describe('IssuesDashboardApp component', () => {
});
},
);
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(urlSortParams))(
'updates to the new sort when payload is `%s`',
async (sortKey) => {
// Ensure initial sort key is different so we can trigger an update when emitting a sort key
if (sortKey === CREATED_DESC) {
mountComponent({ provide: { initialSort: UPDATED_DESC } });
} else {
mountComponent();
}
findIssuableList().vm.$emit('sort', sortKey);
await nextTick();
expect(findIssuableList().props('urlParams')).toMatchObject({
sort: urlSortParams[sortKey],
});
},
);
describe('when user is signed in', () => {
it('calls mutation to save sort preference', () => {
const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
mountComponent({ sortPreferenceMutationResponse: mutationMock });
findIssuableList().vm.$emit('sort', UPDATED_DESC);
expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: UPDATED_DESC } });
});
it('captures error when mutation response has errors', async () => {
const mutationMock = jest
.fn()
.mockResolvedValue(setSortPreferenceMutationResponseWithErrors);
mountComponent({ sortPreferenceMutationResponse: mutationMock });
findIssuableList().vm.$emit('sort', UPDATED_DESC);
await waitForPromises();
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!'));
});
});
describe('when user is signed out', () => {
it('does not call mutation to save sort preference', () => {
const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
mountComponent({
provide: { isSignedIn: false },
sortPreferenceMutationResponse: mutationMock,
});
findIssuableList().vm.$emit('sort', CREATED_DESC);
expect(mutationMock).not.toHaveBeenCalled();
});
});
});
});
});
......@@ -3,9 +3,8 @@
require 'spec_helper'
RSpec.describe IssuesHelper do
let(:project) { create(:project) }
let(:issue) { create(:issue, project: project) }
let(:ext_project) { create(:project, :with_redmine_integration) }
let_it_be(:project) { create(:project) }
let_it_be_with_reload(:issue) { create(:issue, project: project) }
describe '#work_item_type_icon' do
it 'returns icon of all standard base types' do
......@@ -392,6 +391,7 @@
expected = {
calendar_path: '#',
empty_state_svg_path: '#',
initial_sort: current_user&.user_preference&.issues_sort,
is_public_visibility_restricted: Gitlab::CurrentSettings.restricted_visibility_levels ? 'false' : '',
is_signed_in: current_user.present?.to_s,
rss_path: '#'
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment