Commit 423c5f60 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '322755-add-email-issue-to-project' into 'master'

Add email issue to project modal to issues page refactor

See merge request gitlab-org/gitlab!60430
parents feda19e6 8a2b26e6
......@@ -12,6 +12,7 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
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 {
......@@ -54,6 +55,7 @@ export default {
GlIcon,
GlLink,
GlSprintf,
IssuableByEmail,
IssuableList,
IssueCardTimeInfo,
BlockingIssuesCount: () => import('ee_component/issues/components/blocking_issues_count.vue'),
......@@ -86,6 +88,9 @@ export default {
hasIssues: {
default: false,
},
initialEmail: {
default: '',
},
isSignedIn: {
default: false,
},
......@@ -376,143 +381,146 @@ export default {
</script>
<template>
<issuable-list
v-if="hasIssues"
:namespace="projectPath"
recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')"
:search-tokens="searchTokens"
:initial-filter-value="filterTokens"
:sort-options="$options.sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
:total-items="totalIssues"
:current-page="page"
:previous-page="page - 1"
:next-page="page + 1"
:url-params="urlParams"
@click-tab="handleClickTab"
@filter="handleFilter"
@page-change="handlePageChange"
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
>
<template #nav-actions>
<gl-button
v-gl-tooltip
:href="rssPath"
icon="rss"
:title="$options.i18n.rssLabel"
:aria-label="$options.i18n.rssLabel"
/>
<gl-button
v-gl-tooltip
:href="calendarPath"
icon="calendar"
:title="$options.i18n.calendarLabel"
:aria-label="$options.i18n.calendarLabel"
/>
<csv-import-export-buttons
class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="totalIssues"
/>
<gl-button
v-if="canBulkUpdate"
:disabled="showBulkEditSidebar"
@click="handleBulkUpdateClick"
>
{{ __('Edit issues') }}
</gl-button>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
<div v-if="hasIssues">
<issuable-list
:namespace="projectPath"
recent-searches-storage-key="issues"
:search-input-placeholder="__('Search or filter results…')"
:search-tokens="searchTokens"
:initial-filter-value="filterTokens"
:sort-options="$options.sortOptions"
:initial-sort-by="sortKey"
:issuables="issues"
:tabs="$options.IssuableListTabs"
:current-tab="state"
:tab-counts="tabCounts"
:issuables-loading="isLoading"
:is-manual-ordering="isManualOrdering"
:show-bulk-edit-sidebar="showBulkEditSidebar"
:show-pagination-controls="showPaginationControls"
:total-items="totalIssues"
:current-page="page"
:previous-page="page - 1"
:next-page="page + 1"
:url-params="urlParams"
@click-tab="handleClickTab"
@filter="handleFilter"
@page-change="handlePageChange"
@reorder="handleReorder"
@sort="handleSort"
@update-legacy-bulk-edit="handleUpdateLegacyBulkEdit"
>
<template #nav-actions>
<gl-button
v-gl-tooltip
:href="rssPath"
icon="rss"
:title="$options.i18n.rssLabel"
:aria-label="$options.i18n.rssLabel"
/>
<gl-button
v-gl-tooltip
:href="calendarPath"
icon="calendar"
:title="$options.i18n.calendarLabel"
:aria-label="$options.i18n.calendarLabel"
/>
<csv-import-export-buttons
class="gl-mr-3"
:export-csv-path="exportCsvPathWithQuery"
:issuable-count="totalIssues"
/>
<gl-button
v-if="canBulkUpdate"
:disabled="showBulkEditSidebar"
@click="handleBulkUpdateClick"
>
{{ __('Edit issues') }}
</gl-button>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
<template #timeframe="{ issuable = {} }">
<issue-card-time-info :issue="issuable" />
</template>
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Related merge requests')"
data-testid="issuable-mr"
>
<gl-icon name="merge-request" />
{{ issuable.mergeRequestsCount }}
</li>
<li
v-if="issuable.upvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Upvotes')"
data-testid="issuable-upvotes"
>
<gl-icon name="thumb-up" />
{{ issuable.upvotes }}
</li>
<li
v-if="issuable.downvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Downvotes')"
data-testid="issuable-downvotes"
>
<gl-icon name="thumb-down" />
{{ issuable.downvotes }}
</li>
<blocking-issues-count
class="gl-display-none gl-sm-display-block"
:blocking-issues-count="issuable.blockingIssuesCount"
:is-list-item="true"
/>
</template>
<template #statistics="{ issuable = {} }">
<li
v-if="issuable.mergeRequestsCount"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Related merge requests')"
data-testid="issuable-mr"
>
<gl-icon name="merge-request" />
{{ issuable.mergeRequestsCount }}
</li>
<li
v-if="issuable.upvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Upvotes')"
data-testid="issuable-upvotes"
>
<gl-icon name="thumb-up" />
{{ issuable.upvotes }}
</li>
<li
v-if="issuable.downvotes"
v-gl-tooltip
class="gl-display-none gl-sm-display-block"
:title="__('Downvotes')"
data-testid="issuable-downvotes"
>
<gl-icon name="thumb-down" />
{{ issuable.downvotes }}
</li>
<blocking-issues-count
class="gl-display-none gl-sm-display-block"
:blocking-issues-count="issuable.blockingIssuesCount"
:is-list-item="true"
/>
</template>
<template #empty-state>
<gl-empty-state
v-if="searchQuery"
:description="$options.i18n.noSearchResultsDescription"
:title="$options.i18n.noSearchResultsTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<template #empty-state>
<gl-empty-state
v-if="searchQuery"
:description="$options.i18n.noSearchResultsDescription"
:title="$options.i18n.noSearchResultsTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<gl-empty-state
v-else-if="isOpenTab"
:description="$options.i18n.noOpenIssuesDescription"
:title="$options.i18n.noOpenIssuesTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<gl-empty-state
v-else-if="isOpenTab"
:description="$options.i18n.noOpenIssuesDescription"
:title="$options.i18n.noOpenIssuesTitle"
:svg-path="emptyStateSvgPath"
>
<template #actions>
<gl-button v-if="showNewIssueLink" :href="newIssuePath" variant="confirm">
{{ $options.i18n.newIssueLabel }}
</gl-button>
</template>
</gl-empty-state>
<gl-empty-state
v-else
:title="$options.i18n.noClosedIssuesTitle"
:svg-path="emptyStateSvgPath"
/>
</template>
</issuable-list>
<gl-empty-state
v-else
:title="$options.i18n.noClosedIssuesTitle"
:svg-path="emptyStateSvgPath"
/>
</template>
</issuable-list>
<issuable-by-email v-if="initialEmail" class="gl-text-center gl-pt-5 gl-pb-7" />
</div>
<div v-else-if="isSignedIn">
<gl-empty-state
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { IssuableType } from '~/issue_show/constants';
import IssuesListApp from '~/issues_list/components/issues_list_app.vue';
import createDefaultClient from '~/lib/graphql';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
......@@ -80,6 +81,7 @@ export function initIssuesListApp() {
canEdit,
canImportIssues,
email,
emailsHelpPagePath,
emptyStateSvgPath,
endpoint,
exportCsvPath,
......@@ -88,15 +90,19 @@ export function initIssuesListApp() {
hasIssues,
hasIssueWeightsFeature,
importCsvIssuesPath,
initialEmail,
isSignedIn,
issuesPath,
jiraIntegrationPath,
markdownHelpPath,
maxAttachmentSize,
newIssuePath,
projectImportJiraPath,
projectLabelsPath,
projectMilestonesPath,
projectPath,
quickActionsHelpPath,
resetPath,
rssPath,
showNewIssueLink,
signInPath,
......@@ -138,6 +144,13 @@ export function initIssuesListApp() {
showExportButton: parseBoolean(hasIssues),
showImportButton: parseBoolean(canImportIssues),
showLabel: !parseBoolean(hasIssues),
// For IssuableByEmail component
emailsHelpPagePath,
initialEmail,
issuableType: IssuableType.Issue,
markdownHelpPath,
quickActionsHelpPath,
resetPath,
},
render: (createComponent) => createComponent(IssuesListApp),
});
......
......@@ -1032,11 +1032,6 @@ pre.light-well {
}
}
.issuable-footer {
padding-top: $gl-padding;
padding-bottom: 37px;
}
.project-ci-linter {
.ci-editor {
height: 400px;
......
......@@ -172,20 +172,25 @@ module IssuesHelper
can_edit: can?(current_user, :admin_project, project).to_s,
can_import_issues: can?(current_user, :import_issues, @project).to_s,
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
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,
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,
issues_path: project_issues_path(project),
jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_milestones_path: project_milestones_path(project, format: :json),
project_path: project.full_path,
quick_actions_help_path: help_page_path('user/project/quick_actions'),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
rss_path: url_for(safe_params.merge(rss_url_options)),
show_new_issue_link: show_new_issue_link?(project).to_s,
sign_in_path: new_user_session_path
......
......@@ -29,7 +29,7 @@
.issues-holder
= render 'issues'
- if new_issue_email
.issuable-footer.text-center
.gl-text-center.gl-pt-5.gl-pb-7
.js-issueable-by-email{ data: { initial_email: new_issue_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
- new_project_issue_button_path = @project.archived? ? false : new_project_issue_path(@project)
......
......@@ -22,7 +22,7 @@
.merge-requests-holder
= render 'merge_requests'
- if new_merge_request_email
.issuable-footer.text-center
.gl-text-center.gl-pt-5.gl-pb-7
.js-issueable-by-email{ data: { initial_email: new_merge_request_email, issuable_type: issuable_type, emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'), quick_actions_help_path: help_page_path('user/project/quick_actions'), markdown_help_path: help_page_path('user/markdown'), reset_path: new_issuable_address_project_path(@project, issuable_type: issuable_type) } }
- else
= render 'shared/empty_states/merge_requests', button_path: new_merge_request_path
......@@ -6,6 +6,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
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';
......@@ -24,7 +25,6 @@ import { setUrlParams } from '~/lib/utils/url_utility';
jest.mock('~/flash');
describe('IssuesListApp component', () => {
const originalWindowLocation = window.location;
let axiosMock;
let wrapper;
......@@ -65,6 +65,7 @@ describe('IssuesListApp component', () => {
};
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
const findGlButton = () => wrapper.findComponent(GlButton);
const findGlButtons = () => wrapper.findAllComponents(GlButton);
const findGlButtonAt = (index) => findGlButtons().at(index);
......@@ -88,7 +89,7 @@ describe('IssuesListApp component', () => {
});
afterEach(() => {
window.location = originalWindowLocation;
global.jsdom.reconfigure({ url: TEST_HOST });
axiosMock.reset();
wrapper.destroy();
});
......@@ -122,34 +123,31 @@ describe('IssuesListApp component', () => {
describe('header action buttons', () => {
it('renders rss button', () => {
wrapper = mountComponent();
wrapper = mountComponent({ mountFn: mount });
expect(findGlButtonAt(0).props('icon')).toBe('rss');
expect(findGlButtonAt(0).attributes()).toMatchObject({
href: defaultProvide.rssPath,
icon: 'rss',
'aria-label': IssuesListApp.i18n.rssLabel,
});
});
it('renders calendar button', () => {
wrapper = mountComponent();
wrapper = mountComponent({ mountFn: mount });
expect(findGlButtonAt(1).props('icon')).toBe('calendar');
expect(findGlButtonAt(1).attributes()).toMatchObject({
href: defaultProvide.calendarPath,
icon: 'calendar',
'aria-label': IssuesListApp.i18n.calendarLabel,
});
});
it('renders csv import/export component', async () => {
const search = '?page=1&search=refactor';
const search = '?page=1&search=refactor&state=opened&order_by=created_at&sort=desc';
Object.defineProperty(window, 'location', {
writable: true,
value: { search },
});
global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` });
wrapper = mountComponent();
wrapper = mountComponent({ mountFn: mount });
await waitForPromises();
......@@ -161,7 +159,7 @@ describe('IssuesListApp component', () => {
describe('bulk edit button', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true } });
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
expect(findGlButtonAt(2).text()).toBe('Edit issues');
});
......@@ -173,7 +171,7 @@ describe('IssuesListApp component', () => {
});
it('emits "issuables:enableBulkEdit" event to legacy bulk edit class', () => {
wrapper = mountComponent({ provide: { canBulkUpdate: true } });
wrapper = mountComponent({ provide: { canBulkUpdate: true }, mountFn: mount });
jest.spyOn(eventHub, '$emit');
......@@ -185,7 +183,7 @@ describe('IssuesListApp component', () => {
describe('new issue button', () => {
it('renders when user has permissions', () => {
wrapper = mountComponent({ provide: { showNewIssueLink: true } });
wrapper = mountComponent({ provide: { showNewIssueLink: true }, mountFn: mount });
expect(findGlButtonAt(2).text()).toBe('New issue');
expect(findGlButtonAt(2).attributes('href')).toBe(defaultProvide.newIssuePath);
......@@ -204,10 +202,7 @@ describe('IssuesListApp component', () => {
it('is set from the url params', () => {
const page = 5;
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams({ page }, TEST_HOST) },
});
global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) });
wrapper = mountComponent();
......@@ -217,10 +212,7 @@ describe('IssuesListApp component', () => {
describe('search', () => {
it('is set from the url params', () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { search: locationSearch },
});
global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
wrapper = mountComponent();
......@@ -230,10 +222,7 @@ describe('IssuesListApp component', () => {
describe('sort', () => {
it.each(Object.keys(sortParams))('is set as %s from the url params', (sortKey) => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams(sortParams[sortKey], TEST_HOST) },
});
global.jsdom.reconfigure({ url: setUrlParams(sortParams[sortKey], TEST_HOST) });
wrapper = mountComponent();
......@@ -248,10 +237,7 @@ describe('IssuesListApp component', () => {
it('is set from the url params', () => {
const initialState = IssuableStates.All;
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams({ state: initialState }, TEST_HOST) },
});
global.jsdom.reconfigure({ url: setUrlParams({ state: initialState }, TEST_HOST) });
wrapper = mountComponent();
......@@ -261,10 +247,7 @@ describe('IssuesListApp component', () => {
describe('filter tokens', () => {
it('is set from the url params', () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { search: locationSearch },
});
global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
wrapper = mountComponent();
......@@ -290,16 +273,25 @@ describe('IssuesListApp component', () => {
);
});
describe('IssuableByEmail component', () => {
describe.each([true, false])(`when issue creation by email is enabled=%s`, (enabled) => {
it(`${enabled ? 'renders' : 'does not render'}`, () => {
wrapper = mountComponent({ provide: { initialEmail: enabled } });
expect(findIssuableByEmail().exists()).toBe(enabled);
});
});
});
describe('empty states', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
writable: true,
value: { search: '?search=no+results' },
});
beforeEach(async () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount });
wrapper = mountComponent({ provide: { hasIssues: true } });
await waitForPromises();
});
it('shows empty state', () => {
......@@ -312,8 +304,10 @@ describe('IssuesListApp component', () => {
});
describe('when "Open" tab has no issues', () => {
beforeEach(() => {
wrapper = mountComponent({ provide: { hasIssues: true } });
beforeEach(async () => {
wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
......@@ -327,12 +321,13 @@ describe('IssuesListApp component', () => {
describe('when "Closed" tab has no issues', () => {
beforeEach(async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: { href: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST) },
global.jsdom.reconfigure({
url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
});
wrapper = mountComponent({ provide: { hasIssues: true } });
wrapper = mountComponent({ provide: { hasIssues: true }, mountFn: mount });
await waitForPromises();
});
it('shows empty state', () => {
......
......@@ -300,20 +300,25 @@ RSpec.describe IssuesHelper do
can_edit: 'true',
can_import_issues: 'true',
email: current_user&.notification_email,
emails_help_page_path: help_page_path('development/emails', anchor: 'email-namespace'),
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,
import_csv_issues_path: '#',
initial_email: project.new_issuable_address(current_user, 'issue'),
is_signed_in: current_user.present?.to_s,
issues_path: project_issues_path(project),
jira_integration_path: help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues'),
markdown_help_path: help_page_path('user/markdown'),
max_attachment_size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes),
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }),
project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_milestones_path: project_milestones_path(project, format: :json),
project_path: project.full_path,
quick_actions_help_path: help_page_path('user/project/quick_actions'),
reset_path: new_issuable_address_project_path(project, issuable_type: 'issue'),
rss_path: '#',
show_new_issue_link: 'true',
sign_in_path: new_user_session_path
......
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