Commit d0c3794c authored by David O'Regan's avatar David O'Regan Committed by Natalia Tepluhina

Add incident route

Add base incident route for
dedicated incident page routed
from the issues namespace
parent 387ae1dc
...@@ -19,6 +19,7 @@ import Api from '~/api'; ...@@ -19,6 +19,7 @@ import Api from '~/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { convertToSnakeCase } from '~/lib/utils/text_utility'; import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import { urlParamsToObject } from '~/lib/utils/common_utils'; import { urlParamsToObject } from '~/lib/utils/common_utils';
...@@ -40,6 +41,7 @@ import { ...@@ -40,6 +41,7 @@ import {
TH_CREATED_AT_TEST_ID, TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID, TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID, TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH,
} from '../constants'; } from '../constants';
const tdClass = const tdClass =
...@@ -111,6 +113,7 @@ export default { ...@@ -111,6 +113,7 @@ export default {
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
mixins: [glFeatureFlagsMixin()],
inject: [ inject: [
'projectPath', 'projectPath',
'newIssuePath', 'newIssuePath',
...@@ -332,7 +335,10 @@ export default { ...@@ -332,7 +335,10 @@ export default {
return Boolean(assignees.nodes?.length); return Boolean(assignees.nodes?.length);
}, },
navigateToIncidentDetails({ iid }) { navigateToIncidentDetails({ iid }) {
return visitUrl(joinPaths(this.issuePath, iid)); const path = this.glFeatures.issuesIncidentDetails
? joinPaths(this.issuePath, INCIDENT_DETAILS_PATH)
: this.issuePath;
return visitUrl(joinPaths(path, iid));
}, },
handlePageChange(page) { handlePageChange(page) {
const { startCursor, endCursor } = this.incidents.pageInfo; const { startCursor, endCursor } = this.incidents.pageInfo;
......
...@@ -38,3 +38,4 @@ export const DEFAULT_PAGE_SIZE = 20; ...@@ -38,3 +38,4 @@ export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' }; export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' }; export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
...@@ -44,6 +44,7 @@ export const checkPageAndAction = (page, action) => { ...@@ -44,6 +44,7 @@ export const checkPageAndAction = (page, action) => {
return pagePath === page && actionPath === action; return pagePath === page && actionPath === action;
}; };
export const isInIncidentPage = () => checkPageAndAction('issues', 'incident');
export const isInIssuePage = () => checkPageAndAction('issues', 'show'); export const isInIssuePage = () => checkPageAndAction('issues', 'show');
export const isInMRPage = () => checkPageAndAction('merge_requests', 'show'); export const isInMRPage = () => checkPageAndAction('merge_requests', 'show');
export const isInEpicPage = () => checkPageAndAction('epics', 'show'); export const isInEpicPage = () => checkPageAndAction('epics', 'show');
......
import initRelatedIssues from '~/related_issues';
import initShow from '../../issues/show';
document.addEventListener('DOMContentLoaded', () => {
initShow();
initRelatedIssues();
});
...@@ -13,7 +13,7 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio ...@@ -13,7 +13,7 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio
import SidebarSeverity from './components/severity/sidebar_severity.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { isInIssuePage, parseBoolean } from '~/lib/utils/common_utils'; import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
...@@ -50,7 +50,7 @@ function mountAssigneesComponent(mediator) { ...@@ -50,7 +50,7 @@ function mountAssigneesComponent(mediator) {
projectPath: fullPath, projectPath: fullPath,
field: el.dataset.field, field: el.dataset.field,
signedIn: el.hasAttribute('data-signed-in'), signedIn: el.hasAttribute('data-signed-in'),
issuableType: isInIssuePage() ? 'issue' : 'merge_request', issuableType: isInIssuePage() || isInIncidentPage() ? 'issue' : 'merge_request',
}, },
}), }),
}); });
...@@ -127,7 +127,7 @@ function mountLockComponent() { ...@@ -127,7 +127,7 @@ function mountLockComponent() {
const initialData = JSON.parse(dataNode.innerHTML); const initialData = JSON.parse(dataNode.innerHTML);
let importStore; let importStore;
if (isInIssuePage()) { if (isInIssuePage() || isInIncidentPage()) {
importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then( importStore = import(/* webpackChunkName: 'notesStore' */ '~/notes/stores').then(
({ store }) => store, ({ store }) => store,
); );
......
# frozen_string_literal: true # frozen_string_literal: true
class Projects::IncidentsController < Projects::ApplicationController class Projects::IncidentsController < Projects::ApplicationController
include IssuableActions
include Gitlab::Utils::StrongMemoize
before_action :authorize_read_issue! before_action :authorize_read_issue!
before_action :check_feature_flag, only: [:show]
before_action :load_incident, only: [:show]
before_action do
push_frontend_feature_flag(:issues_incident_details, @project)
end
def index def index
end end
private
def incident
strong_memoize(:incident) do
incident_finder
.execute
.inc_relations_for_view
.iid_in(params[:id])
.without_order
.first
end
end
def load_incident
@issue = incident # needed by rendered view
return render_404 unless can?(current_user, :read_issue, incident)
@noteable = incident
@note = incident.project.notes.new(noteable: issuable)
end
alias_method :issuable, :incident
def incident_finder
IssuesFinder.new(current_user, project_id: @project.id, issue_types: :incident)
end
def serializer
IssueSerializer.new(current_user: current_user, project: incident.project)
end
def check_feature_flag
render_404 unless Feature.enabled?(:issues_incident_details, @project)
end
end end
...@@ -239,7 +239,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -239,7 +239,7 @@ class Projects::IssuesController < Projects::ApplicationController
return @issue if defined?(@issue) return @issue if defined?(@issue)
# The Sortable default scope causes performance issues when used with find_by # The Sortable default scope causes performance issues when used with find_by
@issuable = @noteable = @issue ||= @project.issues.includes(author: :status).where(iid: params[:id]).reorder(nil).take! @issuable = @noteable = @issue ||= @project.issues.inc_relations_for_view.iid_in(params[:id]).without_order.take!
@note = @project.notes.new(noteable: @issuable) @note = @project.notes.new(noteable: @issuable)
return render_404 unless can?(current_user, :read_issue, @issue) return render_404 unless can?(current_user, :read_issue, @issue)
......
...@@ -55,7 +55,8 @@ module NavHelper ...@@ -55,7 +55,8 @@ module NavHelper
current_path?('projects/merge_requests/conflicts#show') || current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') || current_path?('issues#show') ||
current_path?('milestones#show') || current_path?('milestones#show') ||
current_path?('issues#designs') current_path?('issues#designs') ||
current_path?('incidents#show')
end end
def admin_monitoring_nav_links def admin_monitoring_nav_links
......
...@@ -126,6 +126,7 @@ class Issue < ApplicationRecord ...@@ -126,6 +126,7 @@ class Issue < ApplicationRecord
scope :counts_by_state, -> { reorder(nil).group(:state_id).count } scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
scope :service_desk, -> { where(author: ::User.support_bot) } scope :service_desk, -> { where(author: ::User.support_bot) }
scope :inc_relations_for_view, -> { includes(author: :status) }
# An issue can be uniquely identified by project_id and iid # An issue can be uniquely identified by project_id and iid
# Takes one or more sets of composite IDs, expressed as hash-like records of # Takes one or more sets of composite IDs, expressed as hash-like records of
......
= render 'projects/issues/new_branch'
= render template: 'projects/issues/show'
---
name: issues_incident_details
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43459
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/257842
type: development
group: group::health
default_enabled: false
...@@ -311,6 +311,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -311,6 +311,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :incidents, only: [:index] resources :incidents, only: [:index]
get 'issues/incident/:id' => 'incidents#show', as: :issues_incident
namespace :error_tracking do namespace :error_tracking do
resources :projects, only: :index resources :projects, only: :index
end end
......
...@@ -3,42 +3,127 @@ ...@@ -3,42 +3,127 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Projects::IncidentsController do RSpec.describe Projects::IncidentsController do
let_it_be(:project) { create(:project) } let_it_be_with_refind(:project) { create(:project) }
let_it_be(:developer) { create(:user) } let_it_be(:developer) { create(:user) }
let_it_be(:guest) { create(:user) } let_it_be(:guest) { create(:user) }
let_it_be(:anonymous) { nil }
before_all do before_all do
project.add_guest(guest) project.add_guest(guest)
project.add_developer(developer) project.add_developer(developer)
end end
before do
sign_in(user) if user
end
subject { make_request }
shared_examples 'not found' do
include_examples 'returning response status', :not_found
end
shared_examples 'login required' do
it 'redirects to the login page' do
subject
expect(response).to redirect_to(new_user_session_path)
end
end
describe 'GET #index' do describe 'GET #index' do
def make_request def make_request
get :index, params: { namespace_id: project.namespace, project_id: project } get :index, params: project_params
end end
it 'shows the page for users with guest role' do let(:user) { developer }
sign_in(guest)
make_request it 'shows the page' do
subject
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index) expect(response).to render_template(:index)
end end
it 'shows the page for users with developer role' do context 'when user is unauthorized' do
sign_in(developer) let(:user) { anonymous }
make_request
it_behaves_like 'login required'
end
context 'when user is a guest' do
let(:user) { guest }
it 'shows the page' do
subject
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index) expect(response).to render_template(:index)
end end
end
end
context 'when user is unauthorized' do describe 'GET #show' do
it 'redirects to the login page' do def make_request
make_request get :show, params: project_params(id: resource)
end
expect(response).to redirect_to(new_user_session_path) let_it_be(:resource) { create(:incident, project: project) }
let(:user) { developer }
it 'renders incident page' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
expect(assigns(:incident)).to be_present
expect(assigns(:incident).author.association(:status)).to be_loaded
expect(assigns(:issue)).to be_present
expect(assigns(:noteable)).to eq(assigns(:incident))
end
context 'with feature flag disabled' do
before do
stub_feature_flags(issues_incident_details: false)
end
it_behaves_like 'not found'
end
context 'with non existing id' do
let(:resource) { non_existing_record_id }
it_behaves_like 'not found'
end
context 'for issue' do
let_it_be(:resource) { create(:issue, project: project) }
it_behaves_like 'not found'
end
context 'when user is a guest' do
let(:user) { guest }
it 'shows the page' do
subject
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:show)
end end
end end
context 'when unauthorized' do
let(:user) { anonymous }
it_behaves_like 'login required'
end
end
private
def project_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace, project_id: project)
end end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Incident details', :js do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
let_it_be(:incident) { create(:incident, project: project, author: developer) }
before_all do
project.add_developer(developer)
end
before do
sign_in(developer)
visit project_issues_incident_path(project, incident)
wait_for_requests
end
context 'when a developer+ displays the incident' do
it 'shows the incident' do
page.within('.issuable-details') do
expect(find('h2')).to have_content(incident.title)
end
end
it 'does not show design management' do
expect(page).not_to have_selector('.js-design-management')
end
end
end
...@@ -28,10 +28,10 @@ import mockFilters from '../mocks/incidents_filter.json'; ...@@ -28,10 +28,10 @@ import mockFilters from '../mocks/incidents_filter.json';
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'), visitUrl: jest.fn().mockName('visitUrlMock'),
joinPaths: jest.fn().mockName('joinPaths'), joinPaths: jest.fn(),
mergeUrlParams: jest.fn().mockName('mergeUrlParams'), mergeUrlParams: jest.fn(),
setUrlParams: jest.fn().mockName('setUrlParams'), setUrlParams: jest.fn(),
updateHistory: jest.fn().mockName('updateHistory'), updateHistory: jest.fn(),
})); }));
describe('Incidents List', () => { describe('Incidents List', () => {
...@@ -81,12 +81,13 @@ describe('Incidents List', () => { ...@@ -81,12 +81,13 @@ describe('Incidents List', () => {
newIssuePath, newIssuePath,
incidentTemplateName, incidentTemplateName,
incidentType, incidentType,
issuePath: '/project/isssues', issuePath: '/project/issues',
publishedAvailable: true, publishedAvailable: true,
emptyListSvgPath, emptyListSvgPath,
textQuery: '', textQuery: '',
authorUsernamesQuery: '', authorUsernamesQuery: '',
assigneeUsernamesQuery: '', assigneeUsernamesQuery: '',
issuesIncidentDetails: false,
}, },
stubs: { stubs: {
GlButton: true, GlButton: true,
...@@ -182,13 +183,6 @@ describe('Incidents List', () => { ...@@ -182,13 +183,6 @@ describe('Incidents List', () => {
expect(src).toBe(avatarUrl); expect(src).toBe(avatarUrl);
}); });
it('contains a link to the issue details', () => {
findTableRows()
.at(0)
.trigger('click');
expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/isssues/`, mockIncidents[0].iid));
});
it('renders a closed icon for closed incidents', () => { it('renders a closed icon for closed incidents', () => {
expect(findClosedIcon().length).toBe( expect(findClosedIcon().length).toBe(
mockIncidents.filter(({ state }) => state === 'closed').length, mockIncidents.filter(({ state }) => state === 'closed').length,
...@@ -199,6 +193,30 @@ describe('Incidents List', () => { ...@@ -199,6 +193,30 @@ describe('Incidents List', () => {
it('renders severity per row', () => { it('renders severity per row', () => {
expect(findSeverity().length).toBe(mockIncidents.length); expect(findSeverity().length).toBe(mockIncidents.length);
}); });
it('contains a link to the issue details page', () => {
findTableRows()
.at(0)
.trigger('click');
expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/issues/`, mockIncidents[0].iid));
});
it('contains a link to the incident details page', async () => {
beforeEach(() =>
mountComponent({
data: { incidents: { list: mockIncidents }, incidentsCount: {} },
loading: false,
provide: { glFeatures: { issuesIncidentDetails: true } },
}),
);
findTableRows()
.at(0)
.trigger('click');
expect(visitUrl).toHaveBeenCalledWith(
joinPaths(`/project/issues/incident`, mockIncidents[0].iid),
);
});
}); });
describe('Create Incident', () => { describe('Create Incident', () => {
...@@ -218,12 +236,11 @@ describe('Incidents List', () => { ...@@ -218,12 +236,11 @@ describe('Incidents List', () => {
); );
}); });
it('sets button loading on click', () => { it('sets button loading on click', async () => {
findCreateIncidentBtn().vm.$emit('click'); findCreateIncidentBtn().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(findCreateIncidentBtn().attributes('loading')).toBe('true'); expect(findCreateIncidentBtn().attributes('loading')).toBe('true');
}); });
});
it("doesn't show the button when list is empty", () => { it("doesn't show the button when list is empty", () => {
mountComponent({ mountComponent({
...@@ -254,10 +271,10 @@ describe('Incidents List', () => { ...@@ -254,10 +271,10 @@ describe('Incidents List', () => {
}); });
describe('prevPage', () => { describe('prevPage', () => {
it('returns prevPage button', () => { it('returns prevPage button', async () => {
findPagination().vm.$emit('input', 3); findPagination().vm.$emit('input', 3);
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect( expect(
findPagination() findPagination()
.findAll('.page-item') .findAll('.page-item')
...@@ -265,30 +282,27 @@ describe('Incidents List', () => { ...@@ -265,30 +282,27 @@ describe('Incidents List', () => {
.text(), .text(),
).toBe('Prev'); ).toBe('Prev');
}); });
});
it('returns prevPage number', () => { it('returns prevPage number', async () => {
findPagination().vm.$emit('input', 3); findPagination().vm.$emit('input', 3);
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.prevPage).toBe(2); expect(wrapper.vm.prevPage).toBe(2);
}); });
});
it('returns 0 when it is the first page', () => { it('returns 0 when it is the first page', async () => {
findPagination().vm.$emit('input', 1); findPagination().vm.$emit('input', 1);
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.prevPage).toBe(0); expect(wrapper.vm.prevPage).toBe(0);
}); });
}); });
});
describe('nextPage', () => { describe('nextPage', () => {
it('returns nextPage button', () => { it('returns nextPage button', async () => {
findPagination().vm.$emit('input', 3); findPagination().vm.$emit('input', 3);
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect( expect(
findPagination() findPagination()
.findAll('.page-item') .findAll('.page-item')
...@@ -296,9 +310,8 @@ describe('Incidents List', () => { ...@@ -296,9 +310,8 @@ describe('Incidents List', () => {
.text(), .text(),
).toBe('Next'); ).toBe('Next');
}); });
});
it('returns nextPage number', () => { it('returns nextPage number', async () => {
mountComponent({ mountComponent({
data: { data: {
incidents: { incidents: {
...@@ -312,19 +325,17 @@ describe('Incidents List', () => { ...@@ -312,19 +325,17 @@ describe('Incidents List', () => {
}); });
findPagination().vm.$emit('input', 1); findPagination().vm.$emit('input', 1);
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBe(2); expect(wrapper.vm.nextPage).toBe(2);
}); });
});
it('returns `null` when currentPage is already last page', () => { it('returns `null` when currentPage is already last page', async () => {
findStatusTabs().vm.$emit('input', 1); findStatusTabs().vm.$emit('input', 1);
findPagination().vm.$emit('input', 1); findPagination().vm.$emit('input', 1);
return wrapper.vm.$nextTick(() => { await wrapper.vm.$nextTick();
expect(wrapper.vm.nextPage).toBeNull(); expect(wrapper.vm.nextPage).toBeNull();
}); });
}); });
});
describe('Filtered search component', () => { describe('Filtered search component', () => {
beforeEach(() => { beforeEach(() => {
......
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