Commit 1073f51a authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '233974-update-issue-header-actions-ui' into 'master'

Update issue header actions UI

See merge request gitlab-org/gitlab!44440
parents ff44ac74 0770fb5f
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import createFlash from '~/flash';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import { __ } from '~/locale';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
GlLink,
GlModal,
},
actionCancel: {
text: __('Cancel'),
},
actionPrimary: {
text: __('Yes, close issue'),
attributes: [{ variant: 'warning' }],
},
inject: [
'canCreateIssue',
'canReopenIssue',
'canReportSpam',
'canUpdateIssue',
'iid',
'isIssueAuthor',
'newIssuePath',
'projectPath',
'reportAbusePath',
'submitAsSpamPath',
],
data() {
return {
isUpdatingState: false,
};
},
computed: {
...mapGetters(['getNoteableData']),
isClosed() {
return this.getNoteableData.state === IssuableStatus.Closed;
},
buttonText() {
return this.isClosed ? __('Reopen issue') : __('Close issue');
},
buttonVariant() {
return this.isClosed ? 'default' : 'warning';
},
showToggleIssueButton() {
const canClose = !this.isClosed && this.canUpdateIssue;
const canReopen = this.isClosed && this.canReopenIssue;
return canClose || canReopen;
},
},
methods: {
toggleIssueState() {
if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) {
this.$refs.blockedByIssuesModal.show();
return;
}
this.invokeUpdateIssueMutation();
},
invokeUpdateIssueMutation() {
this.isUpdatingState = true;
this.$apollo
.mutate({
mutation: updateIssueMutation,
variables: {
input: {
iid: this.iid.toString(),
projectPath: this.projectPath,
stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close,
},
},
})
.then(({ data }) => {
if (data.updateIssue.errors.length) {
createFlash(data.updateIssue.errors.join('. '));
return;
}
const payload = {
detail: {
data: { id: this.iid },
isClosed: !this.isClosed,
},
};
// Dispatch event which updates open/close state, shared among the issue show page
document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload));
})
.catch(() => createFlash(__('Update failed. Please try again.')))
.finally(() => {
this.isUpdatingState = false;
});
},
},
};
</script>
<template>
<div class="detail-page-header-actions">
<gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="__('Issue actions')">
<gl-dropdown-item
v-if="showToggleIssueButton"
:disabled="isUpdatingState"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ __('New issue') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
data-method="post"
rel="nofollow"
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
</gl-dropdown>
<gl-button
v-if="showToggleIssueButton"
class="gl-display-none gl-display-sm-inline-flex!"
category="secondary"
:loading="isUpdatingState"
:variant="buttonVariant"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-button>
<gl-dropdown
class="gl-display-none gl-display-sm-inline-flex!"
toggle-class="gl-border-0! gl-shadow-none!"
no-caret
right
>
<template #button-content>
<gl-icon name="ellipsis_v" aria-hidden="true" />
<span class="gl-sr-only">{{ __('Actions') }}</span>
</template>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ __('New issue') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
data-method="post"
rel="nofollow"
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
</gl-dropdown>
<gl-modal
ref="blockedByIssuesModal"
modal-id="blocked-by-issues-modal"
:action-cancel="$options.actionCancel"
:action-primary="$options.actionPrimary"
:title="__('Are you sure you want to close this blocked issue?')"
@primary="invokeUpdateIssueMutation"
>
<p>{{ __('This issue is currently blocked by the following issues:') }}</p>
<ul>
<li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid">
<gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link>
</li>
</ul>
</gl-modal>
</div>
</template>
...@@ -18,5 +18,10 @@ export const IssuableType = { ...@@ -18,5 +18,10 @@ export const IssuableType = {
MergeRequest: 'merge_request', MergeRequest: 'merge_request',
}; };
export const IssueStateEvent = {
Close: 'CLOSE',
Reopen: 'REOPEN',
};
export const STATUS_PAGE_PUBLISHED = __('Published on status page'); export const STATUS_PAGE_PUBLISHED = __('Published on status page');
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting'); export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableApp from './components/app.vue'; import IssuableApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue';
export default function initIssuableApp(issuableData, store) { export function initIssuableApp(issuableData, store) {
return new Vue({ return new Vue({
el: document.getElementById('js-issuable-app'), el: document.getElementById('js-issuable-app'),
store, store,
...@@ -19,3 +23,36 @@ export default function initIssuableApp(issuableData, store) { ...@@ -19,3 +23,36 @@ export default function initIssuableApp(issuableData, store) {
}, },
}); });
} }
export function initIssueHeaderActions(store) {
const el = document.querySelector('.js-issue-header-actions');
if (!el) {
return undefined;
}
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
store,
provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath,
reportAbusePath: el.dataset.reportAbusePath,
submitAsSpamPath: el.dataset.submitAsSpamPath,
},
render: createElement => createElement(HeaderActions),
});
}
mutation updateIssue($input: UpdateIssueInput!) {
updateIssue(input: $input) {
errors
}
}
...@@ -5,7 +5,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; ...@@ -5,7 +5,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode'; import ZenMode from '~/zen_mode';
import '~/notes/index'; import '~/notes/index';
import { store } from '~/notes/stores'; import { store } from '~/notes/stores';
import initIssueApp from '~/issue_show/issue'; import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue';
import initIncidentApp from '~/issue_show/incident'; import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
...@@ -24,13 +24,14 @@ export default function() { ...@@ -24,13 +24,14 @@ export default function() {
initIncidentApp(issuableData); initIncidentApp(issuableData);
break; break;
case IssuableType.Issue: case IssuableType.Issue:
initIssueApp(issuableData, store); initIssuableApp(issuableData, store);
break; break;
default: default:
break; break;
} }
initIssuableHeaderWarning(store); initIssuableHeaderWarning(store);
initIssueHeaderActions(store);
initSentryErrorStackTraceApp(); initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp(); initRelatedMergeRequestsApp();
......
...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuable_sidebar, project.group) push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:tribute_autocomplete, @project) push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project) push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:vue_issue_header, @project)
end end
before_action only: :show do before_action only: :show do
......
...@@ -152,6 +152,21 @@ module IssuesHelper ...@@ -152,6 +152,21 @@ module IssuesHelper
sort: 'desc' sort: 'desc'
} }
end end
def issue_header_actions_data(project, issue, current_user)
{
can_create_issue: show_new_issue_link?(project).to_s,
can_reopen_issue: can?(current_user, :reopen_issue, issue).to_s,
can_report_spam: issue.submittable_as_spam_by?(current_user).to_s,
can_update_issue: can?(current_user, :update_issue, issue).to_s,
iid: issue.iid,
is_issue_author: issue.author == current_user,
new_issue_path: new_project_issue_path(project),
project_path: project.full_path,
report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)),
submit_as_spam_path: mark_as_spam_project_issue_path(project, issue)
}
end
end end
IssuesHelper.prepend_if_ee('EE::IssuesHelper') IssuesHelper.prepend_if_ee('EE::IssuesHelper')
...@@ -23,6 +23,9 @@ ...@@ -23,6 +23,9 @@
%a.btn.gl-button.btn-default.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } %a.btn.gl-button.btn-default.float-right.gl-display-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" }
= sprite_icon('chevron-double-lg-left') = sprite_icon('chevron-double-lg-left')
- if Feature.enabled?(:vue_issue_header, @project)
.js-issue-header-actions{ data: issue_header_actions_data(@project, @issue, current_user) }
- else
.detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } } .detail-page-header-actions.js-issuable-actions.js-issuable-buttons{ data: { "action": "close-reopen" } }
.clearfix.issue-btn-group.dropdown .clearfix.issue-btn-group.dropdown
%button.btn.gl-button.btn-default.float-left.d-md-none.d-lg-none.d-xl-none{ type: "button", data: { toggle: "dropdown" } } %button.btn.gl-button.btn-default.float-left.d-md-none.d-lg-none.d-xl-none{ type: "button", data: { toggle: "dropdown" } }
......
---
name: vue_issue_header
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44440
rollout_issue_url:
type: development
group: group::project management
default_enabled: false
...@@ -16,6 +16,8 @@ RSpec.describe 'Related issues', :js do ...@@ -16,6 +16,8 @@ RSpec.describe 'Related issues', :js do
context 'when user has permission to manage related issues' do context 'when user has permission to manage related issues' do
before do before do
stub_feature_flags(vue_issue_header: false)
project.add_maintainer(user) project.add_maintainer(user)
project_b.add_maintainer(user) project_b.add_maintainer(user)
gitlab_sign_in(user) gitlab_sign_in(user)
......
...@@ -14,6 +14,8 @@ RSpec.describe 'Issue Sidebar' do ...@@ -14,6 +14,8 @@ RSpec.describe 'Issue Sidebar' do
let_it_be(:issue_no_group) { create(:labeled_issue, project: project_without_group, labels: [label]) } let_it_be(:issue_no_group) { create(:labeled_issue, project: project_without_group, labels: [label]) }
before do before do
stub_feature_flags(vue_issue_header: false)
sign_in(user) sign_in(user)
end end
......
...@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr ...@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr
end end
before do before do
stub_feature_flags(vue_issue_header: false)
project.add_developer(user) project.add_developer(user)
sign_in(user) sign_in(user)
......
...@@ -5493,6 +5493,9 @@ msgstr "" ...@@ -5493,6 +5493,9 @@ msgstr ""
msgid "Close epic" msgid "Close epic"
msgstr "" msgstr ""
msgid "Close issue"
msgstr ""
msgid "Close milestone" msgid "Close milestone"
msgstr "" msgstr ""
...@@ -14771,6 +14774,9 @@ msgstr "" ...@@ -14771,6 +14774,9 @@ msgstr ""
msgid "Issue Boards" msgid "Issue Boards"
msgstr "" msgstr ""
msgid "Issue actions"
msgstr ""
msgid "Issue already promoted to epic." msgid "Issue already promoted to epic."
msgstr "" msgstr ""
...@@ -22372,6 +22378,9 @@ msgstr "" ...@@ -22372,6 +22378,9 @@ msgstr ""
msgid "Reopen epic" msgid "Reopen epic"
msgstr "" msgstr ""
msgid "Reopen issue"
msgstr ""
msgid "Reopen milestone" msgid "Reopen milestone"
msgstr "" msgstr ""
...@@ -27292,6 +27301,9 @@ msgstr "" ...@@ -27292,6 +27301,9 @@ msgstr ""
msgid "This is your current session" msgid "This is your current session"
msgstr "" msgstr ""
msgid "This issue is currently blocked by the following issues:"
msgstr ""
msgid "This issue is currently blocked by the following issues: %{issues}." msgid "This issue is currently blocked by the following issues: %{issues}."
msgstr "" msgstr ""
......
...@@ -13,6 +13,8 @@ RSpec.describe "User views incident" do ...@@ -13,6 +13,8 @@ RSpec.describe "User views incident" do
end end
before do before do
stub_feature_flags(vue_issue_header: false)
sign_in(user) sign_in(user)
visit(project_issues_incident_path(project, incident)) visit(project_issues_incident_path(project, incident))
......
...@@ -7,6 +7,10 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do ...@@ -7,6 +7,10 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do
let(:user) { create(:user) } let(:user) { create(:user) }
before do
stub_feature_flags(vue_issue_header: false)
end
shared_examples 'an issuable close/reopen/report toggle' do shared_examples 'an issuable close/reopen/report toggle' do
let(:container) { find('.issuable-close-dropdown') } let(:container) { find('.issuable-close-dropdown') }
let(:human_model_name) { issuable.model_name.human.downcase } let(:human_model_name) { issuable.model_name.human.downcase }
......
...@@ -13,6 +13,8 @@ RSpec.describe "User views issue" do ...@@ -13,6 +13,8 @@ RSpec.describe "User views issue" do
end end
before do before do
stub_feature_flags(vue_issue_header: false)
sign_in(user) sign_in(user)
visit(project_issue_path(project, issue)) visit(project_issue_path(project, issue))
......
...@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr ...@@ -16,6 +16,8 @@ RSpec.describe Projects::IssuesController, '(JavaScript fixtures)', type: :contr
end end
before do before do
stub_feature_flags(vue_issue_header: false)
sign_in(admin) sign_in(admin)
end end
......
import { GlButton, GlDropdown, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import HeaderActions from '~/issue_show/components/header_actions.vue';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import createStore from '~/notes/stores';
describe('HeaderActions component', () => {
let dispatchEventSpy;
let wrapper;
const localVue = createLocalVue();
localVue.use(Vuex);
const store = createStore();
const defaultProps = {
canCreateIssue: true,
canReopenIssue: true,
canReportSpam: true,
canUpdateIssue: true,
iid: '32',
isIssueAuthor: true,
newIssuePath: 'gitlab-org/gitlab-test/-/issues/new',
projectPath: 'gitlab-org/gitlab-test',
reportAbusePath:
'-/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%2Fgitlab-org%2Fgitlab-test%2F-%2Fissues%2F32&user_id=1',
submitAsSpamPath: 'gitlab-org/gitlab-test/-/issues/32/submit_as_spam',
};
const mutate = jest.fn().mockResolvedValue({ data: { updateIssue: { errors: [] } } });
const findToggleIssueStateButton = () => wrapper.find(GlButton);
const findDropdownAt = index => wrapper.findAll(GlDropdown).at(index);
const findMobileDropdownItems = () => findDropdownAt(0).findAll(GlDropdownItem);
const findDesktopDropdownItems = () => findDropdownAt(1).findAll(GlDropdownItem);
const findModal = () => wrapper.find(GlModal);
const findModalLinkAt = index =>
findModal()
.findAll(GlLink)
.at(index);
const mountComponent = ({
props = {},
issueState = IssuableStatus.Open,
blockedByIssues = [],
} = {}) => {
store.getters.getNoteableData.state = issueState;
store.getters.getNoteableData.blocked_by_issues = blockedByIssues;
return shallowMount(HeaderActions, {
localVue,
store,
provide: {
...defaultProps,
...props,
},
mocks: {
$apollo: {
mutate,
},
},
});
};
afterEach(() => {
if (dispatchEventSpy) {
dispatchEventSpy.mockRestore();
}
wrapper.destroy();
});
describe('close/reopen button', () => {
describe.each`
description | issueState | buttonText | newIssueState
${'when the issue is open'} | ${IssuableStatus.Open} | ${'Close issue'} | ${IssueStateEvent.Close}
${'when the issue is closed'} | ${IssuableStatus.Closed} | ${'Reopen issue'} | ${IssueStateEvent.Reopen}
`('$description', ({ issueState, buttonText, newIssueState }) => {
beforeEach(() => {
dispatchEventSpy = jest.spyOn(document, 'dispatchEvent');
wrapper = mountComponent({ issueState });
});
it(`has text "${buttonText}"`, () => {
expect(findToggleIssueStateButton().text()).toBe(buttonText);
});
it('calls apollo mutation', () => {
findToggleIssueStateButton().vm.$emit('click');
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
iid: defaultProps.iid.toString(),
projectPath: defaultProps.projectPath,
stateEvent: newIssueState,
},
},
}),
);
});
it('dispatches a custom event to update the issue page', async () => {
findToggleIssueStateButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(dispatchEventSpy).toHaveBeenCalledTimes(1);
});
});
});
describe.each`
description | isCloseIssueItemVisible | findDropdownItems
${'mobile dropdown'} | ${true} | ${findMobileDropdownItems}
${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems}
`('$description', ({ isCloseIssueItemVisible, findDropdownItems }) => {
describe.each`
description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam
${'when user can update issue'} | ${'Close issue'} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true}
${'when user cannot update issue'} | ${'Close issue'} | ${false} | ${false} | ${true} | ${true} | ${true}
${'when user can create issue'} | ${'New issue'} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot create issue'} | ${'New issue'} | ${false} | ${true} | ${false} | ${true} | ${true}
${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true}
${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true}
${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true}
${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false}
`(
'$description',
({
itemText,
isItemVisible,
canUpdateIssue,
canCreateIssue,
isIssueAuthor,
canReportSpam,
}) => {
beforeEach(() => {
wrapper = mountComponent({
props: {
canUpdateIssue,
canCreateIssue,
isIssueAuthor,
canReportSpam,
},
});
});
it(`${isItemVisible ? 'shows' : 'hides'} "${itemText}" item`, () => {
expect(
findDropdownItems()
.filter(item => item.text() === itemText)
.exists(),
).toBe(isItemVisible);
});
},
);
});
describe('modal', () => {
const blockedByIssues = [
{ iid: 13, web_url: 'gitlab-org/gitlab-test/-/issues/13' },
{ iid: 79, web_url: 'gitlab-org/gitlab-test/-/issues/79' },
];
beforeEach(() => {
wrapper = mountComponent({ blockedByIssues });
});
it('has title text', () => {
expect(findModal().attributes('title')).toBe(
'Are you sure you want to close this blocked issue?',
);
});
it('has body text', () => {
expect(findModal().text()).toContain(
'This issue is currently blocked by the following issues:',
);
});
it('calls apollo mutation when primary button is clicked', () => {
findModal().vm.$emit('primary');
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
input: {
iid: defaultProps.iid.toString(),
projectPath: defaultProps.projectPath,
stateEvent: IssueStateEvent.Close,
},
},
}),
);
});
describe.each`
ordinal | index
${'first'} | ${0}
${'second'} | ${1}
`('$ordinal blocked-by issue link', ({ index }) => {
it('has link text', () => {
expect(findModalLinkAt(index).text()).toBe(`#${blockedByIssues[index].iid}`);
});
it('has url', () => {
expect(findModalLinkAt(index).attributes('href')).toBe(blockedByIssues[index].web_url);
});
});
});
});
...@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import initIssuableApp from '~/issue_show/issue'; import { initIssuableApp } from '~/issue_show/issue';
import * as parseData from '~/issue_show/utils/parse_data'; import * as parseData from '~/issue_show/utils/parse_data';
import { appProps } from './mock_data'; import { appProps } from './mock_data';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
......
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