Commit b5e1b76e authored by Sarah Yasonik's avatar Sarah Yasonik Committed by Denys Mishunov

Expose url of incident on status page

In order to display in the UI when an issue
is published to a status page app, we want to
expose the url of the status detail page for
the issue is to the FE as part of the issue
request.
parent e520e164
...@@ -58,7 +58,12 @@ export default { ...@@ -58,7 +58,12 @@ export default {
zoomMeetingUrl: { zoomMeetingUrl: {
type: String, type: String,
required: false, required: false,
default: null, default: '',
},
publishedIncidentUrl: {
type: String,
required: false,
default: '',
}, },
issuableRef: { issuableRef: {
type: String, type: String,
...@@ -380,7 +385,10 @@ export default { ...@@ -380,7 +385,10 @@ export default {
:title-text="state.titleText" :title-text="state.titleText"
:show-inline-edit-button="showInlineEditButton" :show-inline-edit-button="showInlineEditButton"
/> />
<pinned-links :zoom-meeting-url="zoomMeetingUrl" /> <pinned-links
:zoom-meeting-url="zoomMeetingUrl"
:published-incident-url="publishedIncidentUrl"
/>
<description-component <description-component
v-if="state.descriptionHtml" v-if="state.descriptionHtml"
:can-update="canUpdate" :can-update="canUpdate"
......
...@@ -11,21 +11,40 @@ export default { ...@@ -11,21 +11,40 @@ export default {
zoomMeetingUrl: { zoomMeetingUrl: {
type: String, type: String,
required: false, required: false,
default: null, default: '',
},
publishedIncidentUrl: {
type: String,
required: false,
default: '',
}, },
}, },
}; };
</script> </script>
<template> <template>
<div v-if="zoomMeetingUrl" class="border-bottom mb-3 mt-n2"> <div class="border-bottom gl-mb-6 gl-display-flex gl-justify-content-start">
<gl-link <div v-if="publishedIncidentUrl" class="gl-pr-3">
:href="zoomMeetingUrl" <gl-link
target="_blank" :href="publishedIncidentUrl"
class="btn btn-inverted btn-secondary btn-sm text-dark mb-3" target="_blank"
> class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
<icon name="brand-zoom" :size="14" /> data-testid="publishedIncidentUrl"
<strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong> >
</gl-link> <icon name="tanuki" :size="14" />
<strong class="vertical-align-top">{{ __('Published on status page') }}</strong>
</gl-link>
</div>
<div v-if="zoomMeetingUrl">
<gl-link
:href="zoomMeetingUrl"
target="_blank"
class="btn btn-inverted btn-secondary btn-sm text-dark mb-3"
data-testid="zoomMeetingUrl"
>
<icon name="brand-zoom" :size="14" />
<strong class="vertical-align-top">{{ __('Join Zoom meeting') }}</strong>
</gl-link>
</div>
</div> </div>
</template> </template>
---
title: Add link to status page detail view for status page published issues
merge_request: 30249
author:
type: added
...@@ -90,6 +90,12 @@ After the quick action is used, a background worker publishes the issue onto the ...@@ -90,6 +90,12 @@ After the quick action is used, a background worker publishes the issue onto the
Since all incidents are published publicly, user and group mentions are anonymized with `Incident Responder`, Since all incidents are published publicly, user and group mentions are anonymized with `Incident Responder`,
and titles of non-public [GitLab references](../../markdown.md#special-gitlab-references) are removed. and titles of non-public [GitLab references](../../markdown.md#special-gitlab-references) are removed.
When an Incident is published in the GitLab project, you can access the
details page of the Incident by clicking the **Published on status page** button
displayed under the Incident's title.
![Status Page detail link](../img/status_page_detail_link_v13_1.png)
NOTE: **Note:** NOTE: **Note:**
Confidential issues can't be published. If you make a published issue confidential, it will be unpublished. Confidential issues can't be published. If you make a published issue confidential, it will be unpublished.
......
...@@ -28,6 +28,15 @@ module EE ...@@ -28,6 +28,15 @@ module EE
data data
end end
override :issue_only_initial_data
def issue_only_initial_data(issuable)
return {} unless issuable.is_a?(Issue)
super.merge(
publishedIncidentUrl: StatusPage::Storage.details_url(issuable)
)
end
override :issuable_meta_author_slot override :issuable_meta_author_slot
def issuable_meta_author_slot(author, css_class: nil) def issuable_meta_author_slot(author, css_class: nil)
gitlab_team_member_badge(author, css_class: css_class) gitlab_team_member_badge(author, css_class: css_class)
......
...@@ -48,6 +48,17 @@ module StatusPage ...@@ -48,6 +48,17 @@ module StatusPage
super && project&.feature_available?(:status_page) super && project&.feature_available?(:status_page)
end end
# Status page uses hash-routing, so we may see a number
# of different url endings from user-provided value;
# This ensures `/#/` is the tail
def normalized_status_page_url
return if status_page_url.blank?
status_page_url
.chomp('/').chomp('#').chomp('/')
.concat('/#/')
end
def storage_client def storage_client
return unless enabled? return unless enabled?
......
...@@ -13,22 +13,39 @@ module StatusPage ...@@ -13,22 +13,39 @@ module StatusPage
MAX_PAGES = 5 MAX_PAGES = 5
MAX_UPLOADS = MAX_KEYS_PER_PAGE * MAX_PAGES MAX_UPLOADS = MAX_KEYS_PER_PAGE * MAX_PAGES
def self.details_path(id) class << self
"data/incident/#{id}.json" def details_path(id)
end "data/incident/#{id}.json"
end
def self.upload_path(issue_iid, secret, file_name) def details_url(issue)
uploads_path = self.uploads_path(issue_iid) return unless published_issue_available?(issue, issue.project.status_page_setting)
File.join(uploads_path, secret, file_name) issue.project.status_page_setting.normalized_status_page_url +
end CGI.escape(details_path(issue.iid))
end
def self.uploads_path(issue_iid) def upload_path(issue_iid, secret, file_name)
File.join('data', 'incident', issue_iid.to_s, '/') uploads_path = uploads_path(issue_iid)
end
File.join(uploads_path, secret, file_name)
end
def self.list_path def uploads_path(issue_iid)
'data/list.json' File.join('data', 'incident', issue_iid.to_s, '/')
end
def list_path
'data/list.json'
end
private
def published_issue_available?(issue, setting)
issue.status_page_published_incident &&
setting&.enabled? &&
setting&.status_page_url
end
end end
class Error < StandardError class Error < StandardError
......
...@@ -44,11 +44,27 @@ RSpec.describe IssuablesHelper do ...@@ -44,11 +44,27 @@ RSpec.describe IssuablesHelper do
end end
context 'for an issue' do context 'for an issue' do
it 'returns the correct data that includes canAdmin: true' do let_it_be(:issue) { create(:issue, author: user, description: 'issue text') }
issue = create(:issue, author: user, description: 'issue text')
it 'returns the correct data' do
@project = issue.project @project = issue.project
expect(helper.issuable_initial_data(issue)).to include(canAdmin: true) expected_data = {
canAdmin: true,
publishedIncidentUrl: nil
}
expect(helper.issuable_initial_data(issue)).to include(expected_data)
end
context 'when published to a configured status page' do
it 'returns the correct data that includes publishedIncidentUrl' do
@project = issue.project
expect(StatusPage::Storage).to receive(:details_url).with(issue).and_return('http://status.com')
expect(helper.issuable_initial_data(issue)).to include(
publishedIncidentUrl: 'http://status.com'
)
end
end end
end end
......
...@@ -9,6 +9,48 @@ RSpec.describe StatusPage::Storage do ...@@ -9,6 +9,48 @@ RSpec.describe StatusPage::Storage do
it { is_expected.to eq('data/incident/123.json') } it { is_expected.to eq('data/incident/123.json') }
end end
describe '.details_url' do
let_it_be(:issue, reload: true) { create(:issue) }
subject { described_class.details_url(issue) }
context 'when issue is not published' do
it { is_expected.to be_nil }
end
context 'with a published incident' do
let_it_be(:incident) { create(:status_page_published_incident, issue: issue) }
context 'without a status page setting' do
it { is_expected.to be_nil }
end
context 'when status page setting is disabled' do
let_it_be(:setting) { create(:status_page_setting, project: issue.project) }
it { is_expected.to be_nil }
end
context 'when status page setting is enabled' do
let_it_be(:setting) { create(:status_page_setting, :enabled, project: issue.project) }
before do
stub_licensed_features(status_page: true)
end
it { is_expected.to eq("https://status.gitlab.com/#/data%2Fincident%2F#{issue.iid}.json") }
context 'when status page setting does not include a url' do
before do
setting.update!(status_page_url: nil)
end
it { is_expected.to be_nil }
end
end
end
end
describe '.list_path' do describe '.list_path' do
subject { described_class.list_path } subject { described_class.list_path }
......
...@@ -143,6 +143,48 @@ RSpec.describe StatusPage::ProjectSetting do ...@@ -143,6 +143,48 @@ RSpec.describe StatusPage::ProjectSetting do
end end
end end
describe '#normalized_status_page_url' do
let(:status_page_setting) { build(:status_page_setting, status_page_url: status_page_url) }
let(:status_page_url) { 'https://status.gitlab.com' }
let(:expected_url) { 'https://status.gitlab.com/#/' }
subject { status_page_setting.normalized_status_page_url }
context 'when status_page_url exists' do
it { is_expected.to eq(expected_url) }
end
context 'when status_page_url is blank' do
let(:status_page_url) { '' }
it { is_expected.to be_nil }
end
context 'when status_page_url is nil' do
let(:status_page_url) { nil }
it { is_expected.to be_nil }
end
context 'when status_page_url contains trailing slash' do
let(:status_page_url) { 'https://status.gitlab.com/' }
it { is_expected.to eq(expected_url) }
end
context 'when status_page_url contains trailing hash-navigator' do
let(:status_page_url) { 'https://status.gitlab.com/#' }
it { is_expected.to eq(expected_url) }
end
context 'when status_page_url matches expected url' do
let(:status_page_url) { 'https://status.gitlab.com/#/' }
it { is_expected.to eq(expected_url) }
end
end
describe '#storage_client' do describe '#storage_client' do
let(:status_page_setting) { build(:status_page_setting, :enabled) } let(:status_page_setting) { build(:status_page_setting, :enabled) }
......
...@@ -17965,6 +17965,9 @@ msgstr "" ...@@ -17965,6 +17965,9 @@ msgstr ""
msgid "Publish to status page" msgid "Publish to status page"
msgstr "" msgstr ""
msgid "Published on status page"
msgstr ""
msgid "Publishes this issue to the associated status page." msgid "Publishes this issue to the associated status page."
msgstr "" msgstr ""
......
...@@ -17,6 +17,9 @@ jest.mock('~/issue_show/event_hub'); ...@@ -17,6 +17,9 @@ jest.mock('~/issue_show/event_hub');
const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; const REALTIME_REQUEST_STACK = [initialRequest, secondRequest];
const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811';
const publishedIncidentUrl = 'https://status.com/';
describe('Issuable output', () => { describe('Issuable output', () => {
let mock; let mock;
let realtimeRequestCount = 0; let realtimeRequestCount = 0;
...@@ -67,6 +70,8 @@ describe('Issuable output', () => { ...@@ -67,6 +70,8 @@ describe('Issuable output', () => {
projectNamespace: '/', projectNamespace: '/',
projectPath: '/', projectPath: '/',
issuableTemplateNamesPath: '/issuable-templates-path', issuableTemplateNamesPath: '/issuable-templates-path',
zoomMeetingUrl,
publishedIncidentUrl,
}, },
}).$mount(); }).$mount();
}); });
...@@ -132,7 +137,7 @@ describe('Issuable output', () => { ...@@ -132,7 +137,7 @@ describe('Issuable output', () => {
vm.canUpdate = false; vm.canUpdate = false;
return vm.$nextTick().then(() => { return vm.$nextTick().then(() => {
expect(vm.$el.querySelector('.btn')).toBeNull(); expect(vm.$el.querySelector('.markdown-selector')).toBeNull();
}); });
}); });
...@@ -183,6 +188,17 @@ describe('Issuable output', () => { ...@@ -183,6 +188,17 @@ describe('Issuable output', () => {
}); });
}); });
describe('Pinned links propagated', () => {
it.each`
prop | value
${'zoomMeetingUrl'} | ${zoomMeetingUrl}
${'publishedIncidentUrl'} | ${publishedIncidentUrl}
`('sets the $prop correctly on underlying pinned links', ({ prop, value }) => {
expect(vm[prop]).toEqual(value);
expect(vm.$el.querySelector(`[data-testid="${prop}"]`).href).toBe(value);
});
});
describe('updateIssuable', () => { describe('updateIssuable', () => {
it('fetches new data after update', () => { it('fetches new data after update', () => {
const updateStoreSpy = jest.spyOn(vm, 'updateStoreState'); const updateStoreSpy = jest.spyOn(vm, 'updateStoreState');
......
...@@ -3,23 +3,18 @@ import { GlLink } from '@gitlab/ui'; ...@@ -3,23 +3,18 @@ import { GlLink } from '@gitlab/ui';
import PinnedLinks from '~/issue_show/components/pinned_links.vue'; import PinnedLinks from '~/issue_show/components/pinned_links.vue';
const plainZoomUrl = 'https://zoom.us/j/123456789'; const plainZoomUrl = 'https://zoom.us/j/123456789';
const plainStatusUrl = 'https://status.com';
describe('PinnedLinks', () => { describe('PinnedLinks', () => {
let wrapper; let wrapper;
const link = { const findLinks = () => wrapper.findAll(GlLink);
get text() {
return wrapper.find(GlLink).text();
},
get href() {
return wrapper.find(GlLink).attributes('href');
},
};
const createComponent = props => { const createComponent = props => {
wrapper = shallowMount(PinnedLinks, { wrapper = shallowMount(PinnedLinks, {
propsData: { propsData: {
zoomMeetingUrl: null, zoomMeetingUrl: '',
publishedIncidentUrl: '',
...props, ...props,
}, },
}); });
...@@ -30,12 +25,29 @@ describe('PinnedLinks', () => { ...@@ -30,12 +25,29 @@ describe('PinnedLinks', () => {
zoomMeetingUrl: `<a href="${plainZoomUrl}">Zoom</a>`, zoomMeetingUrl: `<a href="${plainZoomUrl}">Zoom</a>`,
}); });
expect(link.text).toBe('Join Zoom meeting'); expect(
findLinks()
.at(0)
.text(),
).toBe('Join Zoom meeting');
});
it('displays Status link', () => {
createComponent({
publishedIncidentUrl: `<a href="${plainStatusUrl}">Status</a>`,
});
expect(
findLinks()
.at(0)
.text(),
).toBe('Published on status page');
}); });
it('does not render if there are no links', () => { it('does not render if there are no links', () => {
createComponent({ createComponent({
zoomMeetingUrl: null, zoomMeetingUrl: '',
publishedIncidentUrl: '',
}); });
expect(wrapper.find(GlLink).exists()).toBe(false); expect(wrapper.find(GlLink).exists()).toBe(false);
......
...@@ -6,6 +6,15 @@ import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data ...@@ -6,6 +6,15 @@ import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data
describe('RelatedIssuableItem', () => { describe('RelatedIssuableItem', () => {
let wrapper; let wrapper;
function mountComponent({ mountMethod = mount, stubs = {}, props = {}, slots = {} } = {}) {
wrapper = mountMethod(RelatedIssuableItem, {
propsData: props,
slots,
stubs,
});
}
const props = { const props = {
idKey: 1, idKey: 1,
displayReference: 'gitlab-org/gitlab-test#1', displayReference: 'gitlab-org/gitlab-test#1',
...@@ -26,10 +35,7 @@ describe('RelatedIssuableItem', () => { ...@@ -26,10 +35,7 @@ describe('RelatedIssuableItem', () => {
}; };
beforeEach(() => { beforeEach(() => {
wrapper = mount(RelatedIssuableItem, { mountComponent({ props, slots });
slots,
propsData: props,
});
}); });
afterEach(() => { afterEach(() => {
......
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