Commit c2367afb authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 51a95129
Please view this file on the master branch, on stable branches it's out of date. Please view this file on the master branch, on stable branches it's out of date.
## 12.8.1
### Performance (1 change)
- Geo - Fix query to retrieve Job Artifacts when selective sync is disabled. !25388
## 12.8.0 ## 12.8.0
### Removed (1 change) ### Removed (1 change)
......
...@@ -55,9 +55,9 @@ export default { ...@@ -55,9 +55,9 @@ export default {
<template> <template>
<section id="grafana" class="settings no-animate js-grafana-integration"> <section id="grafana" class="settings no-animate js-grafana-integration">
<div class="settings-header"> <div class="settings-header">
<h4 class="js-section-header"> <h3 class="js-section-header h4">
{{ s__('GrafanaIntegration|Grafana Authentication') }} {{ s__('GrafanaIntegration|Grafana Authentication') }}
</h4> </h3>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
<p class="js-section-sub-header"> <p class="js-section-sub-header">
{{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }} {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }}
......
...@@ -33,9 +33,9 @@ export default { ...@@ -33,9 +33,9 @@ export default {
<template> <template>
<section class="settings no-animate"> <section class="settings no-animate">
<div class="settings-header"> <div class="settings-header">
<h4 class="js-section-header"> <h3 class="js-section-header h4">
{{ s__('ExternalMetrics|External Dashboard') }} {{ s__('ExternalMetrics|External Dashboard') }}
</h4> </h3>
<gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button>
<p class="js-section-sub-header"> <p class="js-section-sub-header">
{{ {{
......
...@@ -13,10 +13,11 @@ export default () => { ...@@ -13,10 +13,11 @@ export default () => {
}); });
} }
const pipelineTabLink = document.querySelector('.js-pipeline-tab-link a');
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset; const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document
.querySelector('.js-pipeline-tab-link a') if (pipelineTabLink) {
.getAttribute('href')}/status.json`; const pipelineStatusUrl = `${pipelineTabLink.getAttribute('href')}/status.json`;
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Pipelines({ new Pipelines({
...@@ -28,4 +29,5 @@ export default () => { ...@@ -28,4 +29,5 @@ export default () => {
parentEl: '.pipelines-tabs', parentEl: '.pipelines-tabs',
}, },
}); });
}
}; };
import PersistentUserCallout from '~/persistent_user_callout';
document.addEventListener('DOMContentLoaded', () => {
const callout = document.querySelector('.js-webhooks-moved-alert');
PersistentUserCallout.factory(callout);
});
...@@ -95,14 +95,14 @@ export default () => { ...@@ -95,14 +95,14 @@ export default () => {
}, },
}); });
const tabsElement = document.querySelector('.pipelines-tabs');
const testReportsEnabled = const testReportsEnabled =
window.gon && window.gon.features && window.gon.features.junitPipelineView; window.gon && window.gon.features && window.gon.features.junitPipelineView;
if (testReportsEnabled) { if (tabsElement && testReportsEnabled) {
const fetchReportsAction = 'fetchReports'; const fetchReportsAction = 'fetchReports';
testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint);
const tabsElmement = document.querySelector('.pipelines-tabs');
const isTestTabActive = Boolean( const isTestTabActive = Boolean(
document.querySelector('.pipelines-tabs > li > a.test-tab.active'), document.querySelector('.pipelines-tabs > li > a.test-tab.active'),
); );
...@@ -113,11 +113,11 @@ export default () => { ...@@ -113,11 +113,11 @@ export default () => {
const tabClickHandler = e => { const tabClickHandler = e => {
if (e.target.className === 'test-tab') { if (e.target.className === 'test-tab') {
testReportsStore.dispatch(fetchReportsAction); testReportsStore.dispatch(fetchReportsAction);
tabsElmement.removeEventListener('click', tabClickHandler); tabsElement.removeEventListener('click', tabClickHandler);
} }
}; };
tabsElmement.addEventListener('click', tabClickHandler); tabsElement.addEventListener('click', tabClickHandler);
} }
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
......
...@@ -76,7 +76,7 @@ export default { ...@@ -76,7 +76,7 @@ export default {
<div v-else-if="shouldRenderSuccessState" class="js-success-state"> <div v-else-if="shouldRenderSuccessState" class="js-success-state">
<release-block <release-block
v-for="(release, index) in releases" v-for="(release, index) in releases"
:key="release.tag_name" :key="release.tagName"
:release="release" :release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }" :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/> />
......
...@@ -25,16 +25,16 @@ export default { ...@@ -25,16 +25,16 @@ export default {
}, },
computed: { computed: {
evidenceTitle() { evidenceTitle() {
return sprintf(__('%{tag}-evidence.json'), { tag: this.release.tag_name }); return sprintf(__('%{tag}-evidence.json'), { tag: this.release.tagName });
}, },
evidenceUrl() { evidenceUrl() {
return this.release.assets && this.release.assets.evidence_file_path; return this.release.assets && this.release.assets.evidenceFilePath;
}, },
shortSha() { shortSha() {
return truncateSha(this.sha); return truncateSha(this.sha);
}, },
sha() { sha() {
return this.release.evidence_sha; return this.release.evidenceSha;
}, },
}, },
}; };
......
...@@ -38,13 +38,13 @@ export default { ...@@ -38,13 +38,13 @@ export default {
}, },
computed: { computed: {
id() { id() {
return slugify(this.release.tag_name); return slugify(this.release.tagName);
}, },
assets() { assets() {
return this.release.assets || {}; return this.release.assets || {};
}, },
hasEvidence() { hasEvidence() {
return Boolean(this.release.evidence_sha); return Boolean(this.release.evidenceSha);
}, },
milestones() { milestones() {
return this.release.milestones || []; return this.release.milestones || [];
...@@ -102,7 +102,7 @@ export default { ...@@ -102,7 +102,7 @@ export default {
<evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" /> <evidence-block v-if="hasEvidence && shouldShowEvidence" :release="release" />
<div ref="gfm-content" class="card-text prepend-top-default"> <div ref="gfm-content" class="card-text prepend-top-default">
<div v-html="release.description_html"></div> <div v-html="release.descriptionHtml"></div>
</div> </div>
</div> </div>
...@@ -110,11 +110,11 @@ export default { ...@@ -110,11 +110,11 @@ export default {
v-if="shouldShowFooter" v-if="shouldShowFooter"
class="card-footer" class="card-footer"
:commit="release.commit" :commit="release.commit"
:commit-path="release.commit_path" :commit-path="release.commitPath"
:tag-name="release.tag_name" :tag-name="release.tagName"
:tag-path="release.tag_path" :tag-path="release.tagPath"
:author="release.author" :author="release.author"
:released-at="release.released_at" :released-at="release.releasedAt"
/> />
</div> </div>
</template> </template>
...@@ -31,8 +31,8 @@ export default { ...@@ -31,8 +31,8 @@ export default {
<template #user> <template #user>
<user-avatar-link <user-avatar-link
class="prepend-left-4" class="prepend-left-4"
:link-href="author.web_url" :link-href="author.webUrl"
:img-src="author.avatar_url" :img-src="author.avatarUrl"
:img-alt="userImageAltDescription" :img-alt="userImageAltDescription"
:tooltip-text="author.username" :tooltip-text="author.username"
/> />
......
...@@ -66,9 +66,9 @@ export default { ...@@ -66,9 +66,9 @@ export default {
<icon ref="commitIcon" name="commit" class="mr-1" /> <icon ref="commitIcon" name="commit" class="mr-1" />
<div v-gl-tooltip.bottom :title="commit.title"> <div v-gl-tooltip.bottom :title="commit.title">
<gl-link v-if="commitPath" :href="commitPath"> <gl-link v-if="commitPath" :href="commitPath">
{{ commit.short_id }} {{ commit.shortId }}
</gl-link> </gl-link>
<span v-else>{{ commit.short_id }}</span> <span v-else>{{ commit.shortId }}</span>
</div> </div>
</div> </div>
...@@ -100,8 +100,8 @@ export default { ...@@ -100,8 +100,8 @@ export default {
<div v-if="author" class="d-flex"> <div v-if="author" class="d-flex">
<span class="text-secondary">{{ __('by') }}&nbsp;</span> <span class="text-secondary">{{ __('by') }}&nbsp;</span>
<user-avatar-link <user-avatar-link
:link-href="author.web_url" :link-href="author.webUrl"
:img-src="author.avatar_url" :img-src="author.avatarUrl"
:img-alt="userImageAltDescription" :img-alt="userImageAltDescription"
:tooltip-text="author.username" :tooltip-text="author.username"
tooltip-placement="bottom" tooltip-placement="bottom"
......
...@@ -20,10 +20,10 @@ export default { ...@@ -20,10 +20,10 @@ export default {
}, },
computed: { computed: {
editLink() { editLink() {
return this.release._links?.edit_url; return this.release.Links?.editUrl;
}, },
selfLink() { selfLink() {
return this.release._links?.self; return this.release.Links?.self;
}, },
}, },
}; };
...@@ -36,7 +36,7 @@ export default { ...@@ -36,7 +36,7 @@ export default {
{{ release.name }} {{ release.name }}
</gl-link> </gl-link>
<template v-else>{{ release.name }}</template> <template v-else>{{ release.name }}</template>
<gl-badge v-if="release.upcoming_release" variant="warning" class="align-middle">{{ <gl-badge v-if="release.upcomingRelease" variant="warning" class="align-middle">{{
__('Upcoming Release') __('Upcoming Release')
}}</gl-badge> }}</gl-badge>
</h2> </h2>
......
...@@ -32,21 +32,21 @@ export default { ...@@ -32,21 +32,21 @@ export default {
return this.release.commit || {}; return this.release.commit || {};
}, },
commitUrl() { commitUrl() {
return this.release.commit_path; return this.release.commitPath;
}, },
hasAuthor() { hasAuthor() {
return Boolean(this.author); return Boolean(this.author);
}, },
releasedTimeAgo() { releasedTimeAgo() {
return sprintf(__('released %{time}'), { return sprintf(__('released %{time}'), {
time: this.timeFormatted(this.release.released_at), time: this.timeFormatted(this.release.releasedAt),
}); });
}, },
shouldRenderMilestones() { shouldRenderMilestones() {
return Boolean(this.release.milestones?.length); return Boolean(this.release.milestones?.length);
}, },
tagUrl() { tagUrl() {
return this.release.tag_path; return this.release.tagPath;
}, },
}, },
}; };
...@@ -57,24 +57,24 @@ export default { ...@@ -57,24 +57,24 @@ export default {
<div class="append-right-8"> <div class="append-right-8">
<icon name="commit" class="align-middle" /> <icon name="commit" class="align-middle" />
<gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl"> <gl-link v-if="commitUrl" v-gl-tooltip.bottom :title="commit.title" :href="commitUrl">
{{ commit.short_id }} {{ commit.shortId }}
</gl-link> </gl-link>
<span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.short_id }}</span> <span v-else v-gl-tooltip.bottom :title="commit.title">{{ commit.shortId }}</span>
</div> </div>
<div class="append-right-8"> <div class="append-right-8">
<icon name="tag" class="align-middle" /> <icon name="tag" class="align-middle" />
<gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl"> <gl-link v-if="tagUrl" v-gl-tooltip.bottom :title="__('Tag')" :href="tagUrl">
{{ release.tag_name }} {{ release.tagName }}
</gl-link> </gl-link>
<span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span> <span v-else v-gl-tooltip.bottom :title="__('Tag')">{{ release.tagName }}</span>
</div> </div>
<release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" /> <release-block-milestones v-if="shouldRenderMilestones" :milestones="release.milestones" />
<div class="append-right-4"> <div class="append-right-4">
&bull; &bull;
<span v-gl-tooltip.bottom :title="tooltipTitle(release.released_at)"> <span v-gl-tooltip.bottom :title="tooltipTitle(release.releasedAt)">
{{ releasedTimeAgo }} {{ releasedTimeAgo }}
</span> </span>
</div> </div>
......
...@@ -40,7 +40,7 @@ export default { ...@@ -40,7 +40,7 @@ export default {
return Number.isNaN(percent) ? 0 : percent; return Number.isNaN(percent) ? 0 : percent;
}, },
allIssueStats() { allIssueStats() {
return this.milestones.map(m => m.issue_stats || {}); return this.milestones.map(m => m.issueStats || {});
}, },
openIssuesCount() { openIssuesCount() {
return this.allIssueStats.map(stats => stats.opened || 0).reduce(sumReducer); return this.allIssueStats.map(stats => stats.opened || 0).reduce(sumReducer);
...@@ -109,7 +109,7 @@ export default { ...@@ -109,7 +109,7 @@ export default {
:key="milestone.id" :key="milestone.id"
v-gl-tooltip v-gl-tooltip
:title="milestone.description" :title="milestone.description"
:href="milestone.web_url" :href="milestone.webUrl"
class="append-right-4" class="append-right-4"
> >
{{ milestone.title }} {{ milestone.title }}
......
...@@ -38,7 +38,7 @@ export default { ...@@ -38,7 +38,7 @@ export default {
:key="milestone.id" :key="milestone.id"
v-gl-tooltip v-gl-tooltip
:title="milestone.description" :title="milestone.description"
:href="milestone.web_url" :href="milestone.webUrl"
class="mx-1 js-milestone-link" class="mx-1 js-milestone-link"
> >
{{ milestone.title }} {{ milestone.title }}
......
...@@ -22,8 +22,7 @@ export const fetchRelease = ({ dispatch, state }) => { ...@@ -22,8 +22,7 @@ export const fetchRelease = ({ dispatch, state }) => {
return api return api
.release(state.projectId, state.tagName) .release(state.projectId, state.tagName)
.then(({ data: release }) => { .then(({ data: release }) => {
const camelCasedRelease = convertObjectPropsToCamelCase(release, { deep: true }); dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true }));
dispatch('receiveReleaseSuccess', camelCasedRelease);
}) })
.catch(error => { .catch(error => {
dispatch('receiveReleaseError', error); dispatch('receiveReleaseError', error);
......
...@@ -2,7 +2,11 @@ import * as types from './mutation_types'; ...@@ -2,7 +2,11 @@ import * as types from './mutation_types';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import api from '~/api'; import api from '~/api';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import {
normalizeHeaders,
parseIntPagination,
convertObjectPropsToCamelCase,
} from '~/lib/utils/common_utils';
/** /**
* Commits a mutation to update the state while the main endpoint is being requested. * Commits a mutation to update the state while the main endpoint is being requested.
...@@ -28,7 +32,11 @@ export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => { ...@@ -28,7 +32,11 @@ export const fetchReleases = ({ dispatch }, { page = '1', projectId }) => {
export const receiveReleasesSuccess = ({ commit }, { data, headers }) => { export const receiveReleasesSuccess = ({ commit }, { data, headers }) => {
const pageInfo = parseIntPagination(normalizeHeaders(headers)); const pageInfo = parseIntPagination(normalizeHeaders(headers));
commit(types.RECEIVE_RELEASES_SUCCESS, { data, pageInfo }); const camelCasedReleases = convertObjectPropsToCamelCase(data, { deep: true });
commit(types.RECEIVE_RELEASES_SUCCESS, {
data: camelCasedReleases,
pageInfo,
});
}; };
export const receiveReleasesError = ({ commit }) => { export const receiveReleasesError = ({ commit }) => {
......
...@@ -12,7 +12,8 @@ class Projects::HooksController < Projects::ApplicationController ...@@ -12,7 +12,8 @@ class Projects::HooksController < Projects::ApplicationController
layout "project_settings" layout "project_settings"
def index def index
redirect_to project_settings_integrations_path(@project) @hooks = @project.hooks
@hook = ProjectHook.new
end end
def create def create
...@@ -24,7 +25,7 @@ class Projects::HooksController < Projects::ApplicationController ...@@ -24,7 +25,7 @@ class Projects::HooksController < Projects::ApplicationController
flash[:alert] = @hook.errors.full_messages.join.html_safe flash[:alert] = @hook.errors.full_messages.join.html_safe
end end
redirect_to project_settings_integrations_path(@project) redirect_to action: :index
end end
def edit def edit
...@@ -33,7 +34,7 @@ class Projects::HooksController < Projects::ApplicationController ...@@ -33,7 +34,7 @@ class Projects::HooksController < Projects::ApplicationController
def update def update
if hook.update(hook_params) if hook.update(hook_params)
flash[:notice] = _('Hook was successfully updated.') flash[:notice] = _('Hook was successfully updated.')
redirect_to project_settings_integrations_path(@project) redirect_to action: :index
else else
render 'edit' render 'edit'
end end
...@@ -44,13 +45,13 @@ class Projects::HooksController < Projects::ApplicationController ...@@ -44,13 +45,13 @@ class Projects::HooksController < Projects::ApplicationController
set_hook_execution_notice(result) set_hook_execution_notice(result)
redirect_back_or_default(default: { action: 'index' }) redirect_back_or_default(default: { action: :index })
end end
def destroy def destroy
hook.destroy hook.destroy
redirect_to project_settings_integrations_path(@project), status: :found redirect_to action: :index, status: :found
end end
private private
......
...@@ -9,10 +9,6 @@ module Projects ...@@ -9,10 +9,6 @@ module Projects
layout "project_settings" layout "project_settings"
def show def show
@hooks = @project.hooks
@hook = ProjectHook.new
# Services
@services = @project.find_or_initialize_services(exceptions: service_exceptions) @services = @project.find_or_initialize_services(exceptions: service_exceptions)
end end
......
...@@ -4,7 +4,11 @@ module Resolvers ...@@ -4,7 +4,11 @@ module Resolvers
class BoardsResolver < BaseResolver class BoardsResolver < BaseResolver
type Types::BoardType, null: true type Types::BoardType, null: true
def resolve(**args) argument :id, GraphQL::ID_TYPE,
required: false,
description: 'Find a board by its ID'
def resolve(id: nil)
# The project or group could have been loaded in batch by `BatchLoader`. # The project or group could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the project/group to query for boards, so # At this point we need the `id` of the project/group to query for boards, so
# make sure it's loaded and not `nil` before continuing. # make sure it's loaded and not `nil` before continuing.
...@@ -12,7 +16,17 @@ module Resolvers ...@@ -12,7 +16,17 @@ module Resolvers
return Board.none unless parent return Board.none unless parent
Boards::ListService.new(parent, context[:current_user]).execute(create_default_board: false) Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false)
rescue ActiveRecord::RecordNotFound
Board.none
end
private
def extract_board_id(gid)
return unless gid.present?
GitlabSchema.parse_gid(gid, expected_type: ::Board).model_id
end end
end end
end end
...@@ -52,6 +52,12 @@ module Types ...@@ -52,6 +52,12 @@ module Types
null: true, null: true,
description: 'Boards of the group', description: 'Boards of the group',
resolver: Resolvers::BoardsResolver resolver: Resolvers::BoardsResolver
field :board,
Types::BoardType,
null: true,
description: 'A single board of the group',
resolver: Resolvers::BoardsResolver.single
end end
end end
......
...@@ -185,6 +185,12 @@ module Types ...@@ -185,6 +185,12 @@ module Types
null: true, null: true,
description: 'Boards of the project', description: 'Boards of the project',
resolver: Resolvers::BoardsResolver resolver: Resolvers::BoardsResolver
field :board,
Types::BoardType,
null: true,
description: 'A single board of the project',
resolver: Resolvers::BoardsResolver.single
end end
end end
......
...@@ -669,6 +669,9 @@ module ProjectsHelper ...@@ -669,6 +669,9 @@ module ProjectsHelper
project_members#index project_members#index
integrations#show integrations#show
services#edit services#edit
hooks#index
hooks#edit
hook_logs#show
repository#show repository#show
ci_cd#show ci_cd#show
operations#show operations#show
......
...@@ -5,6 +5,7 @@ module UserCalloutsHelper ...@@ -5,6 +5,7 @@ module UserCalloutsHelper
GCP_SIGNUP_OFFER = 'gcp_signup_offer' GCP_SIGNUP_OFFER = 'gcp_signup_offer'
SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed'
TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight'
WEBHOOKS_MOVED = 'webhooks_moved'
def show_gke_cluster_integration_callout?(project) def show_gke_cluster_integration_callout?(project)
can?(current_user, :create_cluster, project) && can?(current_user, :create_cluster, project) &&
...@@ -33,6 +34,10 @@ module UserCalloutsHelper ...@@ -33,6 +34,10 @@ module UserCalloutsHelper
current_user && !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test? current_user && !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test?
end end
def show_webhooks_moved_alert?
!user_dismissed?(WEBHOOKS_MOVED)
end
private private
def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil)
......
...@@ -21,7 +21,7 @@ class ProjectHook < WebHook ...@@ -21,7 +21,7 @@ class ProjectHook < WebHook
validates :project, presence: true validates :project, presence: true
def pluralized_name def pluralized_name
_('Project Hooks') _('Webhooks')
end end
end end
......
...@@ -15,7 +15,8 @@ module UserCalloutEnums ...@@ -15,7 +15,8 @@ module UserCalloutEnums
gcp_signup_offer: 2, gcp_signup_offer: 2,
cluster_security_warning: 3, cluster_security_warning: 3,
suggest_popover_dismissed: 9, suggest_popover_dismissed: 9,
tabs_position_highlight: 10 tabs_position_highlight: 10,
webhooks_moved: 13
} }
end end
end end
......
...@@ -2,49 +2,35 @@ ...@@ -2,49 +2,35 @@
module ResourceEvents module ResourceEvents
class ChangeMilestoneService class ChangeMilestoneService
attr_reader :resource, :user, :event_created_at, :resource_args attr_reader :resource, :user, :event_created_at, :milestone
def initialize(resource:, user:, created_at: Time.now) def initialize(resource:, user:, created_at: Time.now)
@resource = resource @resource = resource
@user = user @user = user
@event_created_at = created_at @event_created_at = created_at
@milestone = resource&.milestone
@resource_args = {
user_id: user.id,
created_at: event_created_at
}
end end
def execute def execute
args = build_resource_args ResourceMilestoneEvent.create(build_resource_args)
action = if milestone.nil?
:remove
else
:add
end
record = args.merge(milestone_id: milestone&.id, action: ResourceMilestoneEvent.actions[action])
create_event(record) resource.expire_note_etag_cache
end end
private private
def milestone
resource&.milestone
end
def create_event(record)
ResourceMilestoneEvent.create(record)
resource.expire_note_etag_cache
end
def build_resource_args def build_resource_args
key = resource.class.name.underscore.foreign_key action = milestone.blank? ? :remove : :add
key = resource.class.name.foreign_key
resource_args.merge(key => resource.id, state: ResourceMilestoneEvent.states[resource.state]) {
user_id: user.id,
created_at: event_created_at,
milestone_id: milestone&.id,
state: ResourceMilestoneEvent.states[resource.state],
action: ResourceMilestoneEvent.actions[action],
key => resource.id
}
end end
end end
end end
...@@ -366,10 +366,14 @@ ...@@ -366,10 +366,14 @@
%span %span
= _('Members') = _('Members')
- if can_edit - if can_edit
= nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do = nav_link(controller: [:integrations, :services]) do
= link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do = link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do
%span %span
= _('Integrations') = _('Integrations')
= nav_link(controller: [:hooks, :hook_logs]) do
= link_to project_hooks_path(@project), title: _('Webhooks'), data: { qa_selector: 'webhooks_settings_link' } do
%span
= _('Webhooks')
= nav_link(controller: :repository) do = nav_link(controller: :repository) do
= link_to project_settings_repository_path(@project), title: _('Repository') do = link_to project_settings_repository_path(@project), title: _('Repository') do
%span %span
......
- @content_class = 'limit-container-width' unless fluid_layout
- add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path
- page_title _('Webhook Logs')
.row.prepend-top-default.append-bottom-default .row.prepend-top-default.append-bottom-default
.col-lg-3 .col-lg-3
%h4.prepend-top-0 %h4.prepend-top-0
......
- add_to_breadcrumbs _('ProjectService|Integrations'), namespace_project_settings_integrations_path - @content_class = 'limit-container-width' unless fluid_layout
- page_title _('Edit Project Hook') - add_to_breadcrumbs _('Webhook Settings'), namespace_project_hooks_path
- page_title _('Webhook')
.row.prepend-top-default .row.prepend-top-default
.col-lg-3 .col-lg-3
......
- @content_class = 'limit-container-width' unless fluid_layout
- breadcrumb_title _('Webhook Settings')
- page_title _('Webhooks')
.row.prepend-top-default .row.prepend-top-default
.col-lg-4 .col-lg-4
= render 'shared/web_hooks/title_and_docs', hook: @hook = render 'shared/web_hooks/title_and_docs', hook: @hook
......
.row.prepend-top-default.append-bottom-default .row.prepend-top-default
.col-lg-4 .col-lg-4
%h4.prepend-top-0 %h4.prepend-top-0
= s_("ProjectService|Project services") = _('Integrations')
%p= s_("ProjectService|Project services allow you to integrate GitLab with other applications") %p= _('Integrations allow you to integrate GitLab with other applications')
.col-lg-8 .col-lg-8
%table.table %table.table
%colgroup %colgroup
......
- breadcrumb_title @service.title - breadcrumb_title @service.title
- add_to_breadcrumbs _('Integration Settings'), project_settings_integrations_path(@project)
- page_title @service.title, s_("ProjectService|Services") - page_title @service.title, s_("ProjectService|Services")
- add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project))
- add_to_breadcrumbs(s_("ProjectService|Integrations"), project_settings_integrations_path(@project))
= render 'deprecated_message' if @service.deprecation_message = render 'deprecated_message' if @service.deprecation_message
......
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- breadcrumb_title _("Integrations Settings") - breadcrumb_title _('Integration Settings')
- page_title _('Integrations') - page_title _('Integrations')
= render 'projects/hooks/index'
- if show_webhooks_moved_alert?
.gl-alert.gl-alert-info.js-webhooks-moved-alert.prepend-top-default{ role: 'alert', data: { feature_id: UserCalloutsHelper::WEBHOOKS_MOVED, dismiss_endpoint: user_callouts_path } }
= sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
%button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') }
= sprite_icon('close', size: 16, css_class: 'gl-icon')
.gl-alert-body
= _('Webhooks have moved. They can now be found under the Settings menu.')
.gl-alert-actions
= link_to _('Go to Webhooks'), project_hooks_path(@project), class: 'btn gl-alert-action btn-info new-gl-button'
= render 'projects/services/index' = render 'projects/services/index'
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
%section.settings.no-animate.js-error-tracking-settings %section.settings.no-animate.js-error-tracking-settings
.settings-header .settings-header
%h4 %h3{ :class => "h4" }
= _('Error Tracking') = _('Error Tracking')
%button.btn.js-settings-toggle{ type: 'button' } %button.btn.js-settings-toggle{ type: 'button' }
= _('Expand') = _('Expand')
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
%section.settings.no-animate.qa-incident-management-settings %section.settings.no-animate.qa-incident-management-settings
.settings-header .settings-header
%h4= _('Incidents') %h3{ :class => "h4" }= _('Incidents')
%button.btn.js-settings-toggle{ type: 'button' } %button.btn.js-settings-toggle{ type: 'button' }
= _('Expand') = _('Expand')
%p %p
......
...@@ -15,62 +15,62 @@ ...@@ -15,62 +15,62 @@
= form.check_box :push_events, class: 'form-check-input' = form.check_box :push_events, class: 'form-check-input'
= form.label :push_events, class: 'list-label form-check-label ml-1' do = form.label :push_events, class: 'list-label form-check-label ml-1' do
%strong Push events %strong Push events
%p.light.ml-1
This URL will be triggered by a push to the repository
= form.text_field :push_events_branch_filter, class: 'form-control', placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)' = form.text_field :push_events_branch_filter, class: 'form-control', placeholder: 'Branch name or wildcard pattern to trigger on (leave blank for all)'
%p.text-muted.ml-1
This URL will be triggered by a push to the repository
%li %li
= form.check_box :tag_push_events, class: 'form-check-input' = form.check_box :tag_push_events, class: 'form-check-input'
= form.label :tag_push_events, class: 'list-label form-check-label ml-1' do = form.label :tag_push_events, class: 'list-label form-check-label ml-1' do
%strong Tag push events %strong Tag push events
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when a new tag is pushed to the repository This URL will be triggered when a new tag is pushed to the repository
%li %li
= form.check_box :note_events, class: 'form-check-input' = form.check_box :note_events, class: 'form-check-input'
= form.label :note_events, class: 'list-label form-check-label ml-1' do = form.label :note_events, class: 'list-label form-check-label ml-1' do
%strong Comments %strong Comments
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when someone adds a comment This URL will be triggered when someone adds a comment
%li %li
= form.check_box :confidential_note_events, class: 'form-check-input' = form.check_box :confidential_note_events, class: 'form-check-input'
= form.label :confidential_note_events, class: 'list-label form-check-label ml-1' do = form.label :confidential_note_events, class: 'list-label form-check-label ml-1' do
%strong Confidential Comments %strong Confidential Comments
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when someone adds a comment on a confidential issue This URL will be triggered when someone adds a comment on a confidential issue
%li %li
= form.check_box :issues_events, class: 'form-check-input' = form.check_box :issues_events, class: 'form-check-input'
= form.label :issues_events, class: 'list-label form-check-label ml-1' do = form.label :issues_events, class: 'list-label form-check-label ml-1' do
%strong Issues events %strong Issues events
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when an issue is created/updated/merged This URL will be triggered when an issue is created/updated/merged
%li %li
= form.check_box :confidential_issues_events, class: 'form-check-input' = form.check_box :confidential_issues_events, class: 'form-check-input'
= form.label :confidential_issues_events, class: 'list-label form-check-label ml-1' do = form.label :confidential_issues_events, class: 'list-label form-check-label ml-1' do
%strong Confidential Issues events %strong Confidential Issues events
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when a confidential issue is created/updated/merged This URL will be triggered when a confidential issue is created/updated/merged
%li %li
= form.check_box :merge_requests_events, class: 'form-check-input' = form.check_box :merge_requests_events, class: 'form-check-input'
= form.label :merge_requests_events, class: 'list-label form-check-label ml-1' do = form.label :merge_requests_events, class: 'list-label form-check-label ml-1' do
%strong Merge request events %strong Merge request events
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when a merge request is created/updated/merged This URL will be triggered when a merge request is created/updated/merged
%li %li
= form.check_box :job_events, class: 'form-check-input' = form.check_box :job_events, class: 'form-check-input'
= form.label :job_events, class: 'list-label form-check-label ml-1' do = form.label :job_events, class: 'list-label form-check-label ml-1' do
%strong Job events %strong Job events
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when the job status changes This URL will be triggered when the job status changes
%li %li
= form.check_box :pipeline_events, class: 'form-check-input' = form.check_box :pipeline_events, class: 'form-check-input'
= form.label :pipeline_events, class: 'list-label form-check-label ml-1' do = form.label :pipeline_events, class: 'list-label form-check-label ml-1' do
%strong Pipeline events %strong Pipeline events
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when the pipeline status changes This URL will be triggered when the pipeline status changes
%li %li
= form.check_box :wiki_page_events, class: 'form-check-input' = form.check_box :wiki_page_events, class: 'form-check-input'
= form.label :wiki_page_events, class: 'list-label form-check-label ml-1' do = form.label :wiki_page_events, class: 'list-label form-check-label ml-1' do
%strong Wiki Page events %strong Wiki Page events
%p.light.ml-1 %p.text-muted.ml-1
This URL will be triggered when a wiki page is created/updated This URL will be triggered when a wiki page is created/updated
.form-group .form-group
= form.label :enable_ssl_verification, 'SSL verification', class: 'label-bold checkbox' = form.label :enable_ssl_verification, 'SSL verification', class: 'label-bold checkbox'
......
---
title: Decouple Webhooks from Integrations within Project > Settings
merge_request: 23136
author:
type: changed
---
title: Allow group/project board to be queried by ID via GraphQL
merge_request: 24825
author:
type: added
---
title: Fix pipeline details page initialisation on invalid pipeline
merge_request: 25302
author: Fabio Huser
type: fixed
...@@ -2770,6 +2770,16 @@ type Group { ...@@ -2770,6 +2770,16 @@ type Group {
""" """
avatarUrl: String avatarUrl: String
"""
A single board of the group
"""
board(
"""
Find a board by its ID
"""
id: ID
): Board
""" """
Boards of the group Boards of the group
""" """
...@@ -2789,6 +2799,11 @@ type Group { ...@@ -2789,6 +2799,11 @@ type Group {
""" """
first: Int first: Int
"""
Find a board by its ID
"""
id: ID
""" """
Returns the last _n_ elements from the list. Returns the last _n_ elements from the list.
""" """
...@@ -5254,6 +5269,16 @@ type Project { ...@@ -5254,6 +5269,16 @@ type Project {
""" """
avatarUrl: String avatarUrl: String
"""
A single board of the project
"""
board(
"""
Find a board by its ID
"""
id: ID
): Board
""" """
Boards of the project Boards of the project
""" """
...@@ -5273,6 +5298,11 @@ type Project { ...@@ -5273,6 +5298,11 @@ type Project {
""" """
first: Int first: Int
"""
Find a board by its ID
"""
id: ID
""" """
Returns the last _n_ elements from the list. Returns the last _n_ elements from the list.
""" """
......
...@@ -368,10 +368,43 @@ ...@@ -368,10 +368,43 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "board",
"description": "A single board of the project",
"args": [
{
"name": "id",
"description": "Find a board by its ID",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Board",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "boards", "name": "boards",
"description": "Boards of the project", "description": "Boards of the project",
"args": [ "args": [
{
"name": "id",
"description": "Find a board by its ID",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "after", "name": "after",
"description": "Returns the elements in the list that come after the specified cursor.", "description": "Returns the elements in the list that come after the specified cursor.",
...@@ -3175,10 +3208,43 @@ ...@@ -3175,10 +3208,43 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "board",
"description": "A single board of the group",
"args": [
{
"name": "id",
"description": "Find a board by its ID",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Board",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "boards", "name": "boards",
"description": "Boards of the group", "description": "Boards of the group",
"args": [ "args": [
{
"name": "id",
"description": "Find a board by its ID",
"type": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "after", "name": "after",
"description": "Returns the elements in the list that come after the specified cursor.", "description": "Returns the elements in the list that come after the specified cursor.",
......
...@@ -426,6 +426,7 @@ Autogenerated return type of EpicTreeReorder ...@@ -426,6 +426,7 @@ Autogenerated return type of EpicTreeReorder
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `autoDevopsEnabled` | Boolean | Indicates whether Auto DevOps is enabled for all projects within this group | | `autoDevopsEnabled` | Boolean | Indicates whether Auto DevOps is enabled for all projects within this group |
| `avatarUrl` | String | Avatar URL of the group | | `avatarUrl` | String | Avatar URL of the group |
| `board` | Board | A single board of the group |
| `description` | String | Description of the namespace | | `description` | String | Description of the namespace |
| `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` | | `descriptionHtml` | String | The GitLab Flavored Markdown rendering of `description` |
| `emailsDisabled` | Boolean | Indicates if a group has email notifications disabled | | `emailsDisabled` | Boolean | Indicates if a group has email notifications disabled |
...@@ -801,6 +802,7 @@ Information about pagination in a connection. ...@@ -801,6 +802,7 @@ Information about pagination in a connection.
| `archived` | Boolean | Indicates the archived status of the project | | `archived` | Boolean | Indicates the archived status of the project |
| `autocloseReferencedIssues` | Boolean | Indicates if issues referenced by merge requests and commits within the default branch are closed automatically | | `autocloseReferencedIssues` | Boolean | Indicates if issues referenced by merge requests and commits within the default branch are closed automatically |
| `avatarUrl` | String | URL to avatar image file of the project | | `avatarUrl` | String | URL to avatar image file of the project |
| `board` | Board | A single board of the project |
| `containerRegistryEnabled` | Boolean | Indicates if the project stores Docker container images in a container registry | | `containerRegistryEnabled` | Boolean | Indicates if the project stores Docker container images in a container registry |
| `createdAt` | Time | Timestamp of the project creation | | `createdAt` | Time | Timestamp of the project creation |
| `description` | String | Short description of the project | | `description` | String | Short description of the project |
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
## Issue tracker guidelines ## Issue tracker guidelines
**[Search the issue tracker](https://gitlab.com/gitlab-org/gitlab-foss/issues)** for similar entries before **[Search the issue tracker](https://gitlab.com/gitlab-org/gitlab/issues)** for similar entries before
submitting your own, there's a good chance somebody else had the same issue or submitting your own, there's a good chance somebody else had the same issue or
feature proposal. Show your support with an award emoji and/or join the feature proposal. Show your support with an award emoji and/or join the
discussion. discussion.
...@@ -35,7 +35,7 @@ project. ...@@ -35,7 +35,7 @@ project.
## Labels ## Labels
To allow for asynchronous issue handling, we use [milestones](https://gitlab.com/groups/gitlab-org/-/milestones) To allow for asynchronous issue handling, we use [milestones](https://gitlab.com/groups/gitlab-org/-/milestones)
and [labels](https://gitlab.com/gitlab-org/gitlab-foss/-/labels). Leads and product managers handle most of the and [labels](https://gitlab.com/gitlab-org/gitlab/-/labels). Leads and product managers handle most of the
scheduling into milestones. Labelling is a task for everyone. scheduling into milestones. Labelling is a task for everyone.
Most issues will have labels for at least one of the following: Most issues will have labels for at least one of the following:
...@@ -53,7 +53,7 @@ Most issues will have labels for at least one of the following: ...@@ -53,7 +53,7 @@ Most issues will have labels for at least one of the following:
- Severity: ~`S1`, `~S2`, `~S3`, `~S4` - Severity: ~`S1`, `~S2`, `~S3`, `~S4`
All labels, their meaning and priority are defined on the All labels, their meaning and priority are defined on the
[labels page](https://gitlab.com/gitlab-org/gitlab-foss/-/labels). [labels page](https://gitlab.com/gitlab-org/gitlab/-/labels).
If you come across an issue that has none of these, and you're allowed to set If you come across an issue that has none of these, and you're allowed to set
labels, you can _always_ add the team and type, and often also the subject. labels, you can _always_ add the team and type, and often also the subject.
...@@ -372,14 +372,11 @@ A recent example of this was the issue for ...@@ -372,14 +372,11 @@ A recent example of this was the issue for
## Feature proposals ## Feature proposals
To create a feature proposal for CE, open an issue on the To create a feature proposal, open an issue on the
[issue tracker of CE](https://gitlab.com/gitlab-org/gitlab-foss/issues). [issue tracker](https://gitlab.com/gitlab-org/gitlab/issues).
For feature proposals for EE, open an issue on the
[issue tracker of EE](https://gitlab.com/gitlab-org/gitlab/issues).
In order to help track the feature proposals, we have created a In order to help track the feature proposals, we have created a
[`feature`](https://gitlab.com/gitlab-org/gitlab-foss/issues?label_name=feature) label. For the time being, users that are not members [`feature`](https://gitlab.com/gitlab-org/gitlab/issues?label_name=feature) label. For the time being, users that are not members
of the project cannot add labels. You can instead ask one of the [core team](https://about.gitlab.com/community/core-team/) of the project cannot add labels. You can instead ask one of the [core team](https://about.gitlab.com/community/core-team/)
members to add the label ~feature to the issue or add the following members to add the label ~feature to the issue or add the following
code snippet right after your description in a new line: `~feature`. code snippet right after your description in a new line: `~feature`.
...@@ -441,7 +438,7 @@ addressed. ...@@ -441,7 +438,7 @@ addressed.
## Technical and UX debt ## Technical and UX debt
In order to track things that can be improved in GitLab's codebase, In order to track things that can be improved in GitLab's codebase,
we use the ~"technical debt" label in [GitLab's issue tracker](https://gitlab.com/gitlab-org/gitlab-foss/issues). we use the ~"technical debt" label in [GitLab's issue tracker](https://gitlab.com/gitlab-org/gitlab/issues).
For missed user experience requirements, we use the ~"UX debt" label. For missed user experience requirements, we use the ~"UX debt" label.
These labels should be added to issues that describe things that can be improved, These labels should be added to issues that describe things that can be improved,
......
...@@ -472,7 +472,7 @@ end ...@@ -472,7 +472,7 @@ end
``` ```
If a computed update is needed, the value can be wrapped in `Arel.sql`, so Arel If a computed update is needed, the value can be wrapped in `Arel.sql`, so Arel
treats it as an SQL literal. It's also a required deprecation for [Rails 6](https://gitlab.com/gitlab-org/gitlab-foss/issues/61451). treats it as an SQL literal. It's also a required deprecation for [Rails 6](https://gitlab.com/gitlab-org/gitlab/issues/28497).
The below example is the same as the one above, but The below example is the same as the one above, but
the value is set to the product of the `bar` and `baz` columns: the value is set to the product of the `bar` and `baz` columns:
......
...@@ -30,11 +30,11 @@ People are saying multiple inheritance is bad. Mixing multiple modules with ...@@ -30,11 +30,11 @@ People are saying multiple inheritance is bad. Mixing multiple modules with
multiple instance variables scattering everywhere suffer from the same issue. multiple instance variables scattering everywhere suffer from the same issue.
The same applies to `ActiveSupport::Concern`. See: The same applies to `ActiveSupport::Concern`. See:
[Consider replacing concerns with dedicated classes & composition]( [Consider replacing concerns with dedicated classes & composition](
https://gitlab.com/gitlab-org/gitlab-foss/issues/23786) https://gitlab.com/gitlab-org/gitlab/issues/16270)
There's also a similar idea: There's also a similar idea:
[Use decorators and interface segregation to solve overgrowing models problem]( [Use decorators and interface segregation to solve overgrowing models problem](
https://gitlab.com/gitlab-org/gitlab-foss/issues/13484) https://gitlab.com/gitlab-org/gitlab/issues/14235)
Note that `included` doesn't solve the whole issue. They define the Note that `included` doesn't solve the whole issue. They define the
dependencies, but they still allow each modules to talk implicitly via the dependencies, but they still allow each modules to talk implicitly via the
......
...@@ -25,7 +25,7 @@ by [`Namespaces#with_statistics`](https://gitlab.com/gitlab-org/gitlab/blob/4ab5 ...@@ -25,7 +25,7 @@ by [`Namespaces#with_statistics`](https://gitlab.com/gitlab-org/gitlab/blob/4ab5
Additionally, the pattern that is currently used to update the project statistics Additionally, the pattern that is currently used to update the project statistics
(the callback) doesn't scale adequately. It is currently one of the largest (the callback) doesn't scale adequately. It is currently one of the largest
[database queries transactions on production](https://gitlab.com/gitlab-org/gitlab-foss/issues/62488) [database queries transactions on production](https://gitlab.com/gitlab-org/gitlab/issues/29070)
that takes the most time overall. We can't add one more query to it as that takes the most time overall. We can't add one more query to it as
it will increase the transaction's length. it will increase the transaction's length.
...@@ -142,7 +142,7 @@ but we refresh them through Sidekiq jobs and in different transactions: ...@@ -142,7 +142,7 @@ but we refresh them through Sidekiq jobs and in different transactions:
1. Create a second table (`namespace_aggregation_schedules`) with two columns `id` and `namespace_id`. 1. Create a second table (`namespace_aggregation_schedules`) with two columns `id` and `namespace_id`.
1. Whenever the statistics of a project changes, insert a row into `namespace_aggregation_schedules` 1. Whenever the statistics of a project changes, insert a row into `namespace_aggregation_schedules`
- We don't insert a new row if there's already one related to the root namespace. - We don't insert a new row if there's already one related to the root namespace.
- Keeping in mind the length of the transaction that involves updating `project_statistics`(<https://gitlab.com/gitlab-org/gitlab-foss/issues/62488>), the insertion should be done in a different transaction and through a Sidekiq Job. - Keeping in mind the length of the transaction that involves updating `project_statistics`(<https://gitlab.com/gitlab-org/gitlab/issues/29070>), the insertion should be done in a different transaction and through a Sidekiq Job.
1. After inserting the row, we schedule another worker to be executed async at two different moments: 1. After inserting the row, we schedule another worker to be executed async at two different moments:
- One enqueued for immediate execution and another one scheduled in `1.5h` hours. - One enqueued for immediate execution and another one scheduled in `1.5h` hours.
- We only schedule the jobs, if we can obtain a `1.5h` lease on Redis on a key based on the root namespace ID. - We only schedule the jobs, if we can obtain a `1.5h` lease on Redis on a key based on the root namespace ID.
...@@ -162,7 +162,7 @@ This implementation has the following benefits: ...@@ -162,7 +162,7 @@ This implementation has the following benefits:
The only downside of this approach is that namespaces' statistics are updated up to `1.5` hours after the change is done, The only downside of this approach is that namespaces' statistics are updated up to `1.5` hours after the change is done,
which means there's a time window in which the statistics are inaccurate. Because we're still not which means there's a time window in which the statistics are inaccurate. Because we're still not
[enforcing storage limits](https://gitlab.com/gitlab-org/gitlab-foss/issues/30421), this is not a major problem. [enforcing storage limits](https://gitlab.com/gitlab-org/gitlab/issues/17664), this is not a major problem.
## Conclusion ## Conclusion
......
...@@ -45,7 +45,7 @@ They are available **per project** for GitLab Community Edition, ...@@ -45,7 +45,7 @@ They are available **per project** for GitLab Community Edition,
and **per project and per group** for **GitLab Enterprise Edition**. and **per project and per group** for **GitLab Enterprise Edition**.
Navigate to the webhooks page by going to your project's Navigate to the webhooks page by going to your project's
**Settings ➔ Integrations**. **Settings ➔ Webhooks**.
## Maximum number of webhooks (per tier) ## Maximum number of webhooks (per tier)
......
...@@ -6,8 +6,8 @@ type: reference ...@@ -6,8 +6,8 @@ type: reference
> [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/15643) in GitLab 11.7. > [Introduced](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/15643) in GitLab 11.7.
GitLab supports using [Git push options](https://git-scm.com/docs/git-push#Documentation/git-push.txt--oltoptiongt) GitLab supports using client-side [Git push options](https://git-scm.com/docs/git-push#Documentation/git-push.txt--oltoptiongt)
to perform various actions at the same time as pushing changes. to perform various actions at the same time as pushing changes. Additionally, [Push Rules](https://docs.gitlab.com/ee/push_rules/push_rules.html) offer server-side control and enforcement options.
Currently, there are push options available for: Currently, there are push options available for:
......
...@@ -6997,9 +6997,6 @@ msgstr "" ...@@ -6997,9 +6997,6 @@ msgstr ""
msgid "Edit Pipeline Schedule %{id}" msgid "Edit Pipeline Schedule %{id}"
msgstr "" msgstr ""
msgid "Edit Project Hook"
msgstr ""
msgid "Edit Release" msgid "Edit Release"
msgstr "" msgstr ""
...@@ -9451,6 +9448,9 @@ msgstr "" ...@@ -9451,6 +9448,9 @@ msgstr ""
msgid "Go to %{link_to_google_takeout}." msgid "Go to %{link_to_google_takeout}."
msgstr "" msgstr ""
msgid "Go to Webhooks"
msgstr ""
msgid "Go to commits" msgid "Go to commits"
msgstr "" msgstr ""
...@@ -10607,10 +10607,13 @@ msgstr "" ...@@ -10607,10 +10607,13 @@ msgstr ""
msgid "Instance license" msgid "Instance license"
msgstr "" msgstr ""
msgid "Integration Settings"
msgstr ""
msgid "Integrations" msgid "Integrations"
msgstr "" msgstr ""
msgid "Integrations Settings" msgid "Integrations allow you to integrate GitLab with other applications"
msgstr "" msgstr ""
msgid "Interested parties can even contribute by pushing commits if they want to." msgid "Interested parties can even contribute by pushing commits if they want to."
...@@ -14780,9 +14783,6 @@ msgstr "" ...@@ -14780,9 +14783,6 @@ msgstr ""
msgid "Project Files" msgid "Project Files"
msgstr "" msgstr ""
msgid "Project Hooks"
msgstr ""
msgid "Project ID" msgid "Project ID"
msgstr "" msgstr ""
...@@ -14945,30 +14945,18 @@ msgstr "" ...@@ -14945,30 +14945,18 @@ msgstr ""
msgid "ProjectService|Comment will be posted on each event" msgid "ProjectService|Comment will be posted on each event"
msgstr "" msgstr ""
msgid "ProjectService|Integrations"
msgstr ""
msgid "ProjectService|Last edit" msgid "ProjectService|Last edit"
msgstr "" msgstr ""
msgid "ProjectService|Perform common operations on GitLab project: %{project_name}" msgid "ProjectService|Perform common operations on GitLab project: %{project_name}"
msgstr "" msgstr ""
msgid "ProjectService|Project services"
msgstr ""
msgid "ProjectService|Project services allow you to integrate GitLab with other applications"
msgstr ""
msgid "ProjectService|Service" msgid "ProjectService|Service"
msgstr "" msgstr ""
msgid "ProjectService|Services" msgid "ProjectService|Services"
msgstr "" msgstr ""
msgid "ProjectService|Settings"
msgstr ""
msgid "ProjectService|To set up this service:" msgid "ProjectService|To set up this service:"
msgstr "" msgstr ""
...@@ -21811,6 +21799,15 @@ msgstr "" ...@@ -21811,6 +21799,15 @@ msgstr ""
msgid "WebIDE|Merge request" msgid "WebIDE|Merge request"
msgstr "" msgstr ""
msgid "Webhook"
msgstr ""
msgid "Webhook Logs"
msgstr ""
msgid "Webhook Settings"
msgstr ""
msgid "Webhooks" msgid "Webhooks"
msgstr "" msgstr ""
...@@ -21820,6 +21817,9 @@ msgstr "" ...@@ -21820,6 +21817,9 @@ msgstr ""
msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group." msgid "Webhooks allow you to trigger a URL if, for example, new code is pushed or a new issue is created. You can configure webhooks to listen for specific events like pushes, issues or merge requests. Group webhooks will apply to all projects in a group, allowing you to standardize webhook functionality across your entire group."
msgstr "" msgstr ""
msgid "Webhooks have moved. They can now be found under the Settings menu."
msgstr ""
msgid "Wednesday" msgid "Wednesday"
msgstr "" msgstr ""
......
...@@ -12,12 +12,11 @@ describe Projects::HooksController do ...@@ -12,12 +12,11 @@ describe Projects::HooksController do
end end
describe '#index' do describe '#index' do
it 'redirects to settings/integrations page' do it 'renders index with 200 status code' do
get(:index, params: { namespace_id: project.namespace, project_id: project }) get :index, params: { namespace_id: project.namespace, project_id: project }
expect(response).to redirect_to( expect(response).to have_gitlab_http_status(:ok)
project_settings_integrations_path(project) expect(response).to render_template(:index)
)
end end
end end
......
...@@ -22,6 +22,7 @@ FactoryBot.define do ...@@ -22,6 +22,7 @@ FactoryBot.define do
factory :ci_pipeline do factory :ci_pipeline do
trait :invalid do trait :invalid do
status { :failed }
yaml_errors { 'invalid YAML' } yaml_errors { 'invalid YAML' }
failure_reason { :config_error } failure_reason { :config_error }
end end
......
...@@ -88,6 +88,7 @@ describe 'Project navbar' do ...@@ -88,6 +88,7 @@ describe 'Project navbar' do
_('General'), _('General'),
_('Members'), _('Members'),
_('Integrations'), _('Integrations'),
_('Webhooks'),
_('Repository'), _('Repository'),
_('CI / CD'), _('CI / CD'),
_('Operations'), _('Operations'),
......
...@@ -1077,8 +1077,6 @@ describe 'Pipeline', :js do ...@@ -1077,8 +1077,6 @@ describe 'Pipeline', :js do
end end
context 'when pipeline has configuration errors' do context 'when pipeline has configuration errors' do
include_context 'pipeline builds'
let(:pipeline) do let(:pipeline) do
create(:ci_pipeline, create(:ci_pipeline,
:invalid, :invalid,
...@@ -1119,6 +1117,10 @@ describe 'Pipeline', :js do ...@@ -1119,6 +1117,10 @@ describe 'Pipeline', :js do
%Q{span[title="#{pipeline.present.failure_reason}"]}) %Q{span[title="#{pipeline.present.failure_reason}"]})
end end
end end
it 'contains a pipeline header with title' do
expect(page).to have_content "Pipeline ##{pipeline.id}"
end
end end
context 'when pipeline is stuck' do context 'when pipeline is stuck' do
......
...@@ -14,7 +14,7 @@ describe 'User views services' do ...@@ -14,7 +14,7 @@ describe 'User views services' do
end end
it 'shows the list of available services' do it 'shows the list of available services' do
expect(page).to have_content('Project services') expect(page).to have_content('Integrations')
expect(page).to have_content('Campfire') expect(page).to have_content('Campfire')
expect(page).to have_content('HipChat') expect(page).to have_content('HipChat')
expect(page).to have_content('Assembla') expect(page).to have_content('Assembla')
......
...@@ -35,7 +35,7 @@ describe 'Projects > Settings > For a forked project', :js do ...@@ -35,7 +35,7 @@ describe 'Projects > Settings > For a forked project', :js do
end end
it 'renders form for incident management' do it 'renders form for incident management' do
expect(page).to have_selector('h4', text: 'Incidents') expect(page).to have_selector('h3', text: 'Incidents')
end end
it 'sets correct default values' do it 'sets correct default values' do
......
...@@ -2,11 +2,10 @@ ...@@ -2,11 +2,10 @@
require 'spec_helper' require 'spec_helper'
describe 'Projects > Settings > Integration settings' do describe 'Projects > Settings > Webhook Settings' do
let(:project) { create(:project) } let(:project) { create(:project) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:role) { :developer } let(:webhooks_path) { project_hooks_path(project) }
let(:integrations_path) { project_settings_integrations_path(project) }
before do before do
sign_in(user) sign_in(user)
...@@ -17,7 +16,7 @@ describe 'Projects > Settings > Integration settings' do ...@@ -17,7 +16,7 @@ describe 'Projects > Settings > Integration settings' do
let(:role) { :developer } let(:role) { :developer }
it 'to be disallowed to view' do it 'to be disallowed to view' do
visit integrations_path visit webhooks_path
expect(page.status_code).to eq(404) expect(page.status_code).to eq(404)
end end
...@@ -33,7 +32,7 @@ describe 'Projects > Settings > Integration settings' do ...@@ -33,7 +32,7 @@ describe 'Projects > Settings > Integration settings' do
it 'show list of webhooks' do it 'show list of webhooks' do
hook hook
visit integrations_path visit webhooks_path
expect(page.status_code).to eq(200) expect(page.status_code).to eq(200)
expect(page).to have_content(hook.url) expect(page).to have_content(hook.url)
...@@ -49,7 +48,7 @@ describe 'Projects > Settings > Integration settings' do ...@@ -49,7 +48,7 @@ describe 'Projects > Settings > Integration settings' do
end end
it 'create webhook' do it 'create webhook' do
visit integrations_path visit webhooks_path
fill_in 'hook_url', with: url fill_in 'hook_url', with: url
check 'Tag push events' check 'Tag push events'
...@@ -68,7 +67,7 @@ describe 'Projects > Settings > Integration settings' do ...@@ -68,7 +67,7 @@ describe 'Projects > Settings > Integration settings' do
it 'edit existing webhook' do it 'edit existing webhook' do
hook hook
visit integrations_path visit webhooks_path
click_link 'Edit' click_link 'Edit'
fill_in 'hook_url', with: url fill_in 'hook_url', with: url
...@@ -81,25 +80,25 @@ describe 'Projects > Settings > Integration settings' do ...@@ -81,25 +80,25 @@ describe 'Projects > Settings > Integration settings' do
it 'test existing webhook', :js do it 'test existing webhook', :js do
WebMock.stub_request(:post, hook.url) WebMock.stub_request(:post, hook.url)
visit integrations_path visit webhooks_path
find('.hook-test-button.dropdown').click find('.hook-test-button.dropdown').click
click_link 'Push events' click_link 'Push events'
expect(current_path).to eq(integrations_path) expect(current_path).to eq(webhooks_path)
end end
context 'delete existing webhook' do context 'delete existing webhook' do
it 'from webhooks list page' do it 'from webhooks list page' do
hook hook
visit integrations_path visit webhooks_path
expect { click_link 'Delete' }.to change(ProjectHook, :count).by(-1) expect { click_link 'Delete' }.to change(ProjectHook, :count).by(-1)
end end
it 'from webhook edit page' do it 'from webhook edit page' do
hook hook
visit integrations_path visit webhooks_path
click_link 'Edit' click_link 'Edit'
expect { click_link 'Delete' }.to change(ProjectHook, :count).by(-1) expect { click_link 'Delete' }.to change(ProjectHook, :count).by(-1)
......
...@@ -8,13 +8,13 @@ exports[`grafana integration component default state to match the default snapsh ...@@ -8,13 +8,13 @@ exports[`grafana integration component default state to match the default snapsh
<div <div
class="settings-header" class="settings-header"
> >
<h4 <h3
class="js-section-header" class="js-section-header h4"
> >
Grafana Authentication Grafana Authentication
</h4> </h3>
<gl-button-stub <gl-button-stub
class="js-settings-toggle" class="js-settings-toggle"
......
...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { setTestTimeout } from 'helpers/timeout'; import { setTestTimeout } from 'helpers/timeout';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import { cloneDeep } from 'lodash';
import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper';
import { chartColorValues } from '~/monitoring/constants'; import { chartColorValues } from '~/monitoring/constants';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
...@@ -32,9 +33,19 @@ jest.mock('~/lib/utils/icon_utils', () => ({ ...@@ -32,9 +33,19 @@ jest.mock('~/lib/utils/icon_utils', () => ({
describe('Time series component', () => { describe('Time series component', () => {
let mockGraphData; let mockGraphData;
let makeTimeSeriesChart;
let store; let store;
const makeTimeSeriesChart = (graphData, type) =>
shallowMount(TimeSeries, {
propsData: {
graphData: { ...graphData, type },
deploymentData: store.state.monitoringDashboard.deploymentData,
projectPath: `${mockHost}${mockProjectDir}`,
},
store,
});
describe('With a single time series', () => {
beforeEach(() => { beforeEach(() => {
setTestTimeout(1000); setTestTimeout(1000);
...@@ -55,16 +66,6 @@ describe('Time series component', () => { ...@@ -55,16 +66,6 @@ describe('Time series component', () => {
// Pick the second panel group and the first panel in it // Pick the second panel group and the first panel in it
[mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[1].panels; [mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[1].panels;
makeTimeSeriesChart = (graphData, type) =>
shallowMount(TimeSeries, {
propsData: {
graphData: { ...graphData, type },
deploymentData: store.state.monitoringDashboard.deploymentData,
projectPath: `${mockHost}${mockProjectDir}`,
},
store,
});
}); });
describe('general functions', () => { describe('general functions', () => {
...@@ -531,4 +532,66 @@ describe('Time series component', () => { ...@@ -531,4 +532,66 @@ describe('Time series component', () => {
}); });
}); });
}); });
});
describe('with multiple time series', () => {
const mockedResultMultipleSeries = [];
const [, , panelData] = metricsDashboardPayload.panel_groups[1].panels;
for (let i = 0; i < panelData.metrics.length; i += 1) {
mockedResultMultipleSeries.push(cloneDeep(mockedQueryResultPayload));
mockedResultMultipleSeries[
i
].metricId = `${panelData.metrics[i].metric_id}_${panelData.metrics[i].id}`;
}
beforeEach(() => {
setTestTimeout(1000);
store = createStore();
store.commit(
`monitoringDashboard/${types.RECEIVE_METRICS_DATA_SUCCESS}`,
metricsDashboardPayload,
);
store.commit(`monitoringDashboard/${types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS}`, deploymentData);
// Mock data contains the metric_id for a multiple time series panel
for (let i = 0; i < panelData.metrics.length; i += 1) {
store.commit(
`monitoringDashboard/${types.RECEIVE_METRIC_RESULT_SUCCESS}`,
mockedResultMultipleSeries[i],
);
}
// Pick the second panel group and the second panel in it
[, , mockGraphData] = store.state.monitoringDashboard.dashboard.panel_groups[1].panels;
});
describe('General functions', () => {
let timeSeriesChart;
beforeEach(done => {
timeSeriesChart = makeTimeSeriesChart(mockGraphData, 'area-chart');
timeSeriesChart.vm.$nextTick(done);
});
describe('computed', () => {
let chartData;
beforeEach(() => {
({ chartData } = timeSeriesChart.vm);
});
it('should contain different colors for each time series', () => {
expect(chartData[0].lineStyle.color).toBe('#1f78d1');
expect(chartData[1].lineStyle.color).toBe('#1aaa55');
expect(chartData[2].lineStyle.color).toBe('#fc9403');
expect(chartData[3].lineStyle.color).toBe('#6d49cb');
expect(chartData[4].lineStyle.color).toBe('#1f78d1');
});
});
});
});
}); });
...@@ -22,7 +22,7 @@ import { ...@@ -22,7 +22,7 @@ import {
} from '../mock_data'; } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
const expectedPanelCount = 2; const expectedPanelCount = 3;
describe('Dashboard', () => { describe('Dashboard', () => {
let store; let store;
......
...@@ -513,6 +513,48 @@ export const metricsDashboardPayload = { ...@@ -513,6 +513,48 @@ export const metricsDashboardPayload = {
}, },
], ],
}, },
{
title: 'memories',
type: 'area-chart',
y_label: 'memories',
metrics: [
{
id: 'metric_of_ages_1000',
label: 'memory_1000',
unit: 'count',
prometheus_endpoint_path: '/root',
metric_id: 20,
},
{
id: 'metric_of_ages_1001',
label: 'memory_1000',
unit: 'count',
prometheus_endpoint_path: '/root',
metric_id: 21,
},
{
id: 'metric_of_ages_1002',
label: 'memory_1000',
unit: 'count',
prometheus_endpoint_path: '/root',
metric_id: 22,
},
{
id: 'metric_of_ages_1003',
label: 'memory_1000',
unit: 'count',
prometheus_endpoint_path: '/root',
metric_id: 23,
},
{
id: 'metric_of_ages_1004',
label: 'memory_1004',
unit: 'count',
prometheus_endpoint_path: '/root',
metric_id: 24,
},
],
},
], ],
}, },
], ],
......
...@@ -50,9 +50,10 @@ describe('Monitoring mutations', () => { ...@@ -50,9 +50,10 @@ describe('Monitoring mutations', () => {
expect(groups[0].panels).toHaveLength(1); expect(groups[0].panels).toHaveLength(1);
expect(groups[0].panels[0].metrics).toHaveLength(1); expect(groups[0].panels[0].metrics).toHaveLength(1);
expect(groups[1].panels).toHaveLength(2); expect(groups[1].panels).toHaveLength(3);
expect(groups[1].panels[0].metrics).toHaveLength(1); expect(groups[1].panels[0].metrics).toHaveLength(1);
expect(groups[1].panels[1].metrics).toHaveLength(1); expect(groups[1].panels[1].metrics).toHaveLength(1);
expect(groups[1].panels[2].metrics).toHaveLength(5);
}); });
it('assigns metrics a metric id', () => { it('assigns metrics a metric id', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload); mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, payload);
......
...@@ -13,7 +13,7 @@ describe('Release edit component', () => { ...@@ -13,7 +13,7 @@ describe('Release edit component', () => {
beforeEach(() => { beforeEach(() => {
gon.api_version = 'v4'; gon.api_version = 'v4';
releaseClone = JSON.parse(JSON.stringify(convertObjectPropsToCamelCase(release))); releaseClone = convertObjectPropsToCamelCase(release, { deep: true });
state = { state = {
release: releaseClone, release: releaseClone,
......
...@@ -2,12 +2,14 @@ import { mount } from '@vue/test-utils'; ...@@ -2,12 +2,14 @@ import { mount } from '@vue/test-utils';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import { truncateSha } from '~/lib/utils/text_utility'; import { truncateSha } from '~/lib/utils/text_utility';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { release } from '../mock_data'; import { release as originalRelease } from '../mock_data';
import EvidenceBlock from '~/releases/components/evidence_block.vue'; import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
describe('Evidence Block', () => { describe('Evidence Block', () => {
let wrapper; let wrapper;
let release;
const factory = (options = {}) => { const factory = (options = {}) => {
wrapper = mount(EvidenceBlock, { wrapper = mount(EvidenceBlock, {
...@@ -16,6 +18,8 @@ describe('Evidence Block', () => { ...@@ -16,6 +18,8 @@ describe('Evidence Block', () => {
}; };
beforeEach(() => { beforeEach(() => {
release = convertObjectPropsToCamelCase(originalRelease, { deep: true });
factory({ factory({
propsData: { propsData: {
release, release,
...@@ -32,7 +36,7 @@ describe('Evidence Block', () => { ...@@ -32,7 +36,7 @@ describe('Evidence Block', () => {
}); });
it('renders the title for the dowload link', () => { it('renders the title for the dowload link', () => {
expect(wrapper.find(GlLink).text()).toBe(`${release.tag_name}-evidence.json`); expect(wrapper.find(GlLink).text()).toBe(`${release.tagName}-evidence.json`);
}); });
it('renders the correct hover text for the download', () => { it('renders the correct hover text for the download', () => {
...@@ -40,19 +44,19 @@ describe('Evidence Block', () => { ...@@ -40,19 +44,19 @@ describe('Evidence Block', () => {
}); });
it('renders the correct file link for download', () => { it('renders the correct file link for download', () => {
expect(wrapper.find(GlLink).attributes().download).toBe(`${release.tag_name}-evidence.json`); expect(wrapper.find(GlLink).attributes().download).toBe(`${release.tagName}-evidence.json`);
}); });
describe('sha text', () => { describe('sha text', () => {
it('renders the short sha initially', () => { it('renders the short sha initially', () => {
expect(wrapper.find('.js-short').text()).toBe(truncateSha(release.evidence_sha)); expect(wrapper.find('.js-short').text()).toBe(truncateSha(release.evidenceSha));
}); });
it('renders the long sha after expansion', () => { it('renders the long sha after expansion', () => {
wrapper.find('.js-text-expander-prepend').trigger('click'); wrapper.find('.js-text-expander-prepend').trigger('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.find('.js-expanded').text()).toBe(release.evidence_sha); expect(wrapper.find('.js-expanded').text()).toBe(release.evidenceSha);
}); });
}); });
}); });
...@@ -68,7 +72,7 @@ describe('Evidence Block', () => { ...@@ -68,7 +72,7 @@ describe('Evidence Block', () => {
it('copies the sha', () => { it('copies the sha', () => {
expect(wrapper.find(ClipboardButton).attributes('data-clipboard-text')).toBe( expect(wrapper.find(ClipboardButton).attributes('data-clipboard-text')).toBe(
release.evidence_sha, release.evidenceSha,
); );
}); });
}); });
......
...@@ -24,7 +24,7 @@ describe('Release block footer', () => { ...@@ -24,7 +24,7 @@ describe('Release block footer', () => {
const factory = (props = {}) => { const factory = (props = {}) => {
wrapper = mount(ReleaseBlockFooter, { wrapper = mount(ReleaseBlockFooter, {
propsData: { propsData: {
...convertObjectPropsToCamelCase(releaseClone), ...convertObjectPropsToCamelCase(releaseClone, { deep: true }),
...props, ...props,
}, },
}); });
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { cloneDeep, merge } from 'lodash'; import { merge } from 'lodash';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import ReleaseBlockHeader from '~/releases/components/release_block_header.vue'; import ReleaseBlockHeader from '~/releases/components/release_block_header.vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
...@@ -18,9 +18,7 @@ describe('Release block header', () => { ...@@ -18,9 +18,7 @@ describe('Release block header', () => {
}; };
beforeEach(() => { beforeEach(() => {
release = convertObjectPropsToCamelCase(cloneDeep(originalRelease), { release = convertObjectPropsToCamelCase(originalRelease, { deep: true });
ignoreKeyNames: ['_links'],
});
}); });
afterEach(() => { afterEach(() => {
...@@ -39,13 +37,13 @@ describe('Release block header', () => { ...@@ -39,13 +37,13 @@ describe('Release block header', () => {
const link = findHeaderLink(); const link = findHeaderLink();
expect(link.text()).toBe(release.name); expect(link.text()).toBe(release.name);
expect(link.attributes('href')).toBe(release._links.self); expect(link.attributes('href')).toBe(release.Links.self);
}); });
}); });
describe('when _links.self is missing', () => { describe('when _links.self is missing', () => {
beforeEach(() => { beforeEach(() => {
factory({ _links: { self: null } }); factory({ Links: { self: null } });
}); });
it('renders the title as text', () => { it('renders the title as text', () => {
......
...@@ -2,12 +2,13 @@ import { mount } from '@vue/test-utils'; ...@@ -2,12 +2,13 @@ import { mount } from '@vue/test-utils';
import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui'; import { GlProgressBar, GlLink, GlBadge, GlButton } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper'; import { trimText } from 'helpers/text_helper';
import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue'; import ReleaseBlockMilestoneInfo from '~/releases/components/release_block_milestone_info.vue';
import { milestones } from '../mock_data'; import { milestones as originalMilestones } from '../mock_data';
import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/constants'; import { MAX_MILESTONES_TO_DISPLAY } from '~/releases/constants';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
describe('Release block milestone info', () => { describe('Release block milestone info', () => {
let wrapper; let wrapper;
let milestonesClone; let milestones;
const factory = milestonesProp => { const factory = milestonesProp => {
wrapper = mount(ReleaseBlockMilestoneInfo, { wrapper = mount(ReleaseBlockMilestoneInfo, {
...@@ -20,7 +21,7 @@ describe('Release block milestone info', () => { ...@@ -20,7 +21,7 @@ describe('Release block milestone info', () => {
}; };
beforeEach(() => { beforeEach(() => {
milestonesClone = JSON.parse(JSON.stringify(milestones)); milestones = convertObjectPropsToCamelCase(originalMilestones, { deep: true });
}); });
afterEach(() => { afterEach(() => {
...@@ -32,7 +33,7 @@ describe('Release block milestone info', () => { ...@@ -32,7 +33,7 @@ describe('Release block milestone info', () => {
const issuesContainer = () => wrapper.find('.js-issues-container'); const issuesContainer = () => wrapper.find('.js-issues-container');
describe('with default props', () => { describe('with default props', () => {
beforeEach(() => factory(milestonesClone)); beforeEach(() => factory(milestones));
it('renders the correct percentage', () => { it('renders the correct percentage', () => {
expect(milestoneProgressBarContainer().text()).toContain('41% complete'); expect(milestoneProgressBarContainer().text()).toContain('41% complete');
...@@ -53,13 +54,13 @@ describe('Release block milestone info', () => { ...@@ -53,13 +54,13 @@ describe('Release block milestone info', () => {
it('renders a list of links to all associated milestones', () => { it('renders a list of links to all associated milestones', () => {
expect(trimText(milestoneListContainer().text())).toContain('Milestones 13.6 • 13.5'); expect(trimText(milestoneListContainer().text())).toContain('Milestones 13.6 • 13.5');
milestonesClone.forEach((m, i) => { milestones.forEach((m, i) => {
const milestoneLink = milestoneListContainer() const milestoneLink = milestoneListContainer()
.findAll(GlLink) .findAll(GlLink)
.at(i); .at(i);
expect(milestoneLink.text()).toBe(m.title); expect(milestoneLink.text()).toBe(m.title);
expect(milestoneLink.attributes('href')).toBe(m.web_url); expect(milestoneLink.attributes('href')).toBe(m.webUrl);
expect(milestoneLink.attributes('title')).toBe(m.description); expect(milestoneLink.attributes('title')).toBe(m.description);
}); });
}); });
...@@ -84,7 +85,7 @@ describe('Release block milestone info', () => { ...@@ -84,7 +85,7 @@ describe('Release block milestone info', () => {
beforeEach(() => { beforeEach(() => {
lotsOfMilestones = []; lotsOfMilestones = [];
const template = milestonesClone[0]; const template = milestones[0];
for (let i = 0; i < MAX_MILESTONES_TO_DISPLAY + 10; i += 1) { for (let i = 0; i < MAX_MILESTONES_TO_DISPLAY + 10; i += 1) {
lotsOfMilestones.push({ lotsOfMilestones.push({
...@@ -148,16 +149,16 @@ describe('Release block milestone info', () => { ...@@ -148,16 +149,16 @@ describe('Release block milestone info', () => {
/** Ensures we don't have any issues with dividing by zero when computing percentages */ /** Ensures we don't have any issues with dividing by zero when computing percentages */
describe('when all issue counts are zero', () => { describe('when all issue counts are zero', () => {
beforeEach(() => { beforeEach(() => {
milestonesClone = milestonesClone.map(m => ({ milestones = milestones.map(m => ({
...m, ...m,
issue_stats: { issueStats: {
...m.issue_stats, ...m.issueStats,
opened: 0, opened: 0,
closed: 0, closed: 0,
}, },
})); }));
return factory(milestonesClone); return factory(milestones);
}); });
expectAllZeros(); expectAllZeros();
...@@ -165,12 +166,12 @@ describe('Release block milestone info', () => { ...@@ -165,12 +166,12 @@ describe('Release block milestone info', () => {
describe('if the API response is missing the "issue_stats" property', () => { describe('if the API response is missing the "issue_stats" property', () => {
beforeEach(() => { beforeEach(() => {
milestonesClone = milestonesClone.map(m => ({ milestones = milestones.map(m => ({
...m, ...m,
issue_stats: undefined, issueStats: undefined,
})); }));
return factory(milestonesClone); return factory(milestones);
}); });
expectAllZeros(); expectAllZeros();
......
...@@ -5,10 +5,12 @@ import EvidenceBlock from '~/releases/components/evidence_block.vue'; ...@@ -5,10 +5,12 @@ import EvidenceBlock from '~/releases/components/evidence_block.vue';
import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import { release } from '../mock_data'; import { release as originalRelease } from '../mock_data';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import { scrollToElement } from '~/lib/utils/common_utils'; import { scrollToElement } from '~/lib/utils/common_utils';
const { convertObjectPropsToCamelCase } = jest.requireActual('~/lib/utils/common_utils');
let mockLocationHash; let mockLocationHash;
jest.mock('~/lib/utils/url_utility', () => ({ jest.mock('~/lib/utils/url_utility', () => ({
__esModule: true, __esModule: true,
...@@ -22,7 +24,7 @@ jest.mock('~/lib/utils/common_utils', () => ({ ...@@ -22,7 +24,7 @@ jest.mock('~/lib/utils/common_utils', () => ({
describe('Release block', () => { describe('Release block', () => {
let wrapper; let wrapper;
let releaseClone; let release;
const factory = (releaseProp, featureFlags = {}) => { const factory = (releaseProp, featureFlags = {}) => {
wrapper = mount(ReleaseBlock, { wrapper = mount(ReleaseBlock, {
...@@ -45,7 +47,7 @@ describe('Release block', () => { ...@@ -45,7 +47,7 @@ describe('Release block', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn($.fn, 'renderGFM'); jest.spyOn($.fn, 'renderGFM');
releaseClone = JSON.parse(JSON.stringify(release)); release = convertObjectPropsToCamelCase(originalRelease, { deep: true });
}); });
afterEach(() => { afterEach(() => {
...@@ -61,7 +63,7 @@ describe('Release block', () => { ...@@ -61,7 +63,7 @@ describe('Release block', () => {
it('renders an edit button that links to the "Edit release" page', () => { it('renders an edit button that links to the "Edit release" page', () => {
expect(editButton().exists()).toBe(true); expect(editButton().exists()).toBe(true);
expect(editButton().attributes('href')).toBe(release._links.edit_url); expect(editButton().attributes('href')).toBe(release.Links.editUrl);
}); });
it('renders release name', () => { it('renders release name', () => {
...@@ -74,7 +76,7 @@ describe('Release block', () => { ...@@ -74,7 +76,7 @@ describe('Release block', () => {
}); });
it('renders release date', () => { it('renders release date', () => {
expect(wrapper.text()).toContain(timeagoMixin.methods.timeFormatted(release.released_at)); expect(wrapper.text()).toContain(timeagoMixin.methods.timeFormatted(release.releasedAt));
}); });
it('renders number of assets provided', () => { it('renders number of assets provided', () => {
...@@ -129,72 +131,72 @@ describe('Release block', () => { ...@@ -129,72 +131,72 @@ describe('Release block', () => {
}); });
it('renders commit sha', () => { it('renders commit sha', () => {
releaseClone.commit_path = '/commit/example'; release.commitPath = '/commit/example';
return factory(releaseClone).then(() => { return factory(release).then(() => {
expect(wrapper.text()).toContain(release.commit.short_id); expect(wrapper.text()).toContain(release.commit.shortId);
expect(wrapper.find('a[href="/commit/example"]').exists()).toBe(true); expect(wrapper.find('a[href="/commit/example"]').exists()).toBe(true);
}); });
}); });
it('renders tag name', () => { it('renders tag name', () => {
releaseClone.tag_path = '/tag/example'; release.tagPath = '/tag/example';
return factory(releaseClone).then(() => { return factory(release).then(() => {
expect(wrapper.text()).toContain(release.tag_name); expect(wrapper.text()).toContain(release.tagName);
expect(wrapper.find('a[href="/tag/example"]').exists()).toBe(true); expect(wrapper.find('a[href="/tag/example"]').exists()).toBe(true);
}); });
}); });
it("does not render an edit button if release._links.edit_url isn't a string", () => { it("does not render an edit button if release.Links.editUrl isn't a string", () => {
delete releaseClone._links; delete release.Links;
return factory(releaseClone).then(() => { return factory(release).then(() => {
expect(editButton().exists()).toBe(false); expect(editButton().exists()).toBe(false);
}); });
}); });
it('does not render the milestone list if no milestones are associated to the release', () => { it('does not render the milestone list if no milestones are associated to the release', () => {
delete releaseClone.milestones; delete release.milestones;
return factory(releaseClone).then(() => { return factory(release).then(() => {
expect(milestoneListLabel().exists()).toBe(false); expect(milestoneListLabel().exists()).toBe(false);
}); });
}); });
it('renders upcoming release badge', () => { it('renders upcoming release badge', () => {
releaseClone.upcoming_release = true; release.upcomingRelease = true;
return factory(releaseClone).then(() => { return factory(release).then(() => {
expect(wrapper.text()).toContain('Upcoming Release'); expect(wrapper.text()).toContain('Upcoming Release');
}); });
}); });
it('slugifies the tag_name before setting it as the elements ID', () => { it('slugifies the tagName before setting it as the elements ID', () => {
releaseClone.tag_name = 'a dangerous tag name <script>alert("hello")</script>'; release.tagName = 'a dangerous tag name <script>alert("hello")</script>';
return factory(releaseClone).then(() => { return factory(release).then(() => {
expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script'); expect(wrapper.attributes().id).toBe('a-dangerous-tag-name-script-alert-hello-script');
}); });
}); });
describe('evidence block', () => { describe('evidence block', () => {
it('renders the evidence block when the evidence is available and the feature flag is true', () => it('renders the evidence block when the evidence is available and the feature flag is true', () =>
factory(releaseClone, { releaseEvidenceCollection: true }).then(() => factory(release, { releaseEvidenceCollection: true }).then(() =>
expect(wrapper.find(EvidenceBlock).exists()).toBe(true), expect(wrapper.find(EvidenceBlock).exists()).toBe(true),
)); ));
it('does not render the evidence block when the evidence is available but the feature flag is false', () => it('does not render the evidence block when the evidence is available but the feature flag is false', () =>
factory(releaseClone, { releaseEvidenceCollection: true }).then(() => factory(release, { releaseEvidenceCollection: true }).then(() =>
expect(wrapper.find(EvidenceBlock).exists()).toBe(true), expect(wrapper.find(EvidenceBlock).exists()).toBe(true),
)); ));
it('does not render the evidence block when there is no evidence', () => { it('does not render the evidence block when there is no evidence', () => {
releaseClone.evidence_sha = null; release.evidenceSha = null;
return factory(releaseClone).then(() => { return factory(release).then(() => {
expect(wrapper.find(EvidenceBlock).exists()).toBe(false); expect(wrapper.find(EvidenceBlock).exists()).toBe(false);
}); });
}); });
...@@ -222,7 +224,7 @@ describe('Release block', () => { ...@@ -222,7 +224,7 @@ describe('Release block', () => {
}); });
it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => { it("attempts to scroll itself into view if the anchor tag matches the release's tag name", () => {
mockLocationHash = release.tag_name; mockLocationHash = release.tagName;
return factory(release).then(() => { return factory(release).then(() => {
expect(scrollToElement).toHaveBeenCalledTimes(1); expect(scrollToElement).toHaveBeenCalledTimes(1);
...@@ -231,7 +233,7 @@ describe('Release block', () => { ...@@ -231,7 +233,7 @@ describe('Release block', () => {
}); });
it('renders with a light blue background if it is the target of the anchor', () => { it('renders with a light blue background if it is the target of the anchor', () => {
mockLocationHash = release.tag_name; mockLocationHash = release.tagName;
return factory(release).then(() => { return factory(release).then(() => {
expect(hasTargetBlueBackground()).toBe(true); expect(hasTargetBlueBackground()).toBe(true);
...@@ -275,16 +277,16 @@ describe('Release block', () => { ...@@ -275,16 +277,16 @@ describe('Release block', () => {
expect(milestoneLink.text()).toBe(milestone.title); expect(milestoneLink.text()).toBe(milestone.title);
expect(milestoneLink.attributes('href')).toBe(milestone.web_url); expect(milestoneLink.attributes('href')).toBe(milestone.webUrl);
expect(milestoneLink.attributes('title')).toBe(milestone.description); expect(milestoneLink.attributes('title')).toBe(milestone.description);
}); });
}); });
it('renders the label as "Milestone" if only a single milestone is passed in', () => { it('renders the label as "Milestone" if only a single milestone is passed in', () => {
releaseClone.milestones = releaseClone.milestones.slice(0, 1); release.milestones = release.milestones.slice(0, 1);
return factory(releaseClone, { releaseIssueSummary: false }).then(() => { return factory(release, { releaseIssueSummary: false }).then(() => {
expect( expect(
milestoneListLabel() milestoneListLabel()
.find('.js-label-text') .find('.js-label-text')
......
...@@ -45,6 +45,21 @@ describe Resolvers::BoardsResolver do ...@@ -45,6 +45,21 @@ describe Resolvers::BoardsResolver do
expect(resolve_boards).to eq [board1] expect(resolve_boards).to eq [board1]
end end
end end
context 'when querying for a single board' do
let(:board1) { create(:board, name: 'One', resource_parent: board_parent) }
it 'returns specified board' do
expect(resolve_boards(args: { id: global_id_of(board1) })).to eq [board1]
end
it 'returns nil if board not found' do
outside_parent = create(board_parent.class.underscore.to_sym)
outside_board = create(:board, name: 'outside board', resource_parent: outside_parent)
expect(resolve_boards(args: { id: global_id_of(outside_board) })).to eq Board.none
end
end
end end
describe '#resolve' do describe '#resolve' do
......
import _ from 'underscore'; import { escape as esc } from 'lodash';
import VisualTokenValue from '~/filtered_search/visual_token_value'; import VisualTokenValue from '~/filtered_search/visual_token_value';
import AjaxCache from '~/lib/utils/ajax_cache'; import AjaxCache from '~/lib/utils/ajax_cache';
import UsersCache from '~/lib/utils/users_cache'; import UsersCache from '~/lib/utils/users_cache';
...@@ -121,7 +121,7 @@ describe('Filtered Search Visual Tokens', () => { ...@@ -121,7 +121,7 @@ describe('Filtered Search Visual Tokens', () => {
expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name); expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
tokenValueElement.querySelector('.avatar').remove(); tokenValueElement.querySelector('.avatar').remove();
expect(tokenValueElement.innerHTML.trim()).toBe(_.escape(dummyUser.name)); expect(tokenValueElement.innerHTML.trim()).toBe(esc(dummyUser.name));
}) })
.then(done) .then(done)
.catch(done.fail); .catch(done.fail);
......
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
release, release,
releases, releases,
} from '../mock_data'; } from '../mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
describe('Releases App ', () => { describe('Releases App ', () => {
const Component = Vue.extend(app); const Component = Vue.extend(app);
...@@ -27,7 +28,10 @@ describe('Releases App ', () => { ...@@ -27,7 +28,10 @@ describe('Releases App ', () => {
beforeEach(() => { beforeEach(() => {
store = createStore({ list: listModule }); store = createStore({ list: listModule });
releasesPagination = _.range(21).map(index => ({ ...release, tag_name: `${index}.00` })); releasesPagination = _.range(21).map(index => ({
...convertObjectPropsToCamelCase(release, { deep: true }),
tagName: `${index}.00`,
}));
}); });
afterEach(() => { afterEach(() => {
......
...@@ -8,16 +8,18 @@ import { ...@@ -8,16 +8,18 @@ import {
import state from '~/releases/stores/modules/list/state'; import state from '~/releases/stores/modules/list/state';
import * as types from '~/releases/stores/modules/list/mutation_types'; import * as types from '~/releases/stores/modules/list/mutation_types';
import api from '~/api'; import api from '~/api';
import { parseIntPagination } from '~/lib/utils/common_utils'; import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { pageInfoHeadersWithoutPagination, releases } from '../../../mock_data'; import { pageInfoHeadersWithoutPagination, releases as originalReleases } from '../../../mock_data';
describe('Releases State actions', () => { describe('Releases State actions', () => {
let mockedState; let mockedState;
let pageInfo; let pageInfo;
let releases;
beforeEach(() => { beforeEach(() => {
mockedState = state(); mockedState = state();
pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination);
releases = convertObjectPropsToCamelCase(originalReleases, { deep: true });
}); });
describe('requestReleases', () => { describe('requestReleases', () => {
......
...@@ -9,14 +9,12 @@ describe 'get list of boards' do ...@@ -9,14 +9,12 @@ describe 'get list of boards' do
describe 'for a project' do describe 'for a project' do
let(:board_parent) { create(:project, :repository, :private) } let(:board_parent) { create(:project, :repository, :private) }
let(:boards_data) { graphql_data['project']['boards']['edges'] }
it_behaves_like 'group and project boards query' it_behaves_like 'group and project boards query'
end end
describe 'for a group' do describe 'for a group' do
let(:board_parent) { create(:group, :private) } let(:board_parent) { create(:group, :private) }
let(:boards_data) { graphql_data['group']['boards']['edges'] }
before do before do
allow(board_parent).to receive(:multiple_issue_boards_available?).and_return(false) allow(board_parent).to receive(:multiple_issue_boards_available?).and_return(false)
......
...@@ -5,6 +5,8 @@ RSpec.shared_context 'group and project boards query context' do ...@@ -5,6 +5,8 @@ RSpec.shared_context 'group and project boards query context' do
let(:current_user) { user } let(:current_user) { user }
let(:params) { '' } let(:params) { '' }
let(:board_parent_type) { board_parent.class.to_s.downcase } let(:board_parent_type) { board_parent.class.to_s.downcase }
let(:boards_data) { graphql_data[board_parent_type]['boards']['edges'] }
let(:board_data) { graphql_data[board_parent_type]['board'] }
let(:start_cursor) { graphql_data[board_parent_type]['boards']['pageInfo']['startCursor'] } let(:start_cursor) { graphql_data[board_parent_type]['boards']['pageInfo']['startCursor'] }
let(:end_cursor) { graphql_data[board_parent_type]['boards']['pageInfo']['endCursor'] } let(:end_cursor) { graphql_data[board_parent_type]['boards']['pageInfo']['endCursor'] }
...@@ -28,6 +30,18 @@ RSpec.shared_context 'group and project boards query context' do ...@@ -28,6 +30,18 @@ RSpec.shared_context 'group and project boards query context' do
) )
end end
def query_single_board(board_params = params)
graphql_query_for(
board_parent_type,
{ 'fullPath' => board_parent.full_path },
<<~BOARD
board(#{board_params}) {
#{all_graphql_fields_for('board'.classify)}
}
BOARD
)
end
def grab_names(data = boards_data) def grab_names(data = boards_data)
data.map do |board| data.map do |board|
board.dig('node', 'name') board.dig('node', 'name')
......
...@@ -89,4 +89,24 @@ RSpec.shared_examples 'group and project boards query' do ...@@ -89,4 +89,24 @@ RSpec.shared_examples 'group and project boards query' do
end end
end end
end end
context 'when querying for a single board' do
before do
board_parent.add_reporter(current_user)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query_single_board, current_user: current_user)
end
end
it 'finds the correct board' do
board = create(:board, resource_parent: board_parent, name: 'A')
post_graphql(query_single_board("id: \"#{global_id_of(board)}\""), current_user: current_user)
expect(board_data['name']).to eq board.name
end
end
end end
...@@ -56,7 +56,7 @@ describe 'profiles/preferences/show' do ...@@ -56,7 +56,7 @@ describe 'profiles/preferences/show' do
expect(rendered).not_to have_sourcegraph_field expect(rendered).not_to have_sourcegraph_field
end end
it 'does not display integrations settings' do it 'does not display Integration Settings' do
expect(rendered).not_to have_integrations_section expect(rendered).not_to have_integrations_section
end end
end 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