Commit 300844aa authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '244943-incidents-navigation' into 'master'

Show incident details page via `issues/incident/:id`

Closes #244943

See merge request gitlab-org/gitlab!43459
parents bd7f053c d0c3794c
...@@ -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();
});
...@@ -14,7 +14,7 @@ import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptio ...@@ -14,7 +14,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';
...@@ -51,7 +51,7 @@ function mountAssigneesComponent(mediator) { ...@@ -51,7 +51,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',
}, },
}), }),
}); });
...@@ -158,7 +158,7 @@ function mountLockComponent() { ...@@ -158,7 +158,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 render_template(:index)
end
end
end
describe 'GET #show' do
def make_request
get :show, params: project_params(id: resource)
end
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 have_gitlab_http_status(:ok)
expect(response).to render_template(:index) 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 end
context 'when user is unauthorized' do context 'with feature flag disabled' do
it 'redirects to the login page' do before do
make_request 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
expect(response).to redirect_to(new_user_session_path) 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,11 +236,10 @@ describe('Incidents List', () => { ...@@ -218,11 +236,10 @@ 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", () => {
...@@ -254,51 +271,47 @@ describe('Incidents List', () => { ...@@ -254,51 +271,47 @@ 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')
.at(0) .at(0)
.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')
.at(1) .at(1)
.text(), .text(),
).toBe('Next'); ).toBe('Next');
});
}); });
it('returns nextPage number', () => { it('returns nextPage number', async () => {
mountComponent({ mountComponent({
data: { data: {
incidents: { incidents: {
...@@ -312,17 +325,15 @@ describe('Incidents List', () => { ...@@ -312,17 +325,15 @@ 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();
});
}); });
}); });
......
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