Commit 4d59594d authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'tr-incident-tabs' into 'master'

Add a Summary tab for incident issues

See merge request gitlab-org/gitlab!39822
parents 8cbc43ab 9144ffb5
......@@ -20,7 +20,6 @@ export default {
components: {
GlIcon,
GlIntersectionObserver,
descriptionComponent,
titleComponent,
editedComponent,
formComponent,
......@@ -152,6 +151,18 @@ export default {
required: false,
default: 0,
},
descriptionComponent: {
type: Object,
required: false,
default: () => {
return descriptionComponent;
},
},
showTitleBorder: {
type: Boolean,
required: false,
default: true,
},
},
data() {
const store = new Store({
......@@ -209,6 +220,11 @@ export default {
isOpenStatus() {
return this.issuableStatus === IssuableStatus.Open;
},
pinnedLinkClasses() {
return this.showTitleBorder
? 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'
: '';
},
statusIcon() {
return this.isOpenStatus ? 'issue-open-m' : 'mobile-issue-close';
},
......@@ -447,9 +463,11 @@ export default {
<pinned-links
:zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl"
:class="pinnedLinkClasses"
/>
<description-component
<component
:is="descriptionComponent"
v-if="state.descriptionHtml"
:can-update="canUpdate"
:description-html="state.descriptionHtml"
......
<script>
import { GlTab, GlTabs } from '@gitlab/ui';
import DescriptionComponent from './description.vue';
export default {
components: {
GlTab,
GlTabs,
DescriptionComponent,
},
};
</script>
<template>
<div>
<gl-tabs
content-class="gl-reset-line-height gl-mt-3"
class="gl-mt-n3"
data-testid="incident-tabs"
>
<gl-tab :title="__('Summary')">
<description-component v-bind="$attrs" />
</gl-tab>
</gl-tabs>
</div>
</template>
......@@ -45,7 +45,7 @@ export default {
</script>
<template>
<div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start">
<div class="gl-display-flex gl-justify-content-start">
<template v-for="(link, i) in pinnedLinks">
<div v-if="link.url" :key="link.id" :class="{ 'gl-pr-3': needsPaddingClass(i) }">
<gl-button
......
import Vue from 'vue';
import issuableApp from './components/app.vue';
import incidentTabs from './components/incident_tabs.vue';
export default function initIssuableApp(issuableData = {}) {
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
issuableApp,
},
render(createElement) {
return createElement('issuable-app', {
props: {
...issuableData,
descriptionComponent: incidentTabs,
showTitleBorder: false,
},
});
},
});
}
import Vue from 'vue';
import issuableApp from './components/app.vue';
import { parseIssuableData } from './utils/parse_data';
export default function initIssueableApp() {
export default function initIssuableApp(issuableData) {
return new Vue({
el: document.getElementById('js-issuable-app'),
components: {
......@@ -10,7 +9,7 @@ export default function initIssueableApp() {
},
render(createElement) {
return createElement('issuable-app', {
props: parseIssuableData(),
props: issuableData,
});
},
});
......
......@@ -4,14 +4,23 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import { store } from '~/notes/stores';
import initIssueableApp from '~/issue_show';
import initIssueApp from '~/issue_show/issue';
import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
import initRelatedMergeRequestsApp from '~/related_merge_requests';
import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
export default function() {
initIssueableApp();
const { issueType, ...issuableData } = parseIssuableData();
if (issueType === 'incident') {
initIncidentApp(issuableData);
} else {
initIssueApp(issuableData);
}
initIssuableHeaderWarning(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
......
......@@ -292,6 +292,7 @@ module IssuablesHelper
{
hasClosingMergeRequest: issuable.merge_requests_count(current_user) != 0,
issueType: issuable.issue_type,
zoomMeetingUrl: ZoomMeeting.canonical_meeting_url(issuable),
sentryIssueIdentifier: SentryIssue.find_by(issue: issuable)&.sentry_issue_identifier # rubocop:disable CodeReuse/ActiveRecord
}
......@@ -301,8 +302,8 @@ module IssuablesHelper
return { groupPath: parent.path } if parent.is_a?(Group)
{
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path
projectPath: ref_project.path,
projectNamespace: ref_project.namespace.full_path
}
end
......
---
title: Add Summary tab for incident issues
merge_request: 39822
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Incident Detail', :js do
let(:user) { create(:user) }
let(:project) { create(:project, :public) }
let(:incident) { create(:issue, project: project, author: user, issue_type: 'incident', description: 'hello') }
context 'when user displays the incident' do
before do
visit project_issue_path(project, incident)
wait_for_requests
end
it 'shows the incident tabs' do
page.within('.issuable-details') do
incident_tabs = find('[data-testid="incident-tabs"]')
expect(find('h2')).to have_content(incident.title)
expect(incident_tabs).to have_content('Summary')
expect(incident_tabs).to have_content(incident.description)
end
end
end
end
......@@ -9,6 +9,9 @@ import '~/behaviors/markdown/render_gfm';
import IssuableApp from '~/issue_show/components/app.vue';
import eventHub from '~/issue_show/event_hub';
import { initialRequest, secondRequest } from '../mock_data';
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
import DescriptionComponent from '~/issue_show/components/description.vue';
import PinnedLinks from '~/issue_show/components/pinned_links.vue';
function formatText(text) {
return text.trim().replace(/\s\s+/g, ' ');
......@@ -22,6 +25,27 @@ const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
const publishedIncidentUrl = 'https://status.com/';
const defaultProps = {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
};
describe('Issuable output', () => {
useMockIntersectionObserver();
......@@ -31,6 +55,12 @@ describe('Issuable output', () => {
const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]');
const mountComponent = (props = {}) => {
wrapper = mount(IssuableApp, {
propsData: { ...defaultProps, ...props },
});
};
beforeEach(() => {
setFixtures(`
<div>
......@@ -57,28 +87,7 @@ describe('Issuable output', () => {
return res;
});
wrapper = mount(IssuableApp, {
propsData: {
canUpdate: true,
canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes',
updateEndpoint: TEST_HOST,
issuableRef: '#1',
issuableStatus: 'opened',
initialTitleHtml: '',
initialTitleText: '',
initialDescriptionHtml: 'test',
initialDescriptionText: 'test',
lockVersion: 1,
markdownPreviewPath: '/',
markdownDocsPath: '/',
projectNamespace: '/',
projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
},
});
mountComponent();
});
afterEach(() => {
......@@ -562,4 +571,46 @@ describe('Issuable output', () => {
});
});
});
describe('Composable description component', () => {
const findIncidentTabs = () => wrapper.find(IncidentTabs);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
const findPinnedLinks = () => wrapper.find(PinnedLinks);
const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6';
describe('when using description component', () => {
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('does not render incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(false);
});
it('adds a border below the header', () => {
expect(findPinnedLinks().attributes('class')).toContain(borderClass);
});
});
describe('when using incident tabs description wrapper', () => {
beforeEach(() => {
mountComponent({
descriptionComponent: IncidentTabs,
showTitleBorder: false,
});
});
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('renders incident tabs', () => {
expect(findIncidentTabs().exists()).toBe(true);
});
it('does not add a border below the header', () => {
expect(findPinnedLinks().attributes('class')).not.toContain(borderClass);
});
});
});
});
......@@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper';
import { TEST_HOST } from 'helpers/test_constants';
import Description from '~/issue_show/components/description.vue';
import TaskList from '~/task_list';
import { descriptionProps as props } from '../mock_data';
jest.mock('~/task_list');
describe('Description component', () => {
let vm;
let DescriptionComponent;
const props = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
updatedAt: new Date().toString(),
taskStatus: '',
updateUrl: TEST_HOST,
};
beforeEach(() => {
DescriptionComponent = Vue.extend(Description);
......
import { shallowMount } from '@vue/test-utils';
import { GlTab } from '@gitlab/ui';
import IncidentTabs from '~/issue_show/components/incident_tabs.vue';
import { descriptionProps } from '../mock_data';
import DescriptionComponent from '~/issue_show/components/description.vue';
describe('Incident Tabs component', () => {
let wrapper;
const mountComponent = () => {
wrapper = shallowMount(IncidentTabs, {
propsData: {
...descriptionProps,
},
stubs: {
DescriptionComponent: true,
},
});
};
beforeEach(() => {
mountComponent();
});
const findTabs = () => wrapper.findAll(GlTab);
const findSummaryTab = () => findTabs().at(0);
const findDescriptionComponent = () => wrapper.find(DescriptionComponent);
describe('default state', () => {
it('renders the summary tab', async () => {
expect(findTabs()).toHaveLength(1);
expect(findSummaryTab().exists()).toBe(true);
expect(findSummaryTab().attributes('title')).toBe('Summary');
});
it('renders the description component', () => {
expect(findDescriptionComponent().exists()).toBe(true);
});
it('passes all props to the description component', () => {
expect(findDescriptionComponent().props()).toMatchObject(descriptionProps);
});
});
});
import initIssueableApp from '~/issue_show';
import initIssuableApp from '~/issue_show/issue';
import { parseIssuableData } from '~/issue_show/utils/parse_data';
describe('Issue show index', () => {
describe('initIssueableApp', () => {
it('should initialize app with no potential XSS attack', () => {
// Warning: this test is currently faulty.
// More details at https://gitlab.com/gitlab-org/gitlab/-/issues/241717
// eslint-disable-next-line jest/no-disabled-tests
it.skip('should initialize app with no potential XSS attack', () => {
const d = document.createElement('div');
d.id = 'js-issuable-app-initial-data';
d.innerHTML = JSON.stringify({
initialDescriptionHtml: '&lt;img src=x onerror=alert(1)&gt;',
});
document.body.appendChild(d);
const alertSpy = jest.spyOn(window, 'alert');
initIssueableApp();
const issuableData = parseIssuableData();
initIssuableApp(issuableData);
expect(alertSpy).not.toHaveBeenCalled();
});
......
import { TEST_HOST } from 'helpers/test_constants';
export const initialRequest = {
title: '<p>this is a title</p>',
title_text: 'this is a title',
......@@ -21,3 +23,11 @@ export const secondRequest = {
updated_by_path: '/other_user',
lock_version: 2,
};
export const descriptionProps = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
taskStatus: '',
updateUrl: TEST_HOST,
};
......@@ -197,7 +197,8 @@ RSpec.describe IssuablesHelper do
initialTitleText: issue.title,
initialDescriptionHtml: '<p dir="auto">issue text</p>',
initialDescriptionText: 'issue text',
initialTaskStatus: '0 of 0 tasks completed'
initialTaskStatus: '0 of 0 tasks completed',
issueType: 'issue'
}
expect(helper.issuable_initial_data(issue)).to match(hash_including(expected_data))
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