Commit ec0d8e14 authored by Coung Ngo's avatar Coung Ngo Committed by Alex Pooley

Add new issue split dropdown to group issues list refactor

parent dab58c88
......@@ -82,6 +82,7 @@ import searchLabelsQuery from '../queries/search_labels.query.graphql';
import searchMilestonesQuery from '../queries/search_milestones.query.graphql';
import searchUsersQuery from '../queries/search_users.query.graphql';
import IssueCardTimeInfo from './issue_card_time_info.vue';
import NewIssueDropdown from './new_issue_dropdown.vue';
export default {
i18n,
......@@ -96,6 +97,7 @@ export default {
IssuableByEmail,
IssuableList,
IssueCardTimeInfo,
NewIssueDropdown,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
},
directives: {
......@@ -126,6 +128,9 @@ export default {
hasAnyIssues: {
default: false,
},
hasAnyProjects: {
default: false,
},
hasBlockedIssuesFeature: {
default: false,
},
......@@ -253,6 +258,9 @@ export default {
showCsvButtons() {
return this.isProject && this.isSignedIn;
},
showNewIssueDropdown() {
return !this.isProject && this.hasAnyProjects;
},
apiFilterParams() {
return convertToApiParams(this.filterTokens);
},
......@@ -662,6 +670,7 @@ export default {
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
<new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
<template #timeframe="{ issuable = {} }">
......@@ -765,6 +774,7 @@ export default {
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="currentTabCount"
/>
<new-issue-dropdown v-if="showNewIssueDropdown" />
</template>
</gl-empty-state>
<hr />
......
<script>
import {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlLoadingIcon,
GlSearchBoxByType,
} from '@gitlab/ui';
import createFlash from '~/flash';
import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import { __, sprintf } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
export default {
i18n: {
defaultDropdownText: __('Select project to create issue'),
noMatchesFound: __('No matches found'),
toggleButtonLabel: __('Toggle project select'),
},
components: {
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlLoadingIcon,
GlSearchBoxByType,
},
inject: ['fullPath'],
data() {
return {
projects: [],
search: '',
selectedProject: {},
shouldSkipQuery: true,
};
},
apollo: {
projects: {
query: searchProjectsQuery,
variables() {
return {
fullPath: this.fullPath,
search: this.search,
};
},
update: ({ group }) => group.projects.nodes ?? [],
error(error) {
createFlash({
message: __('An error occurred while loading projects.'),
captureError: true,
error,
});
},
skip() {
return this.shouldSkipQuery;
},
debounce: DEBOUNCE_DELAY,
},
},
computed: {
dropdownHref() {
return this.hasSelectedProject
? joinPaths(this.selectedProject.webUrl, DASH_SCOPE, 'issues/new')
: undefined;
},
dropdownText() {
return this.hasSelectedProject
? sprintf(__('New issue in %{project}'), { project: this.selectedProject.name })
: this.$options.i18n.defaultDropdownText;
},
hasSelectedProject() {
return this.selectedProject.id;
},
showNoSearchResultsText() {
return !this.projects.length && this.search;
},
},
methods: {
handleDropdownClick() {
if (!this.dropdownHref) {
this.$refs.dropdown.show();
}
},
handleDropdownShown() {
if (this.shouldSkipQuery) {
this.shouldSkipQuery = false;
}
this.$refs.search.focusInput();
},
selectProject(project) {
this.selectedProject = project;
},
},
};
</script>
<template>
<gl-dropdown
ref="dropdown"
right
split
:split-href="dropdownHref"
:text="dropdownText"
:toggle-text="$options.i18n.toggleButtonLabel"
variant="confirm"
@click="handleDropdownClick"
@shown="handleDropdownShown"
>
<gl-search-box-by-type ref="search" v-model.trim="search" />
<gl-loading-icon v-if="$apollo.queries.projects.loading" />
<template v-else>
<gl-dropdown-item
v-for="project of projects"
:key="project.id"
@click="selectProject(project)"
>
{{ project.nameWithNamespace }}
</gl-dropdown-item>
<gl-dropdown-text v-if="showNoSearchResultsText">
{{ $options.i18n.noMatchesFound }}
</gl-dropdown-text>
</template>
</gl-dropdown>
</template>
......@@ -121,6 +121,7 @@ export function mountIssuesListApp() {
fullPath,
groupEpicsPath,
hasAnyIssues,
hasAnyProjects,
hasBlockedIssuesFeature,
hasIssuableHealthStatusFeature,
hasIssueWeightsFeature,
......@@ -153,6 +154,7 @@ export function mountIssuesListApp() {
fullPath,
groupEpicsPath,
hasAnyIssues: parseBoolean(hasAnyIssues),
hasAnyProjects: parseBoolean(hasAnyProjects),
hasBlockedIssuesFeature: parseBoolean(hasBlockedIssuesFeature),
hasIssuableHealthStatusFeature: parseBoolean(hasIssuableHealthStatusFeature),
hasIssueWeightsFeature: parseBoolean(hasIssueWeightsFeature),
......
query searchProjects($fullPath: ID!, $search: String) {
group(fullPath: $fullPath) {
projects(search: $search, includeSubgroups: true) {
nodes {
id
name
nameWithNamespace
webUrl
}
}
}
}
export const DASH_SCOPE = '-';
const PATH_SEPARATOR = '/';
const PATH_SEPARATOR_LEADING_REGEX = new RegExp(`^${PATH_SEPARATOR}+`);
const PATH_SEPARATOR_ENDING_REGEX = new RegExp(`${PATH_SEPARATOR}+$`);
......
......@@ -322,7 +322,7 @@
display: inline-block;
}
.btn {
.btn:not(.split-content-button):not(.dropdown-toggle-split) {
margin: $gl-padding-8 $gl-padding-4;
@include media-breakpoint-down(xs) {
......
......@@ -238,9 +238,10 @@ module IssuesHelper
)
end
def group_issues_list_data(group, current_user, issues)
def group_issues_list_data(group, current_user, issues, projects)
common_issues_list_data(group, current_user).merge(
has_any_issues: issues.to_a.any?.to_s
has_any_issues: issues.to_a.any?.to_s,
has_any_projects: any_projects?(projects).to_s
)
end
......
......@@ -6,7 +6,7 @@
= auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues")
- if Feature.enabled?(:vue_issues_list, @group, default_enabled: :yaml)
.js-issues-list{ data: group_issues_list_data(@group, current_user, @issues) }
.js-issues-list{ data: group_issues_list_data(@group, current_user, @issues, @projects) }
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
- else
......
......@@ -63,7 +63,7 @@ module EE
end
override :group_issues_list_data
def group_issues_list_data(group, current_user, issues)
def group_issues_list_data(group, current_user, issues, projects)
super.tap do |data|
data[:can_bulk_update] = (can?(current_user, :admin_issue, group) && group.feature_available?(:group_bulk_edit)).to_s
......
......@@ -187,6 +187,7 @@ RSpec.describe EE::IssuesHelper do
describe '#group_issues_list_data' do
let(:current_user) { double.as_null_object }
let(:issues) { [] }
let(:projects) { [] }
before do
allow(helper).to receive(:current_user).and_return(current_user)
......@@ -210,7 +211,7 @@ RSpec.describe EE::IssuesHelper do
group_epics_path: group_epics_path(project.group, format: :json)
}
expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected)
expect(helper.group_issues_list_data(group, current_user, issues, projects)).to include(expected)
end
end
......@@ -229,7 +230,7 @@ RSpec.describe EE::IssuesHelper do
has_multiple_issue_assignees_feature: 'false'
}
result = helper.group_issues_list_data(group, current_user, issues)
result = helper.group_issues_list_data(group, current_user, issues, projects)
expect(result).to include(expected)
expect(result).not_to include(:group_epics_path)
......
......@@ -3703,6 +3703,9 @@ msgstr ""
msgid "An error occurred while loading merge requests."
msgstr ""
msgid "An error occurred while loading projects."
msgstr ""
msgid "An error occurred while loading the Needs tab."
msgstr ""
......@@ -22660,6 +22663,9 @@ msgstr ""
msgid "New issue"
msgstr ""
msgid "New issue in %{project}"
msgstr ""
msgid "New issue title"
msgstr ""
......@@ -30496,6 +30502,9 @@ msgstr ""
msgid "Select project to choose zone"
msgstr ""
msgid "Select project to create issue"
msgstr ""
msgid "Select projects"
msgstr ""
......
......@@ -24,6 +24,7 @@ import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
import { IssuableListTabs, IssuableStates } from '~/issuable_list/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue';
import {
CREATED_DESC,
DUE_DATE_OVERDUE,
......@@ -65,6 +66,7 @@ describe('IssuesListApp component', () => {
exportCsvPath: 'export/csv/path',
fullPath: 'path/to/project',
hasAnyIssues: true,
hasAnyProjects: true,
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
......@@ -93,6 +95,7 @@ describe('IssuesListApp component', () => {
const findGlEmptyState = () => wrapper.findComponent(GlEmptyState);
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown);
const mountComponent = ({
provide = {},
......@@ -190,10 +193,7 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
setWindowLocation(search);
wrapper = mountComponent({
provide: { isSignedIn: true },
mountFn: mount,
});
wrapper = mountComponent({ provide: { isSignedIn: true }, mountFn: mount });
jest.runOnlyPendingTimers();
});
......@@ -208,7 +208,7 @@ describe('IssuesListApp component', () => {
describe('when user is not signed in', () => {
it('does not render', () => {
wrapper = mountComponent({ provide: { isSignedIn: false } });
wrapper = mountComponent({ provide: { isSignedIn: false }, mountFn: mount });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
......@@ -216,7 +216,7 @@ describe('IssuesListApp component', () => {
describe('when in a group context', () => {
it('does not render', () => {
wrapper = mountComponent({ provide: { isProject: false } });
wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
expect(findCsvImportExportButtons().exists()).toBe(false);
});
......@@ -231,7 +231,7 @@ describe('IssuesListApp component', () => {
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: false } });
wrapper = mountComponent({ provide: { canBulkUpdate: false }, mountFn: mount });
expect(findGlButtons().filter((button) => button.text() === 'Edit issues')).toHaveLength(0);
});
......@@ -258,11 +258,25 @@ describe('IssuesListApp component', () => {
});
it('does not render when user does not have permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: false } });
wrapper = mountComponent({ provide: { showNewIssueLink: false }, mountFn: mount });
expect(findGlButtons().filter((button) => button.text() === 'New issue')).toHaveLength(0);
});
});
describe('new issue split dropdown', () => {
it('does not render in a project context', () => {
wrapper = mountComponent({ provide: { isProject: true }, mountFn: mount });
expect(findNewIssueDropdown().exists()).toBe(false);
});
it('renders in a group context', () => {
wrapper = mountComponent({ provide: { isProject: false }, mountFn: mount });
expect(findNewIssueDropdown().exists()).toBe(true);
});
});
});
describe('initial url params', () => {
......
import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui';
import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import NewIssueDropdown from '~/issues_list/components/new_issue_dropdown.vue';
import searchProjectsQuery from '~/issues_list/queries/search_projects.query.graphql';
import { DASH_SCOPE, joinPaths } from '~/lib/utils/url_utility';
import {
emptySearchProjectsQueryResponse,
project1,
project2,
searchProjectsQueryResponse,
} from '../mock_data';
describe('NewIssueDropdown component', () => {
let wrapper;
const localVue = createLocalVue();
localVue.use(VueApollo);
const mountComponent = ({
search = '',
queryResponse = searchProjectsQueryResponse,
mountFn = shallowMount,
} = {}) => {
const requestHandlers = [[searchProjectsQuery, jest.fn().mockResolvedValue(queryResponse)]];
const apolloProvider = createMockApollo(requestHandlers);
return mountFn(NewIssueDropdown, {
localVue,
apolloProvider,
provide: {
fullPath: 'mushroom-kingdom',
},
data() {
return { search };
},
});
};
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findInput = () => wrapper.findComponent(GlSearchBoxByType);
const showDropdown = async () => {
findDropdown().vm.$emit('shown');
await wrapper.vm.$apollo.queries.projects.refetch();
jest.runOnlyPendingTimers();
};
afterEach(() => {
wrapper.destroy();
});
it('renders a split dropdown', () => {
wrapper = mountComponent();
expect(findDropdown().props('split')).toBe(true);
});
it('renders a label for the dropdown toggle button', () => {
wrapper = mountComponent();
expect(findDropdown().attributes('toggle-text')).toBe(NewIssueDropdown.i18n.toggleButtonLabel);
});
it('focuses on input when dropdown is shown', async () => {
wrapper = mountComponent({ mountFn: mount });
const inputSpy = jest.spyOn(findInput().vm, 'focusInput');
await showDropdown();
expect(inputSpy).toHaveBeenCalledTimes(1);
});
it('renders expected dropdown items', async () => {
wrapper = mountComponent({ mountFn: mount });
await showDropdown();
const listItems = wrapper.findAll('li');
expect(listItems.at(0).text()).toBe(project1.nameWithNamespace);
expect(listItems.at(1).text()).toBe(project2.nameWithNamespace);
});
it('renders `No matches found` when there are no matches', async () => {
wrapper = mountComponent({
search: 'no matches',
queryResponse: emptySearchProjectsQueryResponse,
mountFn: mount,
});
await showDropdown();
expect(wrapper.find('li').text()).toBe(NewIssueDropdown.i18n.noMatchesFound);
});
describe('when no project is selected', () => {
beforeEach(() => {
wrapper = mountComponent();
});
it('dropdown button is not a link', () => {
expect(findDropdown().attributes('split-href')).toBeUndefined();
});
it('displays default text on the dropdown button', () => {
expect(findDropdown().props('text')).toBe(NewIssueDropdown.i18n.defaultDropdownText);
});
});
describe('when a project is selected', () => {
beforeEach(async () => {
wrapper = mountComponent({ mountFn: mount });
await showDropdown();
wrapper.findComponent(GlDropdownItem).vm.$emit('click', project1);
});
it('dropdown button is a link', () => {
const href = joinPaths(project1.webUrl, DASH_SCOPE, 'issues/new');
expect(findDropdown().attributes('split-href')).toBe(href);
});
it('displays project name on the dropdown button', () => {
expect(findDropdown().props('text')).toBe(`New issue in ${project1.name}`);
});
});
});
......@@ -221,3 +221,37 @@ export const urlParamsWithSpecialValues = {
epic_id: 'None',
weight: 'None',
};
export const project1 = {
id: 'gid://gitlab/Group/26',
name: 'Super Mario Project',
nameWithNamespace: 'Mushroom Kingdom / Super Mario Project',
webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/super-mario-project',
};
export const project2 = {
id: 'gid://gitlab/Group/59',
name: 'Mario Kart Project',
nameWithNamespace: 'Mushroom Kingdom / Mario Kart Project',
webUrl: 'https://127.0.0.1:3000/mushroom-kingdom/mario-kart-project',
};
export const searchProjectsQueryResponse = {
data: {
group: {
projects: {
nodes: [project1, project2],
},
},
},
};
export const emptySearchProjectsQueryResponse = {
data: {
group: {
projects: {
nodes: [],
},
},
},
};
......@@ -354,6 +354,7 @@ RSpec.describe IssuesHelper do
let(:group) { create(:group) }
let(:current_user) { double.as_null_object }
let(:issues) { [] }
let(:projects) { [] }
it 'returns expected result' do
allow(helper).to receive(:current_user).and_return(current_user)
......@@ -367,13 +368,14 @@ RSpec.describe IssuesHelper do
empty_state_svg_path: '#',
full_path: group.full_path,
has_any_issues: issues.to_a.any?.to_s,
has_any_projects: any_projects?(projects).to_s,
is_signed_in: current_user.present?.to_s,
jira_integration_path: help_page_url('integration/jira/issues', anchor: 'view-jira-issues'),
rss_path: '#',
sign_in_path: new_user_session_path
}
expect(helper.group_issues_list_data(group, current_user, issues)).to include(expected)
expect(helper.group_issues_list_data(group, current_user, issues, projects)).to include(expected)
end
end
......
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