Commit a520f3fc authored by Coung Ngo's avatar Coung Ngo Committed by Andrew Fontaine

Make additions to group/project issues list refactor

parent 68bfa394
......@@ -8,6 +8,7 @@ import {
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
import getIssuesCountsQuery from 'ee_else_ce/issues_list/queries/get_issues_counts.query.graphql';
......@@ -138,6 +139,9 @@ export default {
initialEmail: {
default: '',
},
isAnonymousSearchDisabled: {
default: false,
},
isIssueRepositioningDisabled: {
default: false,
},
......@@ -183,12 +187,22 @@ export default {
sortKey = defaultSortKey;
}
const isSearchDisabled =
this.isAnonymousSearchDisabled &&
!this.isSignedIn &&
window.location.search.includes('search=');
if (isSearchDisabled) {
this.showAnonymousSearchingMessage();
}
return {
dueDateFilter: getDueDateValue(getParameterByName(PARAM_DUE_DATE)),
exportCsvPathWithQuery: this.getExportCsvPathWithQuery(),
filterTokens: getFilterTokens(window.location.search),
filterTokens: isSearchDisabled ? [] : getFilterTokens(window.location.search),
issues: [],
issuesCounts: {},
issuesError: null,
pageInfo: {},
pageParams: getInitialPageParams(sortKey),
showBulkEditSidebar: false,
......@@ -210,7 +224,8 @@ export default {
this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery();
},
error(error) {
createFlash({ message: this.$options.i18n.errorFetchingIssues, captureError: true, error });
this.issuesError = this.$options.i18n.errorFetchingIssues;
Sentry.captureException(error);
},
skip() {
return !this.hasAnyIssues;
......@@ -226,7 +241,8 @@ export default {
return data[this.namespace] ?? {};
},
error(error) {
createFlash({ message: this.$options.i18n.errorFetchingCounts, captureError: true, error });
this.issuesError = this.$options.i18n.errorFetchingCounts;
Sentry.captureException(error);
},
skip() {
return !this.hasAnyIssues;
......@@ -387,6 +403,8 @@ export default {
tokens.push(...this.eeSearchTokens);
}
tokens.sort((a, b) => a.title.localeCompare(b.title));
return tokens;
},
showPaginationControls() {
......@@ -518,7 +536,14 @@ export default {
}
this.state = state;
},
handleDismissAlert() {
this.issuesError = null;
},
handleFilter(filter) {
if (this.isAnonymousSearchDisabled && !this.isSignedIn) {
this.showAnonymousSearchingMessage();
return;
}
this.pageParams = getInitialPageParams(this.sortKey);
this.filterTokens = filter;
},
......@@ -569,7 +594,8 @@ export default {
});
})
.catch((error) => {
createFlash({ message: this.$options.i18n.reorderError, captureError: true, error });
this.issuesError = this.$options.i18n.reorderError;
Sentry.captureException(error);
});
},
handleSort(sortKey) {
......@@ -583,6 +609,12 @@ export default {
}
this.sortKey = sortKey;
},
showAnonymousSearchingMessage() {
createFlash({
message: this.$options.i18n.anonymousSearchingMessage,
type: FLASH_TYPES.NOTICE,
});
},
showIssueRepositioningMessage() {
createFlash({
message: this.$options.i18n.issueRepositioningMessage,
......@@ -607,6 +639,7 @@ export default {
:sort-options="sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
:error="issuesError"
label-filter-param="label_name"
:tabs="$options.IssuableListTabs"
:current-tab="state"
......@@ -620,6 +653,7 @@ export default {
:has-previous-page="pageInfo.hasPreviousPage"
:url-params="urlParams"
@click-tab="handleClickTab"
@dismiss-alert="handleDismissAlert"
@filter="handleFilter"
@next-page="handleNextPage"
@previous-page="handlePreviousPage"
......
......@@ -66,6 +66,7 @@ export const availableSortOptionsJira = [
];
export const i18n = {
anonymousSearchingMessage: __('You must sign in to search for specific terms.'),
calendarLabel: __('Subscribe to calendar'),
closed: __('CLOSED'),
closedMoved: __('CLOSED (MOVED)'),
......@@ -136,6 +137,7 @@ export const DUE_DATE_VALUES = [
DUE_DATE_NEXT_MONTH_AND_PREVIOUS_TWO_WEEKS,
];
export const BLOCKING_ISSUES_ASC = 'BLOCKING_ISSUES_ASC';
export const BLOCKING_ISSUES_DESC = 'BLOCKING_ISSUES_DESC';
export const CREATED_ASC = 'CREATED_ASC';
export const CREATED_DESC = 'CREATED_DESC';
......@@ -157,42 +159,28 @@ export const UPDATED_DESC = 'UPDATED_DESC';
export const WEIGHT_ASC = 'WEIGHT_ASC';
export const WEIGHT_DESC = 'WEIGHT_DESC';
const PRIORITY_ASC_SORT = 'priority_asc';
const CREATED_DATE_SORT = 'created_date';
const CREATED_ASC_SORT = 'created_asc';
const UPDATED_DESC_SORT = 'updated_desc';
const UPDATED_ASC_SORT = 'updated_asc';
const MILESTONE_SORT = 'milestone';
const MILESTONE_DUE_DESC_SORT = 'milestone_due_desc';
const DUE_DATE_DESC_SORT = 'due_date_desc';
const LABEL_PRIORITY_ASC_SORT = 'label_priority_asc';
const POPULARITY_ASC_SORT = 'popularity_asc';
const WEIGHT_DESC_SORT = 'weight_desc';
const BLOCKING_ISSUES_DESC_SORT = 'blocking_issues_desc';
const TITLE_ASC_SORT = 'title_asc';
const TITLE_DESC_SORT = 'title_desc';
export const urlSortParams = {
[PRIORITY_ASC]: PRIORITY_ASC_SORT,
[PRIORITY_DESC]: PRIORITY,
[CREATED_ASC]: CREATED_ASC_SORT,
[CREATED_DESC]: CREATED_DATE_SORT,
[UPDATED_ASC]: UPDATED_ASC_SORT,
[UPDATED_DESC]: UPDATED_DESC_SORT,
[MILESTONE_DUE_ASC]: MILESTONE_SORT,
[MILESTONE_DUE_DESC]: MILESTONE_DUE_DESC_SORT,
[DUE_DATE_ASC]: DUE_DATE,
[DUE_DATE_DESC]: DUE_DATE_DESC_SORT,
[POPULARITY_ASC]: POPULARITY_ASC_SORT,
[POPULARITY_DESC]: POPULARITY,
[LABEL_PRIORITY_ASC]: LABEL_PRIORITY_ASC_SORT,
[LABEL_PRIORITY_DESC]: LABEL_PRIORITY,
[PRIORITY_ASC]: 'priority',
[PRIORITY_DESC]: 'priority_desc',
[CREATED_ASC]: 'created_asc',
[CREATED_DESC]: 'created_date',
[UPDATED_ASC]: 'updated_asc',
[UPDATED_DESC]: 'updated_desc',
[MILESTONE_DUE_ASC]: 'milestone',
[MILESTONE_DUE_DESC]: 'milestone_due_desc',
[DUE_DATE_ASC]: 'due_date',
[DUE_DATE_DESC]: 'due_date_desc',
[POPULARITY_ASC]: 'popularity_asc',
[POPULARITY_DESC]: 'popularity',
[LABEL_PRIORITY_ASC]: 'label_priority',
[LABEL_PRIORITY_DESC]: 'label_priority_desc',
[RELATIVE_POSITION_ASC]: RELATIVE_POSITION,
[WEIGHT_ASC]: WEIGHT,
[WEIGHT_DESC]: WEIGHT_DESC_SORT,
[BLOCKING_ISSUES_DESC]: BLOCKING_ISSUES_DESC_SORT,
[TITLE_ASC]: TITLE_ASC_SORT,
[TITLE_DESC]: TITLE_DESC_SORT,
[WEIGHT_ASC]: 'weight',
[WEIGHT_DESC]: 'weight_desc',
[BLOCKING_ISSUES_ASC]: 'blocking_issues_asc',
[BLOCKING_ISSUES_DESC]: 'blocking_issues_desc',
[TITLE_ASC]: 'title_asc',
[TITLE_DESC]: 'title_desc',
};
export const MAX_LIST_SIZE = 10;
......
......@@ -129,6 +129,7 @@ export function mountIssuesListApp() {
hasMultipleIssueAssigneesFeature,
importCsvIssuesPath,
initialEmail,
isAnonymousSearchDisabled,
isIssueRepositioningDisabled,
isProject,
isSignedIn,
......@@ -162,6 +163,7 @@ export function mountIssuesListApp() {
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasIterationsFeature: parseBoolean(hasIterationsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
isAnonymousSearchDisabled: parseBoolean(isAnonymousSearchDisabled),
isIssueRepositioningDisabled: parseBoolean(isIssueRepositioningDisabled),
isProject: parseBoolean(isProject),
isSignedIn: parseBoolean(isSignedIn),
......
import {
API_PARAM,
BLOCKING_ISSUES_ASC,
BLOCKING_ISSUES_DESC,
CREATED_ASC,
CREATED_DESC,
......@@ -143,7 +144,7 @@ export const getSortOptions = (hasIssueWeightsFeature, hasBlockedIssuesFeature)
id: sortOptions.length + 1,
title: __('Blocking'),
sortDirection: {
ascending: BLOCKING_ISSUES_DESC,
ascending: BLOCKING_ISSUES_ASC,
descending: BLOCKING_ISSUES_DESC,
},
});
......
<script>
import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory, setUrlParams } from '~/lib/utils/url_utility';
......@@ -19,6 +19,7 @@ export default {
tag: 'ul',
},
components: {
GlAlert,
GlKeysetPagination,
GlSkeletonLoading,
IssuableTabs,
......@@ -156,6 +157,11 @@ export default {
required: false,
default: false,
},
error: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -277,6 +283,7 @@ export default {
@onFilter="$emit('filter', $event)"
@onSort="$emit('sort', $event)"
/>
<gl-alert v-if="error" variant="danger" @dismiss="$emit('dismiss-alert')">{{ error }}</gl-alert>
<issuable-bulk-edit-sidebar :expanded="showBulkEditSidebar">
<template #bulk-edit-actions>
<slot name="bulk-edit-actions" :checked-issuables="bulkEditIssuables"></slot>
......
......@@ -212,6 +212,7 @@ module IssuesHelper
calendar_path: url_for(safe_params.merge(calendar_url_options)),
empty_state_svg_path: image_path('illustrations/issues.svg'),
full_path: namespace.full_path,
is_anonymous_search_disabled: Feature.enabled?(:disable_anonymous_search, type: :ops).to_s,
is_issue_repositioning_disabled: issue_repositioning_disabled?.to_s,
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
......
......@@ -14,6 +14,7 @@ Object {
"currentTab": "opened",
"defaultPageSize": 2,
"enableLabelPermalinks": true,
"error": "",
"hasNextPage": false,
"hasPreviousPage": false,
"initialFilterValue": Array [],
......
......@@ -124,22 +124,22 @@ describe('EE IssuesListApp component', () => {
window.gon = originalGon;
});
it('renders all tokens', () => {
it('renders all tokens alphabetically', () => {
const preloadedAuthors = [
{ ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) },
];
expect(findIssuableList().props('searchTokens')).toMatchObject([
{ type: TOKEN_TYPE_AUTHOR, preloadedAuthors },
{ type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors },
{ type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_LABEL },
{ type: TOKEN_TYPE_TYPE },
{ type: TOKEN_TYPE_RELEASE },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_AUTHOR, preloadedAuthors },
{ type: TOKEN_TYPE_CONFIDENTIAL },
{ type: TOKEN_TYPE_ITERATION },
{ type: TOKEN_TYPE_EPIC },
{ type: TOKEN_TYPE_ITERATION },
{ type: TOKEN_TYPE_LABEL },
{ type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_RELEASE },
{ type: TOKEN_TYPE_TYPE },
{ type: TOKEN_TYPE_WEIGHT },
]);
});
......
import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { cloneDeep } from 'lodash';
......@@ -47,6 +48,7 @@ import axios from '~/lib/utils/axios_utils';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { joinPaths } from '~/lib/utils/url_utility';
jest.mock('@sentry/browser');
jest.mock('~/flash');
jest.mock('~/lib/utils/scroll_utils', () => ({
scrollUp: jest.fn().mockName('scrollUpMock'),
......@@ -357,6 +359,27 @@ describe('CE IssuesListApp component', () => {
expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens);
});
describe('when anonymous searching is performed', () => {
beforeEach(() => {
setWindowLocation(locationSearch);
wrapper = mountComponent({
provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
});
});
it('is not set from url params', () => {
expect(findIssuableList().props('initialFilterValue')).toEqual([]);
});
it('shows an alert to tell the user they must be signed in to search', () => {
expect(createFlash).toHaveBeenCalledWith({
message: IssuesListApp.i18n.anonymousSearchingMessage,
type: FLASH_TYPES.NOTICE,
});
});
});
});
});
......@@ -505,11 +528,7 @@ describe('CE IssuesListApp component', () => {
describe('when user is signed out', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
isSignedIn: false,
},
});
wrapper = mountComponent({ provide: { isSignedIn: false } });
});
it('does not render My-Reaction or Confidential tokens', () => {
......@@ -541,20 +560,20 @@ describe('CE IssuesListApp component', () => {
window.gon = originalGon;
});
it('renders all tokens', () => {
it('renders all tokens alphabetically', () => {
const preloadedAuthors = [
{ ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) },
];
expect(findIssuableList().props('searchTokens')).toMatchObject([
{ type: TOKEN_TYPE_AUTHOR, preloadedAuthors },
{ type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors },
{ type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_AUTHOR, preloadedAuthors },
{ type: TOKEN_TYPE_CONFIDENTIAL },
{ type: TOKEN_TYPE_LABEL },
{ type: TOKEN_TYPE_TYPE },
{ type: TOKEN_TYPE_RELEASE },
{ type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_CONFIDENTIAL },
{ type: TOKEN_TYPE_RELEASE },
{ type: TOKEN_TYPE_TYPE },
]);
});
});
......@@ -574,13 +593,18 @@ describe('CE IssuesListApp component', () => {
});
it('shows an error message', () => {
expect(createFlash).toHaveBeenCalledWith({
captureError: true,
error: new Error('Network error: ERROR'),
message,
});
expect(findIssuableList().props('error')).toBe(message);
expect(Sentry.captureException).toHaveBeenCalledWith(new Error('Network error: ERROR'));
});
});
it('clears error message when "dismiss-alert" event is emitted from IssuableList', () => {
wrapper = mountComponent({ issuesQueryResponse: jest.fn().mockRejectedValue(new Error()) });
findIssuableList().vm.$emit('dismiss-alert');
expect(findIssuableList().props('error')).toBeNull();
});
});
describe('events', () => {
......@@ -705,11 +729,10 @@ describe('CE IssuesListApp component', () => {
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({
message: IssuesListApp.i18n.reorderError,
captureError: true,
error: new Error('Request failed with status code 500'),
});
expect(findIssuableList().props('error')).toBe(IssuesListApp.i18n.reorderError);
expect(Sentry.captureException).toHaveBeenCalledWith(
new Error('Request failed with status code 500'),
);
});
});
});
......@@ -770,15 +793,37 @@ describe('CE IssuesListApp component', () => {
});
describe('when "filter" event is emitted by IssuableList', () => {
beforeEach(() => {
it('updates IssuableList with url params', async () => {
wrapper = mountComponent();
findIssuableList().vm.$emit('filter', filteredTokens);
});
await nextTick();
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});
describe('when anonymous searching is performed', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { isAnonymousSearchDisabled: true, isSignedIn: false },
});
findIssuableList().vm.$emit('filter', filteredTokens);
});
it('does not update IssuableList with url params ', async () => {
const defaultParams = { sort: 'created_date', state: 'opened' };
expect(findIssuableList().props('urlParams')).toEqual(defaultParams);
});
it('shows an alert to tell the user they must be signed in to search', () => {
expect(createFlash).toHaveBeenCalledWith({
message: IssuesListApp.i18n.anonymousSearchingMessage,
type: FLASH_TYPES.NOTICE,
});
});
});
});
});
});
import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { GlAlert, GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
......@@ -36,6 +36,7 @@ const createComponent = ({ props = {}, data = {} } = {}) =>
describe('IssuableListRoot', () => {
let wrapper;
const findAlert = () => wrapper.findComponent(GlAlert);
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findGlPagination = () => wrapper.findComponent(GlPagination);
......@@ -310,6 +311,30 @@ describe('IssuableListRoot', () => {
hasPreviousPage: true,
});
});
describe('alert', () => {
const error = 'oopsie!';
it('shows alert when there is an error', () => {
wrapper = createComponent({ props: { error } });
expect(findAlert().text()).toBe(error);
});
it('emits "dismiss-alert" event when dismissed', () => {
wrapper = createComponent({ props: { error } });
findAlert().vm.$emit('dismiss');
expect(wrapper.emitted('dismiss-alert')).toEqual([[]]);
});
it('does not render when there is no error', () => {
wrapper = createComponent();
expect(findAlert().exists()).toBe(false);
});
});
});
describe('events', () => {
......
......@@ -319,6 +319,7 @@ RSpec.describe IssuesHelper do
has_any_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',
initial_email: project.new_issuable_address(current_user, 'issue'),
is_anonymous_search_disabled: 'true',
is_issue_repositioning_disabled: 'true',
is_project: 'true',
is_signed_in: current_user.present?.to_s,
......@@ -340,6 +341,10 @@ RSpec.describe IssuesHelper do
end
describe '#project_issues_list_data' do
before do
stub_feature_flags(disable_anonymous_search: true)
end
context 'when user is signed in' do
it_behaves_like 'issues list data' do
let(:current_user) { double.as_null_object }
......
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