Commit f79f990e authored by Olena Horal-Koretska's avatar Olena Horal-Koretska

Merge branch '322755-add-various-things-to-issues-page-refactor' into 'master'

Add and fix various things in issues page refactor

See merge request gitlab-org/gitlab!62087
parents 4872158f 944cb6d5
......@@ -84,7 +84,7 @@ export default {
<template>
<div :class="containerClass">
<gl-button-group>
<gl-button-group class="gl-w-full">
<gl-button
v-if="showExportButton"
v-gl-tooltip="$options.i18n.exportAsCsvButtonText"
......
......@@ -27,6 +27,15 @@ import {
PARAM_SORT,
PARAM_STATE,
RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_EPIC,
TOKEN_TYPE_ITERATION,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_WEIGHT,
UPDATED_DESC,
URL_PARAM,
urlSortParams,
......@@ -110,15 +119,15 @@ export default {
hasBlockedIssuesFeature: {
default: false,
},
hasIssues: {
default: false,
},
hasIssueWeightsFeature: {
default: false,
},
hasMultipleIssueAssigneesFeature: {
default: false,
},
hasProjectIssues: {
default: false,
},
initialEmail: {
default: '',
},
......@@ -174,6 +183,9 @@ export default {
};
},
computed: {
hasSearch() {
return this.searchQuery || Object.keys(this.urlFilterParams).length;
},
isBulkEditButtonDisabled() {
return this.showBulkEditSidebar || !this.issues.length;
},
......@@ -195,7 +207,7 @@ export default {
searchTokens() {
const tokens = [
{
type: 'author_username',
type: TOKEN_TYPE_AUTHOR,
title: TOKEN_TITLE_AUTHOR,
icon: 'pencil',
token: AuthorToken,
......@@ -205,7 +217,7 @@ export default {
fetchAuthors: this.fetchUsers,
},
{
type: 'assignee_username',
type: TOKEN_TYPE_ASSIGNEE,
title: TOKEN_TITLE_ASSIGNEE,
icon: 'user',
token: AuthorToken,
......@@ -215,7 +227,7 @@ export default {
fetchAuthors: this.fetchUsers,
},
{
type: 'milestone',
type: TOKEN_TYPE_MILESTONE,
title: TOKEN_TITLE_MILESTONE,
icon: 'clock',
token: MilestoneToken,
......@@ -224,24 +236,28 @@ export default {
fetchMilestones: this.fetchMilestones,
},
{
type: 'labels',
type: TOKEN_TYPE_LABEL,
title: TOKEN_TITLE_LABEL,
icon: 'labels',
token: LabelToken,
defaultLabels: [],
fetchLabels: this.fetchLabels,
},
{
type: 'my_reaction_emoji',
];
if (this.isSignedIn) {
tokens.push({
type: TOKEN_TYPE_MY_REACTION,
title: TOKEN_TITLE_MY_REACTION,
icon: 'thumb-up',
token: EmojiToken,
unique: true,
operators: OPERATOR_IS_ONLY,
fetchEmojis: this.fetchEmojis,
},
{
type: 'confidential',
});
tokens.push({
type: TOKEN_TYPE_CONFIDENTIAL,
title: TOKEN_TITLE_CONFIDENTIAL,
icon: 'eye-slash',
token: GlFilteredSearchToken,
......@@ -251,12 +267,12 @@ export default {
{ icon: 'eye-slash', value: 'yes', title: this.$options.i18n.confidentialYes },
{ icon: 'eye', value: 'no', title: this.$options.i18n.confidentialNo },
],
},
];
});
}
if (this.projectIterationsPath) {
tokens.push({
type: 'iteration',
type: TOKEN_TYPE_ITERATION,
title: TOKEN_TITLE_ITERATION,
icon: 'iteration',
token: IterationToken,
......@@ -267,7 +283,7 @@ export default {
if (this.groupEpicsPath) {
tokens.push({
type: 'epic_id',
type: TOKEN_TYPE_EPIC,
title: TOKEN_TITLE_EPIC,
icon: 'epic',
token: EpicToken,
......@@ -278,7 +294,7 @@ export default {
if (this.hasIssueWeightsFeature) {
tokens.push({
type: 'weight',
type: TOKEN_TYPE_WEIGHT,
title: TOKEN_TITLE_WEIGHT,
icon: 'weight',
token: WeightToken,
......@@ -365,7 +381,7 @@ export default {
return axios.get(this.autocompleteUsersPath, { params: { search } });
},
fetchIssues() {
if (!this.hasIssues) {
if (!this.hasProjectIssues) {
return undefined;
}
......@@ -490,7 +506,7 @@ export default {
</script>
<template>
<div v-if="hasIssues">
<div v-if="hasProjectIssues">
<issuable-list
:namespace="projectPath"
recent-searches-storage-key="issues"
......@@ -500,6 +516,7 @@ export default {
:sort-options="sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
label-filter-param="label_name"
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
......@@ -536,7 +553,7 @@ export default {
/>
<csv-import-export-buttons
v-if="isSignedIn"
class="gl-mr-3"
class="gl-md-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="totalIssues"
/>
......@@ -600,7 +617,7 @@ export default {
<template #empty-state>
<gl-empty-state
v-if="searchQuery"
v-if="hasSearch"
:description="$options.i18n.noSearchResultsDescription"
:title="$options.i18n.noSearchResultsTitle"
:svg-path="emptyStateSvgPath"
......
......@@ -281,8 +281,18 @@ export const SPECIAL_FILTER = 'specialFilter';
export const ALTERNATIVE_FILTER = 'alternativeFilter';
export const SPECIAL_FILTER_VALUES = [FILTER_NONE, FILTER_ANY, FILTER_CURRENT];
export const TOKEN_TYPE_AUTHOR = 'author_username';
export const TOKEN_TYPE_ASSIGNEE = 'assignee_username';
export const TOKEN_TYPE_MILESTONE = 'milestone';
export const TOKEN_TYPE_LABEL = 'labels';
export const TOKEN_TYPE_MY_REACTION = 'my_reaction_emoji';
export const TOKEN_TYPE_CONFIDENTIAL = 'confidential';
export const TOKEN_TYPE_ITERATION = 'iteration';
export const TOKEN_TYPE_EPIC = 'epic_id';
export const TOKEN_TYPE_WEIGHT = 'weight';
export const filters = {
author_username: {
[TOKEN_TYPE_AUTHOR]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'author_username',
......@@ -300,7 +310,7 @@ export const filters = {
},
},
},
assignee_username: {
[TOKEN_TYPE_ASSIGNEE]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'assignee_username',
......@@ -321,7 +331,7 @@ export const filters = {
},
},
},
milestone: {
[TOKEN_TYPE_MILESTONE]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'milestone',
......@@ -339,7 +349,7 @@ export const filters = {
},
},
},
labels: {
[TOKEN_TYPE_LABEL]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'labels',
......@@ -357,7 +367,7 @@ export const filters = {
},
},
},
my_reaction_emoji: {
[TOKEN_TYPE_MY_REACTION]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'my_reaction_emoji',
......@@ -371,7 +381,7 @@ export const filters = {
},
},
},
confidential: {
[TOKEN_TYPE_CONFIDENTIAL]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'confidential',
......@@ -383,7 +393,7 @@ export const filters = {
},
},
},
iteration: {
[TOKEN_TYPE_ITERATION]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'iteration_title',
......@@ -403,7 +413,7 @@ export const filters = {
},
},
},
epic_id: {
[TOKEN_TYPE_EPIC]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'epic_id',
......@@ -423,7 +433,7 @@ export const filters = {
},
},
},
weight: {
[TOKEN_TYPE_WEIGHT]: {
[API_PARAM]: {
[OPERATOR_IS]: {
[NORMAL_FILTER]: 'weight',
......
......@@ -88,9 +88,9 @@ export function mountIssuesListApp() {
groupEpicsPath,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssues,
hasIssueWeightsFeature,
hasMultipleIssueAssigneesFeature,
hasProjectIssues,
importCsvIssuesPath,
initialEmail,
isSignedIn,
......@@ -126,9 +126,9 @@ export function mountIssuesListApp() {
groupEpicsPath,
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssues: parseBoolean(hasIssues),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
hasMultipleIssueAssigneesFeature: parseBoolean(hasMultipleIssueAssigneesFeature),
hasProjectIssues: parseBoolean(hasProjectIssues),
isSignedIn: parseBoolean(isSignedIn),
issuesPath,
jiraIntegrationPath,
......@@ -147,9 +147,9 @@ export function mountIssuesListApp() {
importCsvIssuesPath,
maxAttachmentSize,
projectImportJiraPath,
showExportButton: parseBoolean(hasIssues),
showExportButton: parseBoolean(hasProjectIssues),
showImportButton: parseBoolean(canImportIssues),
showLabel: !parseBoolean(hasIssues),
showLabel: !parseBoolean(hasProjectIssues),
// For IssuableByEmail component
emailsHelpPagePath,
initialEmail,
......
......@@ -16,6 +16,7 @@ import {
RELATIVE_POSITION_DESC,
SPECIAL_FILTER,
SPECIAL_FILTER_VALUES,
TOKEN_TYPE_ASSIGNEE,
UPDATED_ASC,
UPDATED_DESC,
urlSortParams,
......@@ -173,7 +174,7 @@ export const getFilterTokens = (locationSearch) => {
const getFilterType = (data, tokenType = '') =>
SPECIAL_FILTER_VALUES.includes(data) ||
(tokenType === 'assignee_username' && isPositiveInteger(data))
(tokenType === TOKEN_TYPE_ASSIGNEE && isPositiveInteger(data))
? SPECIAL_FILTER
: NORMAL_FILTER;
......
......@@ -39,6 +39,14 @@ export default {
};
},
computed: {
currentUser() {
return {
id: gon.current_user_id,
name: gon.current_user_fullname,
username: gon.current_username,
avatar_url: gon.current_user_avatar_url,
};
},
currentValue() {
return this.value.data.toLowerCase();
},
......@@ -113,7 +121,18 @@ export default {
{{ author.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultAuthors.length" />
<gl-loading-icon v-if="loading" />
<template v-if="loading">
<gl-filtered-search-suggestion v-if="currentUser.id" :value="currentUser.username">
<div class="gl-display-flex">
<gl-avatar :size="32" :src="avatarUrl(currentUser)" />
<div>
<div>{{ currentUser.name }}</div>
<div>@{{ currentUser.username }}</div>
</div>
</div>
</gl-filtered-search-suggestion>
<gl-loading-icon class="gl-mt-3" />
</template>
<template v-else>
<gl-filtered-search-suggestion
v-for="author in authors"
......
......@@ -60,7 +60,7 @@ export default {
},
methods: {
avatarUrlTitle(assignee) {
return sprintf(__('Avatar for %{assigneeName}'), {
return sprintf(__('Assigned to %{assigneeName}'), {
assigneeName: assignee.name,
});
},
......
......@@ -192,7 +192,7 @@ module IssuesHelper
empty_state_svg_path: image_path('illustrations/issues.svg'),
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_issues: project_issues(project).exists?.to_s,
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: import_csv_namespace_project_issues_path,
initial_email: project.new_issuable_address(current_user, 'issue'),
is_signed_in: current_user.present?.to_s,
......
......@@ -18,6 +18,15 @@ import {
PAGE_SIZE_MANUAL,
PARAM_DUE_DATE,
RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
TOKEN_TYPE_EPIC,
TOKEN_TYPE_ITERATION,
TOKEN_TYPE_LABEL,
TOKEN_TYPE_MILESTONE,
TOKEN_TYPE_MY_REACTION,
TOKEN_TYPE_WEIGHT,
urlSortParams,
} from '~/issues_list/constants';
import eventHub from '~/issues_list/eventhub';
......@@ -39,8 +48,8 @@ describe('IssuesListApp component', () => {
endpoint: 'api/endpoint',
exportCsvPath: 'export/csv/path',
hasBlockedIssuesFeature: true,
hasIssues: true,
hasIssueWeightsFeature: true,
hasProjectIssues: true,
isSignedIn: false,
issuesPath: 'path/to/issues',
jiraIntegrationPath: 'jira/integration/path',
......@@ -320,7 +329,7 @@ describe('IssuesListApp component', () => {
beforeEach(async () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
......@@ -336,7 +345,7 @@ describe('IssuesListApp component', () => {
describe('when "Open" tab has no issues', () => {
beforeEach(async () => {
wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
......@@ -356,7 +365,7 @@ describe('IssuesListApp component', () => {
url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
});
wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
await waitForPromises();
});
......@@ -374,7 +383,7 @@ describe('IssuesListApp component', () => {
describe('when user is logged in', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasIssues: false, isSignedIn: true },
provide: { hasProjectIssues: false, isSignedIn: true },
mountFn: mount,
});
});
......@@ -413,7 +422,7 @@ describe('IssuesListApp component', () => {
describe('when user is logged out', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: { hasIssues: false, isSignedIn: false },
provide: { hasProjectIssues: false, isSignedIn: false },
});
});
......@@ -430,6 +439,100 @@ describe('IssuesListApp component', () => {
});
});
describe('tokens', () => {
describe('when user is signed out', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
isSignedIn: false,
},
});
});
it('does not render My-Reaction or Confidential tokens', () => {
expect(findIssuableList().props('searchTokens')).not.toMatchObject([
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_CONFIDENTIAL },
]);
});
});
describe('when iterations are not available', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
projectIterationsPath: '',
},
});
});
it('does not render Iteration token', () => {
expect(findIssuableList().props('searchTokens')).not.toMatchObject([
{ type: TOKEN_TYPE_ITERATION },
]);
});
});
describe('when epics are not available', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
groupEpicsPath: '',
},
});
});
it('does not render Epic token', () => {
expect(findIssuableList().props('searchTokens')).not.toMatchObject([
{ type: TOKEN_TYPE_EPIC },
]);
});
});
describe('when weights are not available', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
groupEpicsPath: '',
},
});
});
it('does not render Weight token', () => {
expect(findIssuableList().props('searchTokens')).not.toMatchObject([
{ type: TOKEN_TYPE_WEIGHT },
]);
});
});
describe('when all tokens are available', () => {
beforeEach(() => {
wrapper = mountComponent({
provide: {
isSignedIn: true,
projectIterationsPath: 'project/iterations/path',
groupEpicsPath: 'group/epics/path',
hasIssueWeightsFeature: true,
},
});
});
it('renders all tokens', () => {
expect(findIssuableList().props('searchTokens')).toMatchObject([
{ type: TOKEN_TYPE_AUTHOR },
{ type: TOKEN_TYPE_ASSIGNEE },
{ type: TOKEN_TYPE_MILESTONE },
{ type: TOKEN_TYPE_LABEL },
{ type: TOKEN_TYPE_MY_REACTION },
{ type: TOKEN_TYPE_CONFIDENTIAL },
{ type: TOKEN_TYPE_ITERATION },
{ type: TOKEN_TYPE_EPIC },
{ type: TOKEN_TYPE_WEIGHT },
]);
});
});
});
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
......
......@@ -3,6 +3,7 @@ import {
GlFilteredSearchTokenSegment,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
......@@ -35,6 +36,7 @@ function createComponent(options = {}) {
value = { data: '' },
active = false,
stubs = defaultStubs,
data = {},
} = options;
return mount(AuthorToken, {
propsData: {
......@@ -47,20 +49,32 @@ function createComponent(options = {}) {
alignSuggestions: function fakeAlignSuggestions() {},
suggestionsListClass: 'custom-class',
},
data() {
return { ...data };
},
stubs,
});
}
describe('AuthorToken', () => {
const originalGon = window.gon;
const currentUserLength = 1;
let mock;
let wrapper;
beforeEach(() => {
window.gon = {
...originalGon,
current_user_id: 13,
current_user_fullname: 'Administrator',
current_username: 'root',
current_user_avatar_url: 'avatar/url',
};
mock = new MockAdapter(axios);
wrapper = createComponent();
});
afterEach(() => {
window.gon = originalGon;
mock.restore();
wrapper.destroy();
});
......@@ -91,6 +105,8 @@ describe('AuthorToken', () => {
describe('fetchAuthorBySearchTerm', () => {
it('calls `config.fetchAuthors` with provided searchTerm param', () => {
wrapper = createComponent();
jest.spyOn(wrapper.vm.config, 'fetchAuthors');
wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username);
......@@ -102,6 +118,8 @@ describe('AuthorToken', () => {
});
it('sets response to `authors` when request is succesful', () => {
wrapper = createComponent();
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors);
wrapper.vm.fetchAuthorBySearchTerm('root');
......@@ -112,6 +130,8 @@ describe('AuthorToken', () => {
});
it('calls `createFlash` with flash error message when request fails', () => {
wrapper = createComponent();
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
wrapper.vm.fetchAuthorBySearchTerm('root');
......@@ -122,6 +142,8 @@ describe('AuthorToken', () => {
});
it('sets `loading` to false when request completes', () => {
wrapper = createComponent();
jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({});
wrapper.vm.fetchAuthorBySearchTerm('root');
......@@ -133,21 +155,16 @@ describe('AuthorToken', () => {
});
describe('template', () => {
beforeEach(() => {
wrapper.setData({
authors: mockAuthors,
});
return wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
wrapper = createComponent({ data: { authors: mockAuthors } });
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
});
it('renders token item when value is selected', () => {
wrapper.setProps({
wrapper = createComponent({
value: { data: mockAuthors[0].username },
data: { authors: mockAuthors },
});
return wrapper.vm.$nextTick(() => {
......@@ -172,7 +189,7 @@ describe('AuthorToken', () => {
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(defaultAuthors.length);
expect(suggestions).toHaveLength(defaultAuthors.length + currentUserLength);
defaultAuthors.forEach((label, index) => {
expect(suggestions.at(index).text()).toBe(label.text);
});
......@@ -189,7 +206,6 @@ describe('AuthorToken', () => {
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false);
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
......@@ -206,8 +222,28 @@ describe('AuthorToken', () => {
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(1);
expect(suggestions).toHaveLength(1 + currentUserLength);
expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text);
});
describe('when loading', () => {
beforeEach(() => {
wrapper = createComponent({
active: true,
config: { ...mockAuthorToken, defaultAuthors: [] },
stubs: { Portal: true },
});
});
it('shows loading icon', () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
});
it('shows current user', () => {
const firstSuggestion = wrapper.findComponent(GlFilteredSearchSuggestion).text();
expect(firstSuggestion).toContain('Administrator');
expect(firstSuggestion).toContain('@root');
});
});
});
});
......@@ -91,7 +91,7 @@ describe('IssueAssigneesComponent', () => {
});
it('computes alt text for assignee avatar', () => {
expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Assigned to Terrell Graham');
});
it('renders component root element with class `issue-assignees`', () => {
......@@ -106,7 +106,7 @@ describe('IssueAssigneesComponent', () => {
const expected = mockAssigneesList.slice(0, TEST_MAX_VISIBLE - 1).map((x) =>
expect.objectContaining({
linkHref: x.web_url,
imgAlt: `Avatar for ${x.name}`,
imgAlt: `Assigned to ${x.name}`,
imgCssClasses: TEST_CSS_CLASSES,
imgSrc: x.avatar_url,
imgSize: TEST_ICON_SIZE,
......
......@@ -304,7 +304,7 @@ RSpec.describe IssuesHelper do
empty_state_svg_path: '#',
endpoint: expose_path(api_v4_projects_issues_path(id: project.id)),
export_csv_path: export_csv_project_issues_path(project),
has_issues: project_issues(project).exists?.to_s,
has_project_issues: project_issues(project).exists?.to_s,
import_csv_issues_path: '#',
initial_email: project.new_issuable_address(current_user, 'issue'),
is_signed_in: current_user.present?.to_s,
......
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