Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-04-12 21:11:12 +00:00
parent da59ce8b21
commit beab869416
77 changed files with 1487 additions and 498 deletions

View File

@ -214,7 +214,7 @@ export default {
<gl-form-checkbox <gl-form-checkbox
v-model="issueTransitionEnabled" v-model="issueTransitionEnabled"
:disabled="isInheriting" :disabled="isInheriting"
data-qa-selector="service_jira_issue_transition_enabled" data-qa-selector="service_jira_issue_transition_enabled_checkbox"
> >
{{ s__('JiraService|Enable Jira transitions') }} {{ s__('JiraService|Enable Jira transitions') }}
</gl-form-checkbox> </gl-form-checkbox>
@ -232,7 +232,7 @@ export default {
name="service[jira_issue_transition_automatic]" name="service[jira_issue_transition_automatic]"
:value="issueTransitionOption.value" :value="issueTransitionOption.value"
:disabled="isInheriting" :disabled="isInheriting"
:data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}`" :data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`"
> >
{{ issueTransitionOption.label }} {{ issueTransitionOption.label }}

View File

@ -254,6 +254,37 @@ export const timeIntervalInWords = (intervalInSeconds) => {
: secondsText; : secondsText;
}; };
/**
* Similar to `timeIntervalInWords`, but rounds the return value
* to 1/10th of the largest time unit. For example:
*
* 30 => 30 seconds
* 90 => 1.5 minutes
* 7200 => 2 hours
* 86400 => 1 day
* ... etc.
*
* The largest supported unit is "days".
*
* @param {Number} intervalInSeconds The time interval in seconds
* @returns {String} A humanized description of the time interval
*/
export const humanizeTimeInterval = (intervalInSeconds) => {
if (intervalInSeconds < 60 /* = 1 minute */) {
const seconds = Math.round(intervalInSeconds * 10) / 10;
return n__('%d second', '%d seconds', seconds);
} else if (intervalInSeconds < 3600 /* = 1 hour */) {
const minutes = Math.round(intervalInSeconds / 6) / 10;
return n__('%d minute', '%d minutes', minutes);
} else if (intervalInSeconds < 86400 /* = 1 day */) {
const hours = Math.round(intervalInSeconds / 360) / 10;
return n__('%d hour', '%d hours', hours);
}
const days = Math.round(intervalInSeconds / 8640) / 10;
return n__('%d day', '%d days', days);
};
export const dateInWords = (date, abbreviated = false, hideYear = false) => { export const dateInWords = (date, abbreviated = false, hideYear = false) => {
if (!date) return date; if (!date) return date;

View File

@ -0,0 +1,77 @@
<script>
import { GlPopover, GlSprintf, GlLink } from '@gitlab/ui';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
name: 'LockPopovers',
components: {
GlPopover,
GlSprintf,
GlLink,
},
data() {
return {
targets: [],
};
},
mounted() {
this.targets = [...document.querySelectorAll('.js-cascading-settings-lock-popover-target')].map(
(el) => {
const {
dataset: { popoverData },
} = el;
const {
lockedByAncestor,
lockedByApplicationSetting,
ancestorNamespace,
} = convertObjectPropsToCamelCase(JSON.parse(popoverData || '{}'), { deep: true });
return {
el,
lockedByAncestor,
lockedByApplicationSetting,
ancestorNamespace,
};
},
);
},
};
</script>
<template>
<div>
<template
v-for="(
{ el, lockedByApplicationSetting, lockedByAncestor, ancestorNamespace }, index
) in targets"
>
<gl-popover
v-if="lockedByApplicationSetting || lockedByAncestor"
:key="index"
:target="el"
placement="top"
>
<template #title>{{ s__('CascadingSettings|Setting enforced') }}</template>
<p data-testid="cascading-settings-lock-popover">
<template v-if="lockedByApplicationSetting">{{
s__('CascadingSettings|This setting has been enforced by an instance admin.')
}}</template>
<gl-sprintf
v-else-if="lockedByAncestor && ancestorNamespace"
:message="
s__('CascadingSettings|This setting has been enforced by an owner of %{link}.')
"
>
<template #link>
<gl-link :href="ancestorNamespace.path" class="gl-font-sm">{{
ancestorNamespace.fullName
}}</gl-link>
</template>
</gl-sprintf>
</p>
</gl-popover>
</template>
</div>
</template>

View File

@ -0,0 +1,15 @@
import Vue from 'vue';
import LockPopovers from './components/lock_popovers.vue';
export const initCascadingSettingsLockPopovers = () => {
const el = document.querySelector('.js-cascading-settings-lock-popovers');
if (!el) return false;
return new Vue({
el,
render(createElement) {
return createElement(LockPopovers);
},
});
};

View File

@ -4,6 +4,7 @@ import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory';
import initFilePickers from '~/file_pickers'; import initFilePickers from '~/file_pickers';
import TransferDropdown from '~/groups/transfer_dropdown'; import TransferDropdown from '~/groups/transfer_dropdown';
import groupsSelect from '~/groups_select'; import groupsSelect from '~/groups_select';
import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings'; import initSearchSettings from '~/search_settings';
@ -26,6 +27,7 @@ document.addEventListener('DOMContentLoaded', () => {
projectSelect(); projectSelect();
initSearchSettings(); initSearchSettings();
initCascadingSettingsLockPopovers();
return new TransferDropdown(); return new TransferDropdown();
}); });

View File

@ -54,6 +54,7 @@ export default {
data() { data() {
return { return {
hoveredJobName: '', hoveredJobName: '',
hoveredSourceJobName: '',
highlightedJobs: [], highlightedJobs: [],
measurements: { measurements: {
width: 0, width: 0,
@ -93,6 +94,9 @@ export default {
shouldHideLinks() { shouldHideLinks() {
return this.isStageView; return this.isStageView;
}, },
shouldShowStageName() {
return !this.isStageView;
},
// The show downstream check prevents showing redundant linked columns // The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() { showDownstreamPipelines() {
return ( return (
@ -148,6 +152,9 @@ export default {
setJob(jobName) { setJob(jobName) {
this.hoveredJobName = jobName; this.hoveredJobName = jobName;
}, },
setSourceJob(jobName) {
this.hoveredSourceJobName = jobName;
},
slidePipelineContainer() { slidePipelineContainer() {
this.$refs.mainPipelineContainer.scrollBy({ this.$refs.mainPipelineContainer.scrollBy({
left: ONE_COL_WIDTH, left: ONE_COL_WIDTH,
@ -204,11 +211,13 @@ export default {
<stage-column-component <stage-column-component
v-for="column in layout" v-for="column in layout"
:key="column.id || column.name" :key="column.id || column.name"
:title="column.name" :name="column.name"
:groups="column.groups" :groups="column.groups"
:action="column.status.action" :action="column.status.action"
:highlighted-jobs="highlightedJobs" :highlighted-jobs="highlightedJobs"
:show-stage-name="shouldShowStageName"
:job-hovered="hoveredJobName" :job-hovered="hoveredJobName"
:source-job-hovered="hoveredSourceJobName"
:pipeline-expanded="pipelineExpanded" :pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id" :pipeline-id="pipeline.id"
@refreshPipelineGraph="$emit('refreshPipelineGraph')" @refreshPipelineGraph="$emit('refreshPipelineGraph')"
@ -227,7 +236,7 @@ export default {
:column-title="__('Downstream')" :column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM" :type="$options.pipelineTypeConstants.DOWNSTREAM"
:view-type="viewType" :view-type="viewType"
@downstreamHovered="setJob" @downstreamHovered="setSourceJob"
@pipelineExpandToggle="togglePipelineExpanded" @pipelineExpandToggle="togglePipelineExpanded"
@scrollContainer="slidePipelineContainer" @scrollContainer="slidePipelineContainer"
@error="onError" @error="onError"

View File

@ -1,6 +1,4 @@
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils'; import { reportToSentry } from '../../utils';
import JobItem from './job_item.vue'; import JobItem from './job_item.vue';
@ -11,12 +9,8 @@ import JobItem from './job_item.vue';
* *
*/ */
export default { export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: { components: {
JobItem, JobItem,
CiIcon,
}, },
props: { props: {
group: { group: {
@ -28,6 +22,11 @@ export default {
required: false, required: false,
default: -1, default: -1,
}, },
stageName: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
computedJobId() { computedJobId() {
@ -51,22 +50,21 @@ export default {
<template> <template>
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button <button
v-gl-tooltip.hover="{ boundary: 'viewport' }"
:title="tooltipText"
type="button" type="button"
data-toggle="dropdown" data-toggle="dropdown"
data-display="static" data-display="static"
class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!" class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!"
> >
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between"> <div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
<span class="gl-display-flex gl-align-items-center gl-min-w-0"> <job-item
<ci-icon :status="group.status" :size="24" class="gl-line-height-0" /> :dropdown-length="group.size"
<span class="gl-text-truncate mw-70p gl-pl-3"> :group-tooltip="tooltipText"
{{ group.name }} :job="group"
</span> :stage-name="stageName"
</span> @pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
<span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span> <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div>
</div> </div>
</button> </button>

View File

@ -3,6 +3,7 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import { reportToSentry } from '../../utils'; import { reportToSentry } from '../../utils';
import ActionComponent from '../jobs_shared/action_component.vue'; import ActionComponent from '../jobs_shared/action_component.vue';
import JobNameComponent from '../jobs_shared/job_name_component.vue'; import JobNameComponent from '../jobs_shared/job_name_component.vue';
@ -38,6 +39,7 @@ export default {
hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: { components: {
ActionComponent, ActionComponent,
CiIcon,
JobNameComponent, JobNameComponent,
GlLink, GlLink,
}, },
@ -65,6 +67,11 @@ export default {
required: false, required: false,
default: Infinity, default: Infinity,
}, },
groupTooltip: {
type: String,
required: false,
default: '',
},
jobHovered: { jobHovered: {
type: String, type: String,
required: false, required: false,
@ -80,24 +87,47 @@ export default {
required: false, required: false,
default: -1, default: -1,
}, },
sourceJobHovered: {
type: String,
required: false,
default: '',
},
stageName: {
type: String,
required: false,
default: '',
},
}, },
computed: { computed: {
boundary() { boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
}, },
computedJobId() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
},
detailsPath() { detailsPath() {
return accessValue(this.dataMethod, 'detailsPath', this.status); return accessValue(this.dataMethod, 'detailsPath', this.status);
}, },
hasDetails() { hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status); return accessValue(this.dataMethod, 'hasDetails', this.status);
}, },
computedJobId() { nameComponent() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; return this.hasDetails ? 'gl-link' : 'div';
},
showStageName() {
return Boolean(this.stageName);
}, },
status() { status() {
return this.job && this.job.status ? this.job.status : {}; return this.job && this.job.status ? this.job.status : {};
}, },
testId() {
return this.hasDetails ? 'job-with-link' : 'job-without-link';
},
tooltipText() { tooltipText() {
if (this.groupTooltip) {
return this.groupTooltip;
}
const textBuilder = []; const textBuilder = [];
const { name: jobName } = this.job; const { name: jobName } = this.job;
@ -129,7 +159,7 @@ export default {
return this.job.status && this.job.status.action && this.job.status.action.path; return this.job.status && this.job.status.action && this.job.status.action.path;
}, },
relatedDownstreamHovered() { relatedDownstreamHovered() {
return this.job.name === this.jobHovered; return this.job.name === this.sourceJobHovered;
}, },
relatedDownstreamExpanded() { relatedDownstreamExpanded() {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
@ -156,44 +186,45 @@ export default {
<template> <template>
<div <div
:id="computedJobId" :id="computedJobId"
class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full"
data-qa-selector="job_item_container" data-qa-selector="job_item_container"
> >
<gl-link <component
v-if="hasDetails" :is="nameComponent"
v-gl-tooltip="{ v-gl-tooltip="{
boundary: 'viewport', boundary: 'viewport',
placement: 'bottom', placement: 'bottom',
customClass: 'gl-pointer-events-none', customClass: 'gl-pointer-events-none',
}" }"
:href="detailsPath"
:title="tooltipText" :title="tooltipText"
:class="jobClasses" :class="jobClasses"
class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" :href="detailsPath"
data-testid="job-with-link" class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
:data-testid="testId"
@click.stop="hideTooltips" @click.stop="hideTooltips"
@mouseout="hideTooltips" @mouseout="hideTooltips"
> >
<job-name-component :name="job.name" :status="job.status" :icon-size="24" /> <div class="ci-job-name-component gl-display-flex gl-align-items-center">
</gl-link> <ci-icon :size="24" :status="job.status" class="gl-line-height-0" />
<div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full">
<div <div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div>
v-else <div
v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" v-if="showStageName"
:title="tooltipText" data-testid="stage-name-in-job"
:class="jobClasses" class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
class="js-job-component-tooltip non-details-job-component menu-item" >
data-testid="job-without-link" {{ stageName }}
@mouseout="hideTooltips" </div>
> </div>
<job-name-component :name="job.name" :status="job.status" :icon-size="24" /> </div>
</div> </component>
<action-component <action-component
v-if="hasAction" v-if="hasAction"
:tooltip-text="status.action.title" :tooltip-text="status.action.title"
:link="status.action.path" :link="status.action.path"
:action-icon="status.action.icon" :action-icon="status.action.icon"
class="gl-mr-1"
data-qa-selector="action_button" data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete" @pipelineActionRequestComplete="pipelineActionRequestComplete"
/> />

View File

@ -22,12 +22,12 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
pipelineId: { name: {
type: Number, type: String,
required: true, required: true,
}, },
title: { pipelineId: {
type: String, type: Number,
required: true, required: true,
}, },
action: { action: {
@ -50,6 +50,16 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
showStageName: {
type: Boolean,
required: false,
default: false,
},
sourceJobHovered: {
type: String,
required: false,
default: '',
},
}, },
titleClasses: [ titleClasses: [
'gl-font-weight-bold', 'gl-font-weight-bold',
@ -75,7 +85,7 @@ export default {
}); });
}, },
formattedTitle() { formattedTitle() {
return capitalize(escape(this.title)); return capitalize(escape(this.name));
}, },
hasAction() { hasAction() {
return !isEmpty(this.action); return !isEmpty(this.action);
@ -145,14 +155,20 @@ export default {
v-if="singleJobExists(group)" v-if="singleJobExists(group)"
:job="group.jobs[0]" :job="group.jobs[0]"
:job-hovered="jobHovered" :job-hovered="jobHovered"
:source-job-hovered="sourceJobHovered"
:pipeline-expanded="pipelineExpanded" :pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId" :pipeline-id="pipelineId"
:stage-name="showStageName ? group.stageName : ''"
css-class-job-name="gl-build-content" css-class-job-name="gl-build-content"
:class="{ 'gl-opacity-3': isFadedOut(group.name) }" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/> />
<div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
<job-group-dropdown :group="group" :pipeline-id="pipelineId" /> <job-group-dropdown
:group="group"
:stage-name="showStageName ? group.stageName : ''"
:pipeline-id="pipelineId"
/>
</div> </div>
</div> </div>
</template> </template>

View File

@ -5,7 +5,19 @@ const unwrapGroups = (stages) => {
const { const {
groups: { nodes: groups }, groups: { nodes: groups },
} = stage; } = stage;
return { node: { ...stage, groups }, lookup: { stageIdx: idx } };
/*
Being peformance conscious here means we don't want to spread and copy the
group value just to add one parameter.
*/
/* eslint-disable no-param-reassign */
const groupsWithStageName = groups.map((group) => {
group.stageName = stage.name;
return group;
});
/* eslint-enable no-param-reassign */
return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } };
}); });
}; };

View File

@ -72,6 +72,7 @@ export default {
<gl-dropdown-item <gl-dropdown-item
v-if="canCherryPick" v-if="canCherryPick"
data-testid="cherry-pick-link" data-testid="cherry-pick-link"
data-qa-selector="cherry_pick_button"
@click="showModal($options.openCherryPickModal)" @click="showModal($options.openCherryPickModal)"
> >
{{ s__('ChangeTypeAction|Cherry-pick') }} {{ s__('ChangeTypeAction|Cherry-pick') }}

View File

@ -3,8 +3,6 @@ import { GlTabs, GlTab } from '@gitlab/ui';
import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility'; import { mergeUrlParams, updateHistory, getParameterValues } from '~/lib/utils/url_utility';
import PipelineCharts from './pipeline_charts.vue'; import PipelineCharts from './pipeline_charts.vue';
const charts = ['pipelines', 'deployments'];
export default { export default {
components: { components: {
GlTabs, GlTabs,
@ -12,6 +10,8 @@ export default {
PipelineCharts, PipelineCharts,
DeploymentFrequencyCharts: () => DeploymentFrequencyCharts: () =>
import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'), import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'),
LeadTimeCharts: () =>
import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue'),
}, },
inject: { inject: {
shouldRenderDeploymentFrequencyCharts: { shouldRenderDeploymentFrequencyCharts: {
@ -24,20 +24,29 @@ export default {
selectedTab: 0, selectedTab: 0,
}; };
}, },
computed: {
charts() {
if (this.shouldRenderDeploymentFrequencyCharts) {
return ['pipelines', 'deployments', 'lead-time'];
}
return ['pipelines', 'lead-time'];
},
},
created() { created() {
this.selectTab(); this.selectTab();
window.addEventListener('popstate', this.selectTab); window.addEventListener('popstate', this.selectTab);
}, },
methods: { methods: {
selectTab() { selectTab() {
const [chart] = getParameterValues('chart') || charts; const [chart] = getParameterValues('chart') || this.charts;
const tab = charts.indexOf(chart); const tab = this.charts.indexOf(chart);
this.selectedTab = tab >= 0 ? tab : 0; this.selectedTab = tab >= 0 ? tab : 0;
}, },
onTabChange(index) { onTabChange(index) {
if (index !== this.selectedTab) { if (index !== this.selectedTab) {
this.selectedTab = index; this.selectedTab = index;
const path = mergeUrlParams({ chart: charts[index] }, window.location.pathname); const path = mergeUrlParams({ chart: this.charts[index] }, window.location.pathname);
updateHistory({ url: path, title: window.title }); updateHistory({ url: path, title: window.title });
} }
}, },
@ -46,14 +55,16 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange"> <gl-tabs :value="selectedTab" @input="onTabChange">
<gl-tab :title="__('Pipelines')"> <gl-tab :title="__('Pipelines')">
<pipeline-charts /> <pipeline-charts />
</gl-tab> </gl-tab>
<gl-tab :title="__('Deployments')"> <gl-tab v-if="shouldRenderDeploymentFrequencyCharts" :title="__('Deployments')">
<deployment-frequency-charts /> <deployment-frequency-charts />
</gl-tab> </gl-tab>
<gl-tab :title="__('Lead Time')">
<lead-time-charts />
</gl-tab>
</gl-tabs> </gl-tabs>
<pipeline-charts v-else />
</div> </div>
</template> </template>

View File

@ -44,7 +44,8 @@ export default {
:checked="value" :checked="value"
:disabled="isDisabled" :disabled="isDisabled"
name="squash" name="squash"
class="qa-squash-checkbox js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center" class="js-squash-checkbox gl-mr-2 gl-display-flex gl-align-items-center"
data-qa-selector="squash_checkbox"
:title="tooltipTitle" :title="tooltipTitle"
@change="(checked) => $emit('input', checked)" @change="(checked) => $emit('input', checked)"
> >

View File

@ -1,13 +1,5 @@
<script> <script>
import { import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui';
GlDrawer,
GlInfiniteScroll,
GlResizeObserverDirective,
GlTabs,
GlTab,
GlBadge,
GlLoadingIcon,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height'; import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
@ -20,37 +12,24 @@ export default {
components: { components: {
GlDrawer, GlDrawer,
GlInfiniteScroll, GlInfiniteScroll,
GlTabs,
GlTab,
SkeletonLoader, SkeletonLoader,
Feature, Feature,
GlBadge,
GlLoadingIcon,
}, },
directives: { directives: {
GlResizeObserver: GlResizeObserverDirective, GlResizeObserver: GlResizeObserverDirective,
}, },
mixins: [trackingMixin], mixins: [trackingMixin],
props: { props: {
storageKey: { versionDigest: {
type: String, type: String,
required: true, required: true,
}, },
versions: {
type: Array,
required: true,
},
gitlabDotCom: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']), ...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']),
}, },
mounted() { mounted() {
this.openDrawer(this.storageKey); this.openDrawer(this.versionDigest);
this.fetchItems(); this.fetchItems();
const body = document.querySelector('body'); const body = document.querySelector('body');
@ -70,16 +49,6 @@ export default {
const height = getDrawerBodyHeight(this.$refs.drawer.$el); const height = getDrawerBodyHeight(this.$refs.drawer.$el);
this.setDrawerBodyHeight(height); this.setDrawerBodyHeight(height);
}, },
featuresForVersion(version) {
return this.features.filter((feature) => {
return feature.release === parseFloat(version);
});
},
fetchVersion(version) {
if (this.featuresForVersion(version).length === 0) {
this.fetchItems({ version });
}
},
}, },
}; };
</script> </script>
@ -99,7 +68,6 @@ export default {
</template> </template>
<template v-if="features.length"> <template v-if="features.length">
<gl-infinite-scroll <gl-infinite-scroll
v-if="gitlabDotCom"
:fetched-items="features.length" :fetched-items="features.length"
:max-list-height="drawerBodyHeight" :max-list-height="drawerBodyHeight"
class="gl-p-0" class="gl-p-0"
@ -109,26 +77,6 @@ export default {
<feature v-for="feature in features" :key="feature.title" :feature="feature" /> <feature v-for="feature in features" :key="feature.title" :feature="feature" />
</template> </template>
</gl-infinite-scroll> </gl-infinite-scroll>
<gl-tabs v-else :style="{ height: `${drawerBodyHeight}px` }" class="gl-p-0">
<gl-tab
v-for="(version, index) in versions"
:key="version"
@click="fetchVersion(version)"
>
<template #title>
<span>{{ version }}</span>
<gl-badge v-if="index === 0">{{ __('Your Version') }}</gl-badge>
</template>
<gl-loading-icon v-if="fetching" size="lg" class="text-center" />
<template v-else>
<feature
v-for="feature in featuresForVersion(version)"
:key="feature.title"
:feature="feature"
/>
</template>
</gl-tab>
</gl-tabs>
</template> </template>
<div v-else class="gl-mt-5"> <div v-else class="gl-mt-5">
<skeleton-loader /> <skeleton-loader />

View File

@ -2,7 +2,7 @@ import Vue from 'vue';
import { mapState } from 'vuex'; import { mapState } from 'vuex';
import App from './components/app.vue'; import App from './components/app.vue';
import store from './store'; import store from './store';
import { getStorageKey, setNotification } from './utils/notification'; import { getVersionDigest, setNotification } from './utils/notification';
let whatsNewApp; let whatsNewApp;
@ -27,9 +27,7 @@ export default (el) => {
render(createElement) { render(createElement) {
return createElement('app', { return createElement('app', {
props: { props: {
storageKey: getStorageKey(el), versionDigest: getVersionDigest(el),
versions: JSON.parse(el.getAttribute('data-versions')),
gitlabDotCom: el.getAttribute('data-gitlab-dot-com'),
}, },
}); });
}, },

View File

@ -1,19 +1,20 @@
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { STORAGE_KEY } from '../utils/notification';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
closeDrawer({ commit }) { closeDrawer({ commit }) {
commit(types.CLOSE_DRAWER); commit(types.CLOSE_DRAWER);
}, },
openDrawer({ commit }, storageKey) { openDrawer({ commit }, versionDigest) {
commit(types.OPEN_DRAWER); commit(types.OPEN_DRAWER);
if (storageKey) { if (versionDigest) {
localStorage.setItem(storageKey, JSON.stringify(false)); localStorage.setItem(STORAGE_KEY, versionDigest);
} }
}, },
fetchItems({ commit, state }, { page, version } = { page: null, version: null }) { fetchItems({ commit, state }, { page } = { page: null }) {
if (state.fetching) { if (state.fetching) {
return false; return false;
} }
@ -24,7 +25,6 @@ export default {
.get('/-/whats_new', { .get('/-/whats_new', {
params: { params: {
page, page,
version,
}, },
}) })
.then(({ data, headers }) => { .then(({ data, headers }) => {

View File

@ -1,11 +1,18 @@
export const getStorageKey = (appEl) => appEl.getAttribute('data-storage-key'); export const STORAGE_KEY = 'display-whats-new-notification';
export const getVersionDigest = (appEl) => appEl.getAttribute('data-version-digest');
export const setNotification = (appEl) => { export const setNotification = (appEl) => {
const storageKey = getStorageKey(appEl); const versionDigest = getVersionDigest(appEl);
const notificationEl = document.querySelector('.header-help'); const notificationEl = document.querySelector('.header-help');
let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count'); let notificationCountEl = notificationEl.querySelector('.js-whats-new-notification-count');
if (JSON.parse(localStorage.getItem(storageKey)) === false) { const legacyStorageKey = 'display-whats-new-notification-13.10';
const localStoragePairs = [
[legacyStorageKey, false],
[STORAGE_KEY, versionDigest],
];
if (localStoragePairs.some((pair) => localStorage.getItem(pair[0]) === pair[1].toString())) {
notificationEl.classList.remove('with-notifications'); notificationEl.classList.remove('with-notifications');
if (notificationCountEl) { if (notificationCountEl) {
notificationCountEl.parentElement.removeChild(notificationCountEl); notificationCountEl.parentElement.removeChild(notificationCountEl);

View File

@ -148,7 +148,19 @@
} }
.gl-build-content { .gl-build-content {
@include build-content(); display: inline-block;
padding: 8px 10px 9px;
width: 100%;
border: 1px solid var(--border-color, $border-color);
border-radius: 30px;
background-color: var(--white, $white);
&:hover,
&:focus {
background-color: var(--gray-50, $gray-50);
border: 1px solid $dropdown-toggle-active-border-color;
color: var(--gl-text-color, $gl-text-color);
}
} }
.gl-ci-action-icon-container { .gl-ci-action-icon-container {

View File

@ -5,19 +5,23 @@ module CreatesCommit
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
# rubocop:disable Gitlab/ModuleWithInstanceVariables # rubocop:disable Gitlab/ModuleWithInstanceVariables
def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil) def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil, target_project: nil)
if user_access(@project).can_push_to_branch?(branch_name_or_ref) target_project ||= @project
@project_to_commit_into = @project
if user_access(target_project).can_push_to_branch?(branch_name_or_ref)
@project_to_commit_into = target_project
@branch_name ||= @ref @branch_name ||= @ref
else else
@project_to_commit_into = current_user.fork_of(@project) @project_to_commit_into = current_user.fork_of(target_project)
@branch_name ||= @project_to_commit_into.repository.next_branch('patch') @branch_name ||= @project_to_commit_into.repository.next_branch('patch')
end end
@start_branch ||= @ref || @branch_name @start_branch ||= @ref || @branch_name
start_project = Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml) ? @project_to_commit_into : @project
commit_params = @commit_params.merge( commit_params = @commit_params.merge(
start_project: @project, start_project: start_project,
start_branch: @start_branch, start_branch: @start_branch,
branch_name: @branch_name branch_name: @branch_name
) )
@ -27,7 +31,7 @@ module CreatesCommit
if result[:status] == :success if result[:status] == :success
update_flash_notice(success_notice) update_flash_notice(success_notice)
success_path = final_success_path(success_path) success_path = final_success_path(success_path, target_project)
respond_to do |format| respond_to do |format|
format.html { redirect_to success_path } format.html { redirect_to success_path }
@ -79,9 +83,9 @@ module CreatesCommit
end end
end end
def final_success_path(success_path) def final_success_path(success_path, target_project)
if create_merge_request? if create_merge_request?
merge_request_exists? ? existing_merge_request_path : new_merge_request_path merge_request_exists? ? existing_merge_request_path : new_merge_request_path(target_project)
else else
success_path = success_path.call if success_path.respond_to?(:call) success_path = success_path.call if success_path.respond_to?(:call)
@ -90,12 +94,12 @@ module CreatesCommit
end end
# rubocop:disable Gitlab/ModuleWithInstanceVariables # rubocop:disable Gitlab/ModuleWithInstanceVariables
def new_merge_request_path def new_merge_request_path(target_project)
project_new_merge_request_path( project_new_merge_request_path(
@project_to_commit_into, @project_to_commit_into,
merge_request: { merge_request: {
source_project_id: @project_to_commit_into.id, source_project_id: @project_to_commit_into.id,
target_project_id: @project.id, target_project_id: target_project.id,
source_branch: @branch_name, source_branch: @branch_name,
target_branch: @start_branch target_branch: @start_branch
} }

View File

@ -17,12 +17,13 @@ module RendersCommits
def set_commits_for_rendering(commits, commits_count: nil) def set_commits_for_rendering(commits, commits_count: nil)
@total_commit_count = commits_count || commits.size @total_commit_count = commits_count || commits.size
limited, @hidden_commit_count = limited_commits(commits, @total_commit_count) limited, @hidden_commit_count = limited_commits(commits, @total_commit_count)
commits.each(&:lazy_author) # preload authors
prepare_commits_for_rendering(limited) prepare_commits_for_rendering(limited)
end end
# rubocop: enable Gitlab/ModuleWithInstanceVariables # rubocop: enable Gitlab/ModuleWithInstanceVariables
def prepare_commits_for_rendering(commits) def prepare_commits_for_rendering(commits)
commits.each(&:lazy_author) # preload commits' authors
Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables Banzai::CommitRenderer.render(commits, @project, current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
commits commits

View File

@ -114,7 +114,7 @@ class Projects::CommitController < Projects::ApplicationController
@branch_name = create_new_branch? ? @commit.revert_branch_name : @start_branch @branch_name = create_new_branch? ? @commit.revert_branch_name : @start_branch
create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.", create_commit(Commits::RevertService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully reverted.",
success_path: -> { successful_change_path }, failure_path: failed_change_path) success_path: -> { successful_change_path(@project) }, failure_path: failed_change_path)
end end
def cherry_pick def cherry_pick
@ -122,10 +122,15 @@ class Projects::CommitController < Projects::ApplicationController
return render_404 if @start_branch.blank? return render_404 if @start_branch.blank?
target_project = find_cherry_pick_target_project
return render_404 unless target_project
@branch_name = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch @branch_name = create_new_branch? ? @commit.cherry_pick_branch_name : @start_branch
create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked into #{@branch_name}.", create_commit(Commits::CherryPickService, success_notice: "The #{@commit.change_type_title(current_user)} has been successfully cherry-picked into #{@branch_name}.",
success_path: -> { successful_change_path }, failure_path: failed_change_path) success_path: -> { successful_change_path(target_project) },
failure_path: failed_change_path,
target_project: target_project)
end end
private private
@ -138,8 +143,8 @@ class Projects::CommitController < Projects::ApplicationController
params[:create_merge_request].present? || !can?(current_user, :push_code, @project) params[:create_merge_request].present? || !can?(current_user, :push_code, @project)
end end
def successful_change_path def successful_change_path(target_project)
referenced_merge_request_url || project_commits_url(@project, @branch_name) referenced_merge_request_url || project_commits_url(target_project, @branch_name)
end end
def failed_change_path def failed_change_path
@ -218,4 +223,14 @@ class Projects::CommitController < Projects::ApplicationController
@start_branch = params[:start_branch] @start_branch = params[:start_branch]
@commit_params = { commit: @commit } @commit_params = { commit: @commit }
end end
def find_cherry_pick_target_project
return @project if params[:target_project_id].blank?
return @project unless Feature.enabled?(:pick_into_project, @project, default_enabled: :yaml)
MergeRequestTargetProjectFinder
.new(current_user: current_user, source_project: @project, project_feature: :repository)
.execute
.find_by_id(params[:target_project_id])
end
end end

View File

@ -5,7 +5,7 @@ class WhatsNewController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
before_action :check_valid_page_param, :set_pagination_headers, unless: -> { has_version_param? } before_action :check_valid_page_param, :set_pagination_headers
feature_category :navigation feature_category :navigation
@ -29,19 +29,11 @@ class WhatsNewController < ApplicationController
def highlights def highlights
strong_memoize(:highlights) do strong_memoize(:highlights) do
if has_version_param? ReleaseHighlight.paginated(page: current_page)
ReleaseHighlight.for_version(version: params[:version])
else
ReleaseHighlight.paginated(page: current_page)
end
end end
end end
def set_pagination_headers def set_pagination_headers
response.set_header('X-Next-Page', highlights.next_page) response.set_header('X-Next-Page', highlights.next_page)
end end
def has_version_param?
params[:version].present?
end
end end

View File

@ -139,7 +139,7 @@ module CommitsHelper
def cherry_pick_projects_data(project) def cherry_pick_projects_data(project)
return [] unless Feature.enabled?(:pick_into_project, project, default_enabled: :yaml) return [] unless Feature.enabled?(:pick_into_project, project, default_enabled: :yaml)
target_projects(project).map do |project| [project, project.forked_from_project].compact.map do |project|
{ {
id: project.id.to_s, id: project.id.to_s,
name: project.full_path, name: project.full_path,

View File

@ -56,6 +56,33 @@ module NamespacesHelper
namespaces_options(selected, **options) namespaces_options(selected, **options)
end end
def cascading_namespace_settings_enabled?
NamespaceSetting.cascading_settings_feature_enabled?
end
def cascading_namespace_settings_popover_data(attribute, group, settings_path_helper)
locked_by_ancestor = group.namespace_settings.public_send("#{attribute}_locked_by_ancestor?") # rubocop:disable GitlabSecurity/PublicSend
popover_data = {
locked_by_application_setting: group.namespace_settings.public_send("#{attribute}_locked_by_application_setting?"), # rubocop:disable GitlabSecurity/PublicSend
locked_by_ancestor: locked_by_ancestor
}
if locked_by_ancestor
ancestor_namespace = group.namespace_settings.public_send("#{attribute}_locked_ancestor").namespace # rubocop:disable GitlabSecurity/PublicSend
popover_data[:ancestor_namespace] = {
full_name: ancestor_namespace.full_name,
path: settings_path_helper.call(ancestor_namespace)
}
end
{
popover_data: popover_data.to_json,
testid: 'cascading-settings-lock-icon'
}
end
private private
# Many importers create a temporary Group, so use the real # Many importers create a temporary Group, so use the real

View File

@ -5,15 +5,7 @@ module WhatsNewHelper
ReleaseHighlight.most_recent_item_count ReleaseHighlight.most_recent_item_count
end end
def whats_new_storage_key def whats_new_version_digest
most_recent_version = ReleaseHighlight.versions&.first ReleaseHighlight.most_recent_version_digest
return unless most_recent_version
['display-whats-new-notification', most_recent_version].join('-')
end
def whats_new_versions
ReleaseHighlight.versions
end end
end end

View File

@ -3,17 +3,6 @@
class ReleaseHighlight class ReleaseHighlight
CACHE_DURATION = 1.hour CACHE_DURATION = 1.hour
FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml') FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml')
RELEASE_VERSIONS_IN_A_YEAR = 12
def self.for_version(version:)
index = self.versions.index(version)
return if index.nil?
page = index + 1
self.paginated(page: page)
end
def self.paginated(page: 1) def self.paginated(page: 1)
key = self.cache_key("items:page-#{page}") key = self.cache_key("items:page-#{page}")
@ -82,15 +71,15 @@ class ReleaseHighlight
end end
end end
def self.versions def self.most_recent_version_digest
key = self.cache_key('versions') key = self.cache_key('most_recent_version_digest')
Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do Gitlab::ProcessMemoryCache.cache_backend.fetch(key, expires_in: CACHE_DURATION) do
versions = self.file_paths.first(RELEASE_VERSIONS_IN_A_YEAR).map do |path| version = self.paginated&.items&.first&.[]('release')&.to_s
/\d*\_(\d*\_\d*)\.yml$/.match(path).captures[0].gsub(/0(?=\d)/, "").tr("_", ".")
end
versions.uniq next if version.nil?
Digest::SHA256.hexdigest(version)
end end
end end

View File

@ -2,10 +2,9 @@
- page_title _("Broadcast Messages") - page_title _("Broadcast Messages")
%h3.page-title %h3.page-title
Broadcast Messages = _('Broadcast Messages')
%p.light %p.light
Broadcast messages are displayed for every user and can be used to notify = _('Broadcast messages are displayed for every user and can be used to notify users about scheduled maintenance, recent upgrades and more.')
users about scheduled maintenance, recent upgrades and more.
= render 'form' = render 'form'
@ -15,12 +14,12 @@
%table.table.table-responsive %table.table.table-responsive
%thead %thead
%tr %tr
%th Status %th= _('Status')
%th Preview %th= _('Preview')
%th Starts %th= _('Starts')
%th Ends %th= _('Ends')
%th Target Path %th= _(' Target Path')
%th Type %th= _(' Type')
%th &nbsp; %th &nbsp;
%tbody %tbody
- @broadcast_messages.each do |message| - @broadcast_messages.each do |message|
@ -38,7 +37,7 @@
%td %td
= message.broadcast_type.capitalize = message.broadcast_type.capitalize
%td.gl-white-space-nowrap.gl-display-flex %td.gl-white-space-nowrap.gl-display-flex
= link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn btn-icon gl-button' = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: _('Edit'), class: 'btn btn-icon gl-button'
= link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2' = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: _('Remove'), class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2'
= paginate @broadcast_messages, theme: 'gitlab' = paginate @broadcast_messages, theme: 'gitlab'

View File

@ -18,11 +18,11 @@
= nav_link(page: [dashboard_projects_path, root_path]) do = nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do = link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
= _("Your projects") = _("Your projects")
%span.badge.badge-pill= limited_counter_with_delimiter(@total_user_projects_count) %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_user_projects_count)
= nav_link(page: starred_dashboard_projects_path) do = nav_link(page: starred_dashboard_projects_path) do
= link_to starred_dashboard_projects_path, data: {placement: 'right'} do = link_to starred_dashboard_projects_path, data: {placement: 'right'} do
= _("Starred projects") = _("Starred projects")
%span.badge.badge-pill= limited_counter_with_delimiter(@total_starred_projects_count) %span.badge.gl-tab-counter-badge.badge-muted.badge-pill.gl-badge.sm= limited_counter_with_delimiter(@total_starred_projects_count)
= nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do = nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do
= link_to explore_root_path, data: {placement: 'right'} do = link_to explore_root_path, data: {placement: 'right'} do
= _("Explore projects") = _("Explore projects")

View File

@ -2,15 +2,15 @@
- if @resource.unconfirmed_email.present? || !@resource.created_recently? - if @resource.unconfirmed_email.present? || !@resource.created_recently?
#content #content
= email_default_heading(@resource.unconfirmed_email || @resource.email) = email_default_heading(@resource.unconfirmed_email || @resource.email)
%p Click the link below to confirm your email address. %p= _('Click the link below to confirm your email address.')
#cta #cta
= link_to 'Confirm your email address', confirmation_link = link_to _('Confirm your email address'), confirmation_link
- else - else
#content #content
- if Gitlab.com? - if Gitlab.com?
= email_default_heading('Thanks for signing up to GitLab!') = email_default_heading(_('Thanks for signing up to GitLab!'))
- else - else
= email_default_heading("Welcome, #{@resource.name}!") = email_default_heading(_("Welcome, %{name}!") % { name: @resource.name })
%p To get started, click the link below to confirm your account. %p= _("To get started, click the link below to confirm your account.")
#cta #cta
= link_to 'Confirm your account', confirmation_link = link_to _('Confirm your account'), confirmation_link

View File

@ -3,6 +3,7 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- expanded = expanded_by_default? - expanded = expanded_by_default?
= render 'shared/namespaces/cascading_settings/lock_popovers'
%section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') } %section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') }
.settings-header .settings-header

View File

@ -8,27 +8,27 @@
= render 'shared/allow_request_access', form: f = render 'shared/allow_request_access', form: f
.form-group.gl-mb-3 .form-group.gl-mb-3
.form-check .gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'form-check-input' = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'custom-control-input'
= f.label :share_with_group_lock, class: 'form-check-label' do = f.label :share_with_group_lock, class: 'custom-control-label' do
%span.d-block %span
- group_link = link_to @group.name, group_path(@group) - group_link = link_to @group.name, group_path(@group)
= s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link } = s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link }
%span.js-descr.text-muted= share_with_group_lock_help_text(@group) %p.js-descr.help-text= share_with_group_lock_help_text(@group)
.form-group.gl-mb-3 .form-group.gl-mb-3
.form-check .gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'form-check-input' = f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'custom-control-input'
= f.label :emails_disabled, class: 'form-check-label' do = f.label :emails_disabled, class: 'custom-control-label' do
%span.d-block= s_('GroupSettings|Disable email notifications') %span= s_('GroupSettings|Disable email notifications')
%span.text-muted= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.') %p.help-text= s_('GroupSettings|This setting will override user notification preferences for all members of the group, subgroups, and projects.')
.form-group.gl-mb-3 .form-group.gl-mb-3
.form-check .gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input' = f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'custom-control-input'
= f.label :mentions_disabled, class: 'form-check-label' do = f.label :mentions_disabled, class: 'custom-control-label' do
%span.d-block= s_('GroupSettings|Disable group mentions') %span= s_('GroupSettings|Disable group mentions')
%span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.') %p.help-text= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
= render 'groups/settings/project_access_token_creation', f: f, group: @group = render 'groups/settings/project_access_token_creation', f: f, group: @group
= render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group = render_if_exists 'groups/settings/delayed_project_removal', f: f, group: @group

View File

@ -1,10 +1,10 @@
- return unless render_setting_to_allow_project_access_token_creation?(group) - return unless render_setting_to_allow_project_access_token_creation?(group)
.form-group.gl-mb-3 .form-group.gl-mb-3
.form-check .gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :resource_access_token_creation_allowed, checked: group.namespace_settings.resource_access_token_creation_allowed?, class: 'form-check-input', data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' } = f.check_box :resource_access_token_creation_allowed, checked: group.namespace_settings.resource_access_token_creation_allowed?, class: 'custom-control-input', data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' }
= f.label :resource_access_token_creation_allowed, class: 'form-check-label' do = f.label :resource_access_token_creation_allowed, class: 'custom-control-label' do
%span.gl-display-block= s_('GroupSettings|Allow project access token creation') %span= s_('GroupSettings|Allow project access token creation')
- project_access_tokens_link = help_page_path('user/project/settings/project_access_tokens') - project_access_tokens_link = help_page_path('user/project/settings/project_access_tokens')
- link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: project_access_tokens_link } - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: project_access_tokens_link }
%span.text-muted= s_('GroupSettings|Users can create %{link_start}project access tokens%{link_end} for projects in this group.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } %p.help-text= s_('GroupSettings|Users can create %{link_start}project access tokens%{link_end} for projects in this group.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe }

View File

@ -120,7 +120,7 @@
= sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right') = sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right')
= sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left')
#whats-new-app{ data: { storage_key: whats_new_storage_key, versions: whats_new_versions, gitlab_dot_com: Gitlab.dev_env_org_or_com? } } #whats-new-app{ data: { version_digest: whats_new_version_digest } }
- if can?(current_user, :update_user_status, current_user) - if can?(current_user, :update_user_status, current_user)
.js-set-status-modal-wrapper{ data: user_status_data } .js-set-status-modal-wrapper{ data: user_status_data }

View File

@ -1,5 +1,5 @@
%li %li
%button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', data: { storage_key: whats_new_storage_key }, class: 'gl-display-flex!' } %button.gl-justify-content-space-between.gl-align-items-center.js-whats-new-trigger{ type: 'button', class: 'gl-display-flex!' }
= _("What's new") = _("What's new")
%span.js-whats-new-notification-count.whats-new-notification-count %span.js-whats-new-notification-count.whats-new-notification-count
= whats_new_most_recent_release_items_count = whats_new_most_recent_release_items_count

View File

@ -0,0 +1,14 @@
- attribute = local_assigns.fetch(:attribute, nil)
- group = local_assigns.fetch(:group, nil)
- form = local_assigns.fetch(:form, nil)
- return unless attribute && group && form && cascading_namespace_settings_enabled?
- return if group.namespace_settings.public_send("#{attribute}_locked?")
- lock_attribute = "lock_#{attribute}"
.gl-form-checkbox.custom-control.custom-checkbox
= form.check_box lock_attribute, checked: group.namespace_settings.public_send(lock_attribute), class: 'custom-control-input', data: { testid: 'enforce-for-all-subgroups-checkbox' }
= form.label lock_attribute, class: 'custom-control-label' do
%span= s_('CascadingSettings|Enforce for all subgroups')
%p.help-text= s_('CascadingSettings|Subgroups cannot change this setting.')

View File

@ -0,0 +1 @@
.js-cascading-settings-lock-popovers

View File

@ -0,0 +1,21 @@
- attribute = local_assigns.fetch(:attribute, nil)
- group = local_assigns.fetch(:group, nil)
- form = local_assigns.fetch(:form, nil)
- settings_path_helper = local_assigns.fetch(:settings_path_helper, nil)
- help_text = local_assigns.fetch(:help_text, nil)
- return unless attribute && group && form && settings_path_helper
- setting_locked = group.namespace_settings.public_send("#{attribute}_locked?")
= form.label attribute, class: 'custom-control-label', aria: { disabled: setting_locked } do
%span.position-relative.gl-pr-6.gl-display-inline-flex
= yield
- if setting_locked
%button.position-absolute.gl-top-3.gl-right-0.gl-translate-y-n50.gl-cursor-default.btn.btn-default.btn-sm.gl-button.btn-default-tertiary.js-cascading-settings-lock-popover-target{ class: 'gl-p-1! gl-text-gray-600! gl-bg-transparent!',
type: 'button',
data: cascading_namespace_settings_popover_data(attribute, group, settings_path_helper) }
= sprite_icon('lock', size: 16)
- if help_text
%p.help-text
= help_text

View File

@ -0,0 +1,5 @@
---
title: Fix N+1 for searching commits
merge_request: 58867
author:
type: performance

View File

@ -0,0 +1,5 @@
---
title: Externalize strings in broadcast_messages/index.html.haml
merge_request: 58146
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Externalize strings in _confirmation_instructions_account.html.haml
merge_request: 58214
author: nuwe1
type: other

View File

@ -0,0 +1,5 @@
---
title: Add gl-badge for badges in dashboard nav
merge_request: 57936
author: Yogi (@yo)
type: changed

View File

@ -0,0 +1,133 @@
---
stage: Package
group: Package
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Go Proxy API
This is the API documentation for [Go Packages](../../user/packages/go_proxy/index.md).
This API is behind a feature flag that is disabled by default. GitLab administrators with access to
the GitLab Rails console can [enable](../../administration/feature_flags.md)
this API for your GitLab instance.
WARNING:
This API is used by the [Go client](https://maven.apache.org/)
and is generally not meant for manual consumption.
For instructions on how to work with the Go Proxy, see the [Go Proxy package documentation](../../user/packages/go_proxy/index.md).
NOTE:
These endpoints do not adhere to the standard API authentication methods.
See the [Go Proxy package documentation](../../user/packages/go_proxy/index.md)
for details on which headers and token types are supported.
## List
> Introduced in GitLab 13.1.
Get all tagged versions for a given Go module:
```plaintext
GET projects/:id/packages/go/:module_name/@v/list
```
| Attribute | Type | Required | Description |
| -------------- | ------ | -------- | ----------- |
| `id` | string | yes | The project ID or full path of a project. |
| `module_name` | string | yes | The name of the Go module. |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/list"
```
Example output:
```shell
"v1.0.0\nv1.0.1\nv1.3.8\n2.0.0\n2.1.0\n3.0.0"
```
## Version metadata
> Introduced in GitLab 13.1.
Get all tagged versions for a given Go module:
```plaintext
GET projects/:id/packages/go/:module_name/@v/:module_version.info
```
| Attribute | Type | Required | Description |
| ----------------- | ------ | -------- | ----------- |
| `id` | string | yes | The project ID or full path of a project. |
| `module_name` | string | yes | The name of the Go module. |
| `module_version` | string | yes | The version of the Go module. |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.info"
```
Example output:
```json
{
"Version": "v1.0.0",
"Time": "1617822312 -0600"
}
```
## Download module file
> Introduced in GitLab 13.1.
Fetch the `.mod` module file:
```plaintext
GET projects/:id/packages/go/:module_name/@v/:module_version.mod
```
| Attribute | Type | Required | Description |
| ----------------- | ------ | -------- | ----------- |
| `id` | string | yes | The project ID or full path of a project. |
| `module_name` | string | yes | The name of the Go module. |
| `module_version` | string | yes | The version of the Go module. |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.mod"
```
Write to a file:
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.mod" >> foo.mod
```
This writes to `foo.mod` in the current directory.
## Download module source
> Introduced in GitLab 13.1.
Fetch the `.zip` of the module source:
```plaintext
GET projects/:id/packages/go/:module_name/@v/:module_version.zip
```
| Attribute | Type | Required | Description |
| ----------------- | ------ | -------- | ----------- |
| `id` | string | yes | The project ID or full path of a project. |
| `module_name` | string | yes | The name of the Go module. |
| `module_version` | string | yes | The version of the Go module. |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.zip"
```
Write to a file:
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/go/my-go-module/@v/1.0.0.zip" >> foo.zip
```
This writes to `foo.zip` in the current directory.

View File

@ -110,21 +110,66 @@ The current method provides several attributes that are sent on each click event
| property | text | false | Any additional property of the element, or object being acted on. | | property | text | false | Any additional property of the element, or object being acted on. |
| value | decimal | false | Describes a numeric value or something directly related to the event. This could be the value of an input (e.g. `10` when clicking `internal` visibility). | | value | decimal | false | Describes a numeric value or something directly related to the event. This could be the value of an input (e.g. `10` when clicking `internal` visibility). |
### Examples
| category* | label | action | property** | value |
|-------------|------------------|-----------------------|----------|:-----:|
| [root:index] | main_navigation | click_navigation_link | `[link_label]` | - |
| [groups:boards:show] | toggle_swimlanes | click_toggle_button | - | `[is_active]` |
| [projects:registry:index] | registry_delete | click_button | - | - |
| [projects:registry:index] | registry_delete | confirm_deletion | - | - |
| [projects:blob:show] | congratulate_first_pipeline | click_button | `[human_access]` | - |
| [projects:clusters:new] | chart_options | generate_link | `[chart_link]` | - |
| [projects:clusters:new] | chart_options | click_add_label_button | `[label_id]` | - |
_* It's ok to omit the category, and use the default._<br>
_** Property is usually the best place for variable strings._
### Reference SQL
#### Last 20 `reply_comment_button` events
```sql
SELECT
event_id,
v_tracker,
event_label,
event_action,
event_property,
event_value,
event_category,
contexts
FROM legacy.snowplow_structured_events_all
WHERE
event_label = 'reply_comment_button'
AND event_action = 'click_button'
-- AND event_category = 'projects:issues:show'
-- AND event_value = 1
ORDER BY collector_tstamp DESC
LIMIT 20
```
### Web-specific parameters ### Web-specific parameters
Snowplow JS adds many [web-specific parameters](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/snowplow-tracker-protocol/#Web-specific_parameters) to all web events by default. Snowplow JS adds many [web-specific parameters](https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/snowplow-tracker-protocol/#Web-specific_parameters) to all web events by default.
## Implementing Snowplow JS (Frontend) tracking ## Implementing Snowplow JS (Frontend) tracking
GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. There are a few ways to use tracking, but each generally requires at minimum, a `category` and an `action`. Additional data can be provided that adheres to our [Structured event taxonomy](#structured-event-taxonomy). GitLab provides `Tracking`, an interface that wraps the [Snowplow JavaScript Tracker](https://github.com/snowplow/snowplow/wiki/javascript-tracker) for tracking custom events. The simplest way to use it is to add `data-` attributes to clickable elements and dropdowns. There is also a Vue mixin (exposing a `track` method), and the static method `Tracking.event`. Each of these requires at minimum a `category` and an `action`. Additional data can be provided that adheres to our [Structured event taxonomy](#structured-event-taxonomy).
| field | type | default value | description | | field | type | default value | description |
|:-----------|:-------|:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |:-----------|:-------|:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `category` | string | `document.body.dataset.page` | Page or subsection of a page that events are being captured within. | | `category` | string | `document.body.dataset.page` | Page or subsection of a page that events are being captured within. |
| `action` | string | 'generic' | Action the user is taking. Clicks should be `click` and activations should be `activate`, so for example, focusing a form field would be `activate_form_input`, and clicking a button would be `click_button`. | | `action` | string | generic | Action the user is taking. Clicks should be `click` and activations should be `activate`, so for example, focusing a form field would be `activate_form_input`, and clicking a button would be `click_button`. |
| `data` | object | `{}` | Additional data such as `label`, `property`, `value`, and `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). | | `data` | object | `{}` | Additional data such as `label`, `property`, `value`, and `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). |
### Tracking in HAML (or Vue Templates) ### Usage recommendations
- Use [data attributes](#tracking-with-data-attributes) on HTML elements that emits either the `click`, `show.bs.dropdown`, or `hide.bs.dropdown` events.
- Use the [Vue mixin](#tracking-within-vue-components) when tracking custom events, or if the supported events for data attributes are not propagating.
- Use the [Tracking class directly](#tracking-in-raw-javascript) when tracking on raw JS files.
### Tracking with data attributes
When working within HAML (or Vue templates) we can add `data-track-*` attributes to elements of interest. All elements that have a `data-track-action` attribute automatically have event tracking bound on clicks. When working within HAML (or Vue templates) we can add `data-track-*` attributes to elements of interest. All elements that have a `data-track-action` attribute automatically have event tracking bound on clicks.
@ -142,7 +187,7 @@ Below is an example of `data-track-*` attributes assigned to a button:
/> />
``` ```
Event listeners are bound at the document level to handle click events on or within elements with these data attributes. This allows them to be properly handled on re-rendering and changes to the DOM. Note that because of the way these events are bound, click events should not be stopped from propagating up the DOM tree. If for any reason click events are being stopped from propagating, you need to implement your own listeners and follow the instructions in [Tracking in raw JavaScript](#tracking-in-raw-javascript). Event listeners are bound at the document level to handle click events on or within elements with these data attributes. This allows them to be properly handled on re-rendering and changes to the DOM. Note that because of the way these events are bound, click events should not be stopped from propagating up the DOM tree. If for any reason click events are being stopped from propagating, you need to implement your own listeners and follow the instructions in [Tracking within Vue components](#tracking-within-vue-components) or [Tracking in raw JavaScript](#tracking-in-raw-javascript).
Below is a list of supported `data-track-*` attributes: Below is a list of supported `data-track-*` attributes:
@ -154,16 +199,29 @@ Below is a list of supported `data-track-*` attributes:
| `data-track-value` | false | The `value` as described in our [Structured event taxonomy](#structured-event-taxonomy). If omitted, this is the element's `value` property or an empty string. For checkboxes, the default value is the element's checked attribute or `false` when unchecked. | | `data-track-value` | false | The `value` as described in our [Structured event taxonomy](#structured-event-taxonomy). If omitted, this is the element's `value` property or an empty string. For checkboxes, the default value is the element's checked attribute or `false` when unchecked. |
| `data-track-context` | false | The `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). | | `data-track-context` | false | The `context` as described in our [Structured event taxonomy](#structured-event-taxonomy). |
#### Available helpers
```ruby
tracking_attrs(label, action, property) # { data: { track_label... } }
%button{ **tracking_attrs('main_navigation', 'click_button', 'navigation') }
```
#### Caveats #### Caveats
When using the GitLab helper method [`nav_link`](https://gitlab.com/gitlab-org/gitlab/-/blob/898b286de322e5df6a38d257b10c94974d580df8/app/helpers/tab_helper.rb#L69) be sure to wrap `html_options` under the `html_options` keyword argument. When using the GitLab helper method [`nav_link`](https://gitlab.com/gitlab-org/gitlab/-/blob/898b286de322e5df6a38d257b10c94974d580df8/app/helpers/tab_helper.rb#L69) be sure to wrap `html_options` under the `html_options` keyword argument.
Be careful, as this behavior can be confused with the `ActionView` helper method [`link_to`](https://api.rubyonrails.org/v5.2.3/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to) that does not require additional wrapping of `html_options` Be careful, as this behavior can be confused with the `ActionView` helper method [`link_to`](https://api.rubyonrails.org/v5.2.3/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to) that does not require additional wrapping of `html_options`
`nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { data: { track_label: "groups_dropdown", track_action: "click_dropdown" } })` ```ruby
# Bad
= nav_link(controller: ['dashboard/groups', 'explore/groups'], data: { track_label: "explore_groups", track_action: "click_button" })
vs # Good
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { data: { track_label: "explore_groups", track_action: "click_button" } })
`link_to assigned_issues_dashboard_path, title: _('Issues'), data: { track_label: 'main_navigation', track_action: 'click_issues_link' }` # Good (other helpers)
= link_to explore_groups_path, title: _("Explore"), data: { track_label: "explore_groups", track_action: "click_button" }
```
### Tracking within Vue components ### Tracking within Vue components
@ -186,17 +244,19 @@ export default {
return { return {
expanded: false, expanded: false,
tracking: { tracking: {
label: 'left_sidebar' label: 'left_sidebar',
} },
}; };
}, },
} };
``` ```
The mixin provides a `track` method that can be called within the template, or from component methods. An example of the whole implementation might look like the following. The mixin provides a `track` method that can be called within the template,
or from component methods. An example of the whole implementation might look like this:
```javascript ```javascript
export default { export default {
name: 'RightSidebar',
mixins: [Tracking.mixin({ label: 'right_sidebar' })], mixins: [Tracking.mixin({ label: 'right_sidebar' })],
data() { data() {
return { return {
@ -206,26 +266,84 @@ export default {
methods: { methods: {
toggle() { toggle() {
this.expanded = !this.expanded; this.expanded = !this.expanded;
this.track('click_toggle', { value: this.expanded }) // Additional data will be merged, like `value` below
this.track('click_toggle', { value: Number(this.expanded) });
} }
} }
}; };
``` ```
And if needed within the template, you can use the `track` method directly as well. The event data can be provided with a `tracking` object, declared in the `data` function,
or as a `computed property`.
```javascript
export default {
name: 'RightSidebar',
mixins: [Tracking.mixin()],
data() {
return {
tracking: {
label: 'right_sidebar',
// category: '',
// property: '',
// value: '',
},
};
},
};
```
The event data can be provided directly in the `track` function as well.
This object will merge with any previously provided options.
```javascript
this.track('click_button', {
label: 'right_sidebar',
});
```
Lastly, if needed within the template, you can use the `track` method directly as well.
```html ```html
<template> <template>
<div> <div>
<a class="toggle" @click.prevent="toggle">Toggle</a> <button data-testid="toggle" @click="toggle">Toggle</button>
<div v-if="expanded"> <div v-if="expanded">
<p>Hello world!</p> <p>Hello world!</p>
<a @click.prevent="track('click_action')">Track an event</a> <button @click="track('click_action')">Track another event</button>
</div> </div>
</div> </div>
</template> </template>
``` ```
#### Testing example
```javascript
import { mockTracking } from 'helpers/tracking_helper';
// mockTracking(category, documentOverride, spyMethod)
describe('RightSidebar.vue', () => {
let trackingSpy;
let wrapper;
beforeEach(() => {
trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
});
const findToggle = () => wrapper.find('[data-testid="toggle"]');
it('tracks turning off toggle', () => {
findToggle().trigger('click');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', {
label: 'right_sidebar',
value: 0,
});
});
});
```
### Tracking in raw JavaScript ### Tracking in raw JavaScript
Custom event tracking and instrumentation can be added by directly calling the `Tracking.event` static function. The following example demonstrates tracking a click on a button by calling `Tracking.event` manually. Custom event tracking and instrumentation can be added by directly calling the `Tracking.event` static function. The following example demonstrates tracking a click on a button by calling `Tracking.event` manually.
@ -234,64 +352,35 @@ Custom event tracking and instrumentation can be added by directly calling the `
import Tracking from '~/tracking'; import Tracking from '~/tracking';
const button = document.getElementById('create_from_template_button'); const button = document.getElementById('create_from_template_button');
button.addEventListener('click', () => { button.addEventListener('click', () => {
Tracking.event('dashboard:projects:index', 'click_button', { Tracking.event('dashboard:projects:index', 'click_button', {
label: 'create_from_template', label: 'create_from_template',
property: 'template_preview', property: 'template_preview',
value: 'rails',
});
})
```
### Tests and test helpers
In Jest particularly in Vue tests, you can use the following:
```javascript
import { mockTracking } from 'helpers/tracking_helper';
describe('MyTracking', () => {
let spy;
beforeEach(() => {
spy = mockTracking('_category_', wrapper.element, jest.spyOn);
});
it('tracks an event when clicked on feedback', () => {
wrapper.find('.discover-feedback-icon').trigger('click');
expect(spy).toHaveBeenCalledWith('_category_', 'click_button', {
label: 'security-discover-feedback-cta',
property: '0',
});
}); });
}); });
``` ```
In obsolete Karma tests it's used as below: #### Testing example
```javascript ```javascript
import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper'; import Tracking from '~/tracking';
describe('my component', () => { describe('MyTracking', () => {
let trackingSpy; let wrapper;
beforeEach(() => { beforeEach(() => {
trackingSpy = mockTracking('_category_', vm.$el, spyOn); jest.spyOn(Tracking, 'event');
}); });
const triggerEvent = () => { const findButton = () => wrapper.find('[data-testid="create_from_template"]');
// action which should trigger a event
};
it('tracks an event when toggled', () => { it('tracks event', () => {
expect(trackingSpy).not.toHaveBeenCalled(); findButton().trigger('click');
triggerEvent('a.toggle'); expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'create_from_template',
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_edit_button', { property: 'template_preview',
label: 'right_sidebar',
property: 'confidentiality',
}); });
}); });
}); });
@ -355,12 +444,6 @@ There are several tools for developing and testing Snowplow Event
**{check-circle}** Available, **{status_preparing}** In progress, **{dotted-circle}** Not Planned **{check-circle}** Available, **{status_preparing}** In progress, **{dotted-circle}** Not Planned
### Preparing your MR for Review
1. For frontend events, in the MR description section, add a screenshot of the event's relevant section using the [Snowplow Analytics Debugger](https://chrome.google.com/webstore/detail/snowplow-analytics-debugg/jbnlcgeengmijcghameodeaenefieedm) Chrome browser extension.
1. For backend events, please use Snowplow Micro and add the output of the Snowplow Micro good events `GET http://localhost:9090/micro/good`.
1. Include a member of the Product Intelligence team as a reviewer of your MR. Mention `@gitlab-org/growth/product_intelligence/engineers` in your MR to request a review.
### Snowplow Analytics Debugger Chrome Extension ### Snowplow Analytics Debugger Chrome Extension
Snowplow Analytics Debugger is a browser extension for testing frontend events. This works on production, staging and local development environments. Snowplow Analytics Debugger is a browser extension for testing frontend events. This works on production, staging and local development environments.

View File

@ -34,7 +34,7 @@ Product Intelligence files.
### Roles and process ### Roles and process
The merge request **author** should: #### The merge request **author** should
- Decide whether a Product Intelligence review is needed. - Decide whether a Product Intelligence review is needed.
- If a Product Intelligence review is needed, add the labels - If a Product Intelligence review is needed, add the labels
@ -48,7 +48,15 @@ The merge request **author** should:
[Metrics Dictionary](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/development/usage_ping/dictionary.md) if it is needed. [Metrics Dictionary](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/development/usage_ping/dictionary.md) if it is needed.
- Add a changelog [according to guidelines](../changelog.md). - Add a changelog [according to guidelines](../changelog.md).
The Product Intelligence **reviewer** should: ##### When adding or modifiying Snowplow events
- For frontend events, when relevant, add a screenshot of the event in
the [testing tool](../snowplow.md#developing-and-testing-snowplow) used.
- For backend events, when relevant, add the output of the Snowplow Micro
good events `GET http://localhost:9090/micro/good` (it might be a good idea
to reset with `GET http://localhost:9090/micro/reset` first).
#### The Product Intelligence **reviewer** should
- Perform a first-pass review on the merge request and suggest improvements to the author. - Perform a first-pass review on the merge request and suggest improvements to the author.
- Approve the MR, and relabel the MR with `~"product intelligence::approved"`. - Approve the MR, and relabel the MR with `~"product intelligence::approved"`.
@ -71,6 +79,9 @@ Any of the Product Intelligence engineers can be assigned for the Product Intell
- For tracking using Redis HLL (HyperLogLog): - For tracking using Redis HLL (HyperLogLog):
- Check the Redis slot. - Check the Redis slot.
- Check if a [feature flag is needed](index.md#recommendations). - Check if a [feature flag is needed](index.md#recommendations).
- For tracking with Snowplow:
- Check that the [event taxonomy](../snowplow.md#structured-event-taxonomy) is correct.
- Check the [usage recomendations](../snowplow.md#usage-recommendations).
- Metrics YAML definitions: - Metrics YAML definitions:
- Check the metric `description`. - Check the metric `description`.
- Check the metrics `key_path`. - Check the metrics `key_path`.

View File

@ -55,29 +55,46 @@ Feature.disable(:security_orchestration_policies_configuration, Project.find(<pr
## Security Policies project ## Security Policies project
The Security Policies feature is a repository to store policies. All security policies are stored in The Security Policies feature is a repository to store policies. All security policies are stored as
the `.gitlab/security-policies` directory as a YAML file with this format: the `.gitlab/security-policies/policy.yml` YAML file with this format:
```yaml ```yaml
--- ---
type: scan_execution_policy scan_execution_policy:
name: Enforce DAST in every pipeline - name: Enforce DAST in every pipeline
description: This policy enforces pipeline configuration to have a job with DAST scan description: This policy enforces pipeline configuration to have a job with DAST scan
enabled: true enabled: true
rules: rules:
- type: pipeline - type: pipeline
branch: master branch: master
actions: actions:
- scan: dast - scan: dast
scanner_profile: Scanner Profile A scanner_profile: Scanner Profile A
site_profile: Site Profile B site_profile: Site Profile B
- name: Enforce DAST in every pipeline in main branch
description: This policy enforces pipeline configuration to have a job with DAST scan for main branch
enabled: true
rules:
- type: pipeline
branch: main
actions:
- scan: dast
scanner_profile: Scanner Profile C
site_profile: Site Profile D
``` ```
### Scan Execution Policies Schema
The YAML file with Scan Execution Policies consists of an array of objects matching Scan Execution Policy Schema nested under the `scan_execution_policy` key. You can configure a maximum of 5 policies under the `scan_execution_policy` key.
| Field | Type | Possible values | Description |
|-------|------|-----------------|-------------|
| `scan_execution_policy` | `array` of Scan Execution Policy | | List of scan execution policies (maximum 5) |
### Scan Execution Policy Schema ### Scan Execution Policy Schema
| Field | Type | Possible values | Description | | Field | Type | Possible values | Description |
|-------|------|-----------------|-------------| |-------|------|-----------------|-------------|
| `type` | `string` | `scan_execution_policy` | The policy's type. |
| `name` | `string` | | Name of the policy. | | `name` | `string` | | Name of the policy. |
| `description` (optional) | `string` | | Description of the policy. | | `description` (optional) | `string` | | Description of the policy. |
| `enabled` | `boolean` | `true`, `false` | Flag to enable (`true`) or disable (`false`) the policy. | | `enabled` | `boolean` | `true`, `false` | Flag to enable (`true`) or disable (`false`) the policy. |
@ -107,7 +124,7 @@ rule in the defined policy are met.
Note the following: Note the following:
- You must create the [site profile](../dast/index.md#site-profile) and [scanner profile](../dast/index.md#scanner-profile) - You must create the [site profile](../dast/index.md#site-profile) and [scanner profile](../dast/index.md#scanner-profile)
with selected names for the project that is assigned to the selected Security Policy Project. with selected names for each project that is assigned to the selected Security Policy Project.
Otherwise, the policy is not applied and a job with an error message is created instead. Otherwise, the policy is not applied and a job with an error message is created instead.
- Once you associate the site profile and scanner profile by name in the policy, it is not possible - Once you associate the site profile and scanner profile by name in the policy, it is not possible
to modify or delete them. If you want to modify them, you must first disable the policy by setting to modify or delete them. If you want to modify them, you must first disable the policy by setting
@ -117,22 +134,37 @@ Here's an example:
```yaml ```yaml
--- ---
type: scan_execution_policy scan_execution_policy:
name: Enforce DAST in every pipeline - name: Enforce DAST in every release pipeline
description: This policy enforces pipeline configuration to have a job with DAST scan description: This policy enforces pipeline configuration to have a job with DAST scan for release branches
enabled: true enabled: true
rules: rules:
- type: pipeline - type: pipeline
branch: release/* branch: release/*
actions: actions:
- scan: dast - scan: dast
scanner_profile: Scanner Profile A scanner_profile: Scanner Profile A
site_profile: Site Profile B site_profile: Site Profile B
- name: Enforce DAST in every pipeline in main branch
description: This policy enforces pipeline configuration to have a job with DAST scan for main branch
enabled: true
rules:
- type: pipeline
branch: main
actions:
- scan: dast
scanner_profile: Scanner Profile C
site_profile: Site Profile D
``` ```
In this example, the DAST scan runs with the scanner profile `Scanner Profile A` and the site In this example, the DAST scan runs with the scanner profile `Scanner Profile A` and the site
profile `Site Profile B`. The scan runs for every pipeline executed on branches that match the profile `Site Profile B` for every pipeline executed on branches that match the
`release/*` wildcard (for example, branch name `release/v1.2.1`). `release/*` wildcard (for example, branch name `release/v1.2.1`); and the DAST scan runs with
the scanner profile `Scanner Profile C` and the site profile `Site Profile D` for every pipeline executed on `main` branch.
NOTE:
All scanner and site profiles must be configured and created for each project that is assigned to the selected Security Policy Project.
If they are not created, the job will fail with the error message.
## Security Policy project selection ## Security Policy project selection

View File

@ -98,7 +98,7 @@ Please note that the certificate [fingerprint algorithm](../../../integration/sa
- [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/211962) in GitLab 13.8 with allowing group owners to not go through SSO. - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/211962) in GitLab 13.8 with allowing group owners to not go through SSO.
- [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/9152) in GitLab 13.11 with enforcing open SSO session to use Git if this setting is switched on. - [Improved](https://gitlab.com/gitlab-org/gitlab/-/issues/9152) in GitLab 13.11 with enforcing open SSO session to use Git if this setting is switched on.
With this option enabled, users must go through your group's GitLab single sign-on URL if they wish to access group resources through the UI. Users can't be manually added as members. With this option enabled, users (except owners) must go through your group's GitLab single sign-on URL if they wish to access group resources through the UI. Users can't be manually added as members.
SSO enforcement does not affect sign in or access to any resources outside of the group. Users can view which groups and projects they are a member of without SSO sign in. SSO enforcement does not affect sign in or access to any resources outside of the group. Users can view which groups and projects they are a member of without SSO sign in.

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@ -16,6 +16,9 @@ info: To determine the technical writer assigned to the Stage/Group associated w
With the Go proxy for GitLab, every project in GitLab can be fetched with the With the Go proxy for GitLab, every project in GitLab can be fetched with the
[Go proxy protocol](https://proxy.golang.org/). [Go proxy protocol](https://proxy.golang.org/).
For documentation of the specific API endpoints that the Go Proxy uses, see the
[Go Proxy API documentation](../../../api/packages/go_proxy.md).
## Enable the Go proxy ## Enable the Go proxy
The Go proxy for GitLab is under development, and isn't ready for production use The Go proxy for GitLab is under development, and isn't ready for production use

View File

@ -14,7 +14,7 @@ You can [comment on](#comment-on-snippets), [clone](#clone-snippets), and
[syntax highlighting](#filenames), [embedding](#embed-snippets), [downloading](#download-snippets), [syntax highlighting](#filenames), [embedding](#embed-snippets), [downloading](#download-snippets),
and you can maintain your snippets with the [snippets API](../api/snippets.md). and you can maintain your snippets with the [snippets API](../api/snippets.md).
![Example of snippet](img/snippet_tooltip_v13_10.png) ![Example of snippet](img/snippet_intro_v13_11.png)
GitLab provides two types of snippets: GitLab provides two types of snippets:
@ -125,7 +125,7 @@ A single snippet can support up to 10 files, which helps keep related files toge
- A `gulpfile.js` file and a `package.json` file, which together can be - A `gulpfile.js` file and a `package.json` file, which together can be
used to bootstrap a project and manage its dependencies. used to bootstrap a project and manage its dependencies.
If you need more than 10 files for your snippet, we recommend you a create a If you need more than 10 files for your snippet, we recommend you create a
[wiki](project/wiki/index.md) instead. Wikis are available for projects at all [wiki](project/wiki/index.md) instead. Wikis are available for projects at all
subscription levels, and [groups](project/wiki/index.md#group-wikis) for subscription levels, and [groups](project/wiki/index.md#group-wikis) for
[GitLab Premium](https://about.gitlab.com/pricing). [GitLab Premium](https://about.gitlab.com/pricing).

View File

@ -31,9 +31,15 @@ msgstr ""
msgid " Please sign in." msgid " Please sign in."
msgstr "" msgstr ""
msgid " Target Path"
msgstr ""
msgid " Try to %{action} this file again." msgid " Try to %{action} this file again."
msgstr "" msgstr ""
msgid " Type"
msgstr ""
msgid " You need to do this before %{grace_period_deadline}." msgid " You need to do this before %{grace_period_deadline}."
msgstr "" msgstr ""
@ -5245,6 +5251,9 @@ msgstr ""
msgid "Broadcast Messages" msgid "Broadcast Messages"
msgstr "" msgstr ""
msgid "Broadcast messages are displayed for every user and can be used to notify users about scheduled maintenance, recent upgrades and more."
msgstr ""
msgid "Browse Directory" msgid "Browse Directory"
msgstr "" msgstr ""
@ -5682,6 +5691,21 @@ msgstr ""
msgid "Capacity threshold" msgid "Capacity threshold"
msgstr "" msgstr ""
msgid "CascadingSettings|Enforce for all subgroups"
msgstr ""
msgid "CascadingSettings|Setting enforced"
msgstr ""
msgid "CascadingSettings|Subgroups cannot change this setting."
msgstr ""
msgid "CascadingSettings|This setting has been enforced by an instance admin."
msgstr ""
msgid "CascadingSettings|This setting has been enforced by an owner of %{link}."
msgstr ""
msgid "CascadingSettings|cannot be changed because it is locked by an ancestor" msgid "CascadingSettings|cannot be changed because it is locked by an ancestor"
msgstr "" msgstr ""
@ -6378,6 +6402,9 @@ msgstr ""
msgid "Click the button below." msgid "Click the button below."
msgstr "" msgstr ""
msgid "Click the link below to confirm your email address."
msgstr ""
msgid "Click to expand it." msgid "Click to expand it."
msgstr "" msgstr ""
@ -8153,6 +8180,12 @@ msgstr ""
msgid "Confirm" msgid "Confirm"
msgstr "" msgstr ""
msgid "Confirm your account"
msgstr ""
msgid "Confirm your email address"
msgstr ""
msgid "Confirmation email sent to %{email}" msgid "Confirmation email sent to %{email}"
msgstr "" msgstr ""
@ -9666,18 +9699,39 @@ msgstr ""
msgid "DORA4Metrics|Date" msgid "DORA4Metrics|Date"
msgstr "" msgstr ""
msgid "DORA4Metrics|Days from merge to deploy"
msgstr ""
msgid "DORA4Metrics|Deployments" msgid "DORA4Metrics|Deployments"
msgstr "" msgstr ""
msgid "DORA4Metrics|Deployments charts" msgid "DORA4Metrics|Deployments charts"
msgstr "" msgstr ""
msgid "DORA4Metrics|Lead time"
msgstr ""
msgid "DORA4Metrics|Median lead time"
msgstr ""
msgid "DORA4Metrics|No merge requests were deployed during this period"
msgstr ""
msgid "DORA4Metrics|Something went wrong while getting deployment frequency data" msgid "DORA4Metrics|Something went wrong while getting deployment frequency data"
msgstr "" msgstr ""
msgid "DORA4Metrics|Something went wrong while getting lead time data."
msgstr ""
msgid "DORA4Metrics|These charts display the frequency of deployments to the production environment, as part of the DORA 4 metrics. The environment must be named %{codeStart}production%{codeEnd} for its data to appear in these charts." msgid "DORA4Metrics|These charts display the frequency of deployments to the production environment, as part of the DORA 4 metrics. The environment must be named %{codeStart}production%{codeEnd} for its data to appear in these charts."
msgstr "" msgstr ""
msgid "DORA4Metrics|These charts display the median time between a merge request being merged and deployed to production, as part of the DORA 4 metrics."
msgstr ""
msgid "DORA4|Lead time charts"
msgstr ""
msgid "Dashboard" msgid "Dashboard"
msgstr "" msgstr ""
@ -11850,6 +11904,9 @@ msgstr ""
msgid "End Time" msgid "End Time"
msgstr "" msgstr ""
msgid "Ends"
msgstr ""
msgid "Ends at (UTC)" msgid "Ends at (UTC)"
msgstr "" msgstr ""
@ -15350,7 +15407,16 @@ msgstr ""
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups" msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
msgstr "" msgstr ""
msgid "GroupSettings|Projects will be permanently deleted after a %{waiting_period}-day delay. This delay can be %{customization_link} in instance settings" msgid "GroupSettings|Projects will be permanently deleted after a %{waiting_period}-day delay."
msgstr ""
msgid "GroupSettings|Projects will be permanently deleted after a %{waiting_period}-day delay. Inherited by subgroups."
msgstr ""
msgid "GroupSettings|Projects will be permanently deleted after a %{waiting_period}-day delay. This delay can be %{link_start}customized by an admin%{link_end} in instance settings."
msgstr ""
msgid "GroupSettings|Projects will be permanently deleted after a %{waiting_period}-day delay. This delay can be %{link_start}customized by an admin%{link_end} in instance settings. Inherited by subgroups."
msgstr "" msgstr ""
msgid "GroupSettings|Select a sub-group as the custom project template source for this group." msgid "GroupSettings|Select a sub-group as the custom project template source for this group."
@ -25473,6 +25539,9 @@ msgstr ""
msgid "Purchase more storage" msgid "Purchase more storage"
msgstr "" msgstr ""
msgid "PurchaseStep|An error occured in the purchase step. If the problem persists please contact support@gitlab.com."
msgstr ""
msgid "Push" msgid "Push"
msgstr "" msgstr ""
@ -29512,6 +29581,9 @@ msgstr ""
msgid "Starting..." msgid "Starting..."
msgstr "" msgstr ""
msgid "Starts"
msgstr ""
msgid "Starts %{startsIn}" msgid "Starts %{startsIn}"
msgstr "" msgstr ""
@ -30618,6 +30690,9 @@ msgstr ""
msgid "Thank you for your support request! We are tracking your request as ticket #%{issue_iid}, and will respond as soon as we can." msgid "Thank you for your support request! We are tracking your request as ticket #%{issue_iid}, and will respond as soon as we can."
msgstr "" msgstr ""
msgid "Thanks for signing up to GitLab!"
msgstr ""
msgid "Thanks for your purchase!" msgid "Thanks for your purchase!"
msgstr "" msgstr ""
@ -34907,6 +34982,9 @@ msgstr ""
msgid "Welcome to the guided GitLab tour" msgid "Welcome to the guided GitLab tour"
msgstr "" msgstr ""
msgid "Welcome, %{name}!"
msgstr ""
msgid "What are you searching for?" msgid "What are you searching for?"
msgstr "" msgstr ""
@ -35908,9 +35986,6 @@ msgstr ""
msgid "Your U2F device was registered!" msgid "Your U2F device was registered!"
msgstr "" msgstr ""
msgid "Your Version"
msgstr ""
msgid "Your WebAuthn device did not send a valid JSON response." msgid "Your WebAuthn device did not send a valid JSON response."
msgstr "" msgstr ""

View File

@ -132,7 +132,7 @@ module QA
all(element_selector_css(name), **kwargs) all(element_selector_css(name), **kwargs)
end end
def check_element(name) def check_element(name, click_by_js = false)
if find_element(name, visible: false).checked? if find_element(name, visible: false).checked?
QA::Runtime::Logger.debug("#{name} is already checked") QA::Runtime::Logger.debug("#{name} is already checked")
@ -140,7 +140,7 @@ module QA
end end
retry_until(sleep_interval: 1) do retry_until(sleep_interval: 1) do
find_element(name, visible: false).click click_checkbox_or_radio(name, click_by_js)
checked = find_element(name, visible: false).checked? checked = find_element(name, visible: false).checked?
QA::Runtime::Logger.debug(checked ? "#{name} was checked" : "#{name} was not checked") QA::Runtime::Logger.debug(checked ? "#{name} was checked" : "#{name} was not checked")
@ -149,7 +149,7 @@ module QA
end end
end end
def uncheck_element(name) def uncheck_element(name, click_by_js = false)
unless find_element(name, visible: false).checked? unless find_element(name, visible: false).checked?
QA::Runtime::Logger.debug("#{name} is already unchecked") QA::Runtime::Logger.debug("#{name} is already unchecked")
@ -157,7 +157,7 @@ module QA
end end
retry_until(sleep_interval: 1) do retry_until(sleep_interval: 1) do
find_element(name, visible: false).click click_checkbox_or_radio(name, click_by_js)
unchecked = !find_element(name, visible: false).checked? unchecked = !find_element(name, visible: false).checked?
QA::Runtime::Logger.debug(unchecked ? "#{name} was unchecked" : "#{name} was not unchecked") QA::Runtime::Logger.debug(unchecked ? "#{name} was unchecked" : "#{name} was not unchecked")
@ -175,9 +175,7 @@ module QA
end end
retry_until(sleep_interval: 1) do retry_until(sleep_interval: 1) do
radio = find_element(name, visible: false) click_checkbox_or_radio(name, click_by_js)
# Some radio buttons are hidden by their labels and cannot be clicked directly
click_by_js ? page.execute_script("arguments[0].click();", radio) : radio.click
selected = find_element(name, visible: false).checked? selected = find_element(name, visible: false).checked?
QA::Runtime::Logger.debug(selected ? "#{name} was selected" : "#{name} was not selected") QA::Runtime::Logger.debug(selected ? "#{name} was selected" : "#{name} was not selected")
@ -423,6 +421,14 @@ module QA
end end
end end
end end
private
def click_checkbox_or_radio(name, click_by_js)
box = find_element(name, visible: false)
# Some checkboxes and radio buttons are hidden by their labels and cannot be clicked directly
click_by_js ? page.execute_script("arguments[0].click();", box) : box.click
end
end end
end end
end end

View File

@ -238,18 +238,12 @@ module QA
end end
def mark_to_squash def mark_to_squash
# The squash checkbox is disabled on load
wait_until do
has_element?(:squash_checkbox)
end
# The squash checkbox is enabled via JS # The squash checkbox is enabled via JS
wait_until(reload: false) do wait_until(reload: false) do
!find_element(:squash_checkbox).disabled? !find_element(:squash_checkbox, visible: false).disabled?
end end
# TODO: Fix workaround for data-qa-selector failure check_element(:squash_checkbox, true)
click_element(:squash_checkbox)
end end
def merge! def merge!

View File

@ -11,10 +11,17 @@ module QA
view 'app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue' do view 'app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue' do
element :options_button element :options_button
element :cherry_pick_button
element :email_patches element :email_patches
element :plain_diff element :plain_diff
end end
def cherry_pick_commit
click_element(:options_button)
click_element(:cherry_pick_button, Page::Component::CommitModal)
click_element(:submit_commit_button)
end
def select_email_patches def select_email_patches
click_element :options_button click_element :options_button
visit_link_in_element :email_patches visit_link_in_element :email_patches

View File

@ -13,9 +13,9 @@ module QA
end end
view 'app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue' do view 'app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue' do
element :service_jira_issue_transition_enabled element :service_jira_issue_transition_enabled_checkbox
element :service_jira_issue_transition_automatic_true, ':data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}`"' # rubocop:disable QA/ElementWithPattern element :service_jira_issue_transition_automatic_true_radio, ':data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`"' # rubocop:disable QA/ElementWithPattern
element :service_jira_issue_transition_automatic_false, ':data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}`"' # rubocop:disable QA/ElementWithPattern element :service_jira_issue_transition_automatic_false_radio, ':data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`"' # rubocop:disable QA/ElementWithPattern
element :service_jira_issue_transition_id_field element :service_jira_issue_transition_id_field
end end
@ -55,15 +55,15 @@ module QA
end end
def enable_transitions def enable_transitions
click_element_coordinates(:service_jira_issue_transition_enabled, visible: false) check_element(:service_jira_issue_transition_enabled_checkbox, true)
end end
def use_automatic_transitions def use_automatic_transitions
click_element_coordinates(:service_jira_issue_transition_automatic_true, visible: false) choose_element(:service_jira_issue_transition_automatic_true_radio, true)
end end
def use_custom_transitions def use_custom_transitions
click_element_coordinates(:service_jira_issue_transition_automatic_false, visible: false) choose_element(:service_jira_issue_transition_automatic_false_radio, true)
end end
def set_transition_ids(transition_ids) def set_transition_ids(transition_ids)

View File

@ -22,7 +22,7 @@ module QA
Flow::Login.sign_in Flow::Login.sign_in
end end
it 'cherry picks a basic merge request', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1616' do it 'creates a merge request', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1616' do
feature_mr.visit! feature_mr.visit!
Page::MergeRequest::Show.perform do |merge_request| Page::MergeRequest::Show.perform do |merge_request|
@ -30,8 +30,11 @@ module QA
merge_request.cherry_pick! merge_request.cherry_pick!
end end
Page::MergeRequest::New.perform do |merge_request| Page::MergeRequest::New.perform(&:create_merge_request)
expect(merge_request).to have_content('The merge request has been successfully cherry-picked')
Page::MergeRequest::Show.perform do |merge_request|
merge_request.click_diffs_tab
expect(merge_request).to have_file(feature_mr.file_name)
end end
end end
end end

View File

@ -0,0 +1,43 @@
# frozen_string_literal: true
module QA
RSpec.describe 'Create' do
describe 'Cherry picking a commit' do
let(:file_name) { "secret_file.md" }
let(:project) do
Resource::Project.fabricate_via_api! do |project|
project.name = 'project'
project.initialize_with_readme = true
end
end
let(:commit) do
Resource::Repository::Commit.fabricate_via_api! do |commit|
commit.project = project
commit.branch = "development"
commit.start_branch = project.default_branch
commit.commit_message = 'Add new file'
commit.add_files([
{ file_path: file_name, content: 'pssst!' }
])
end
end
before do
Flow::Login.sign_in
commit.visit!
end
it 'creates a merge request', testcase: 'https://gitlab.com/gitlab-org/quality/testcases/-/issues/1752' do
Page::Project::Commit::Show.perform(&:cherry_pick_commit)
Page::MergeRequest::New.perform(&:create_merge_request)
Page::MergeRequest::Show.perform do |merge_request|
merge_request.click_diffs_tab
expect(merge_request).to have_file(file_name)
end
end
end
end
end

View File

@ -52,13 +52,13 @@ module QA
elements elements
end end
def check_element(name) def check_element(name, click_by_js = nil)
log("checking :#{name}") log("checking :#{name}")
super super
end end
def uncheck_element(name) def uncheck_element(name, click_by_js = nil)
log("unchecking :#{name}") log("unchecking :#{name}")
super super

View File

@ -57,4 +57,16 @@ RSpec.describe RendersCommits do
end.not_to exceed_all_query_limit(control_count) end.not_to exceed_all_query_limit(control_count)
end end
end end
describe '.prepare_commits_for_rendering' do
it 'avoids N+1' do
control = ActiveRecord::QueryRecorder.new do
subject.prepare_commits_for_rendering(merge_request.commits.take(1))
end
expect do
subject.prepare_commits_for_rendering(merge_request.commits)
end.not_to exceed_all_query_limit(control.count)
end
end
end end

View File

@ -3,6 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Projects::CommitController do RSpec.describe Projects::CommitController do
include ProjectForksHelper
let_it_be(:project) { create(:project, :repository) } let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
@ -295,6 +297,102 @@ RSpec.describe Projects::CommitController do
expect(flash[:alert]).to match('Sorry, we cannot cherry-pick this commit automatically.') expect(flash[:alert]).to match('Sorry, we cannot cherry-pick this commit automatically.')
end end
end end
context 'when a project has a fork' do
let(:project) { create(:project, :repository) }
let(:forked_project) { fork_project(project, user, namespace: user.namespace, repository: true) }
let(:target_project) { project }
let(:create_merge_request) { nil }
def send_request
post(:cherry_pick,
params: {
namespace_id: forked_project.namespace,
project_id: forked_project,
target_project_id: target_project.id,
start_branch: 'feature',
id: forked_project.commit.id,
create_merge_request: create_merge_request
})
end
def merge_request_url(source_project, branch)
project_new_merge_request_path(
source_project,
merge_request: {
source_project_id: source_project.id,
target_project_id: project.id,
source_branch: branch,
target_branch: 'feature'
}
)
end
before do
forked_project.add_maintainer(user)
end
it 'successfully cherry picks a commit from fork to upstream project' do
send_request
expect(response).to redirect_to project_commits_path(project, 'feature')
expect(flash[:notice]).to eq('The commit has been successfully cherry-picked into feature.')
expect(project.commit('feature').message).to include(forked_project.commit.id)
end
context 'when the cherry pick is performed via merge request' do
let(:create_merge_request) { true }
it 'successfully cherry picks a commit from fork to a cherry pick branch' do
branch = forked_project.commit.cherry_pick_branch_name
send_request
expect(response).to redirect_to merge_request_url(project, branch)
expect(flash[:notice]).to start_with("The commit has been successfully cherry-picked into #{branch}")
expect(project.commit(branch).message).to include(forked_project.commit.id)
end
end
context 'when a user cannot push to upstream project' do
let(:create_merge_request) { true }
before do
project.add_reporter(user)
end
it 'cherry picks a commit to the fork' do
branch = forked_project.commit.cherry_pick_branch_name
send_request
expect(response).to redirect_to merge_request_url(forked_project, branch)
expect(flash[:notice]).to start_with("The commit has been successfully cherry-picked into #{branch}")
expect(project.commit('feature').message).not_to include(forked_project.commit.id)
expect(forked_project.commit(branch).message).to include(forked_project.commit.id)
end
end
context 'when a user do not have access to the target project' do
let(:target_project) { create(:project, :private) }
it 'cherry picks a commit to the fork' do
send_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'disable pick_into_project feature flag' do
before do
stub_feature_flags(pick_into_project: false)
end
it 'does not cherry pick a commit from fork to upstream' do
send_request
expect(project.commit('feature').message).not_to include(forked_project.commit.id)
end
end
end
end end
describe 'GET diff_for_path' do describe 'GET diff_for_path' do

View File

@ -0,0 +1,152 @@
import { GlPopover } from '@gitlab/ui';
import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper';
import LockPopovers from '~/namespaces/cascading_settings/components/lock_popovers.vue';
describe('LockPopovers', () => {
const mockNamespace = {
full_name: 'GitLab Org / GitLab',
path: '/gitlab-org/gitlab/-/edit',
};
const createPopoverMountEl = ({
lockedByApplicationSetting = false,
lockedByAncestor = false,
}) => {
const popoverMountEl = document.createElement('div');
popoverMountEl.classList.add('js-cascading-settings-lock-popover-target');
const popoverData = {
locked_by_application_setting: lockedByApplicationSetting,
locked_by_ancestor: lockedByAncestor,
};
if (lockedByApplicationSetting) {
popoverMountEl.setAttribute('data-popover-data', JSON.stringify(popoverData));
} else if (lockedByAncestor) {
popoverMountEl.setAttribute(
'data-popover-data',
JSON.stringify({ ...popoverData, ancestor_namespace: mockNamespace }),
);
}
document.body.appendChild(popoverMountEl);
return popoverMountEl;
};
let wrapper;
const createWrapper = () => {
wrapper = mountExtended(LockPopovers);
};
const findPopover = () => extendedWrapper(wrapper.find(GlPopover));
const findByTextInPopover = (text, options) =>
findPopover().findByText((_, element) => element.textContent === text, options);
const expectPopoverMessageExists = (message) => {
expect(findByTextInPopover(message).exists()).toBe(true);
};
const expectCorrectPopoverTarget = (popoverMountEl, popover = findPopover()) => {
expect(popover.props('target')).toEqual(popoverMountEl);
};
afterEach(() => {
document.body.innerHTML = '';
});
describe('when setting is locked by an application setting', () => {
let popoverMountEl;
beforeEach(() => {
popoverMountEl = createPopoverMountEl({ lockedByApplicationSetting: true });
createWrapper();
});
it('displays correct popover message', () => {
expectPopoverMessageExists('This setting has been enforced by an instance admin.');
});
it('sets `target` prop correctly', () => {
expectCorrectPopoverTarget(popoverMountEl);
});
});
describe('when setting is locked by an ancestor namespace', () => {
let popoverMountEl;
beforeEach(() => {
popoverMountEl = createPopoverMountEl({ lockedByAncestor: true });
createWrapper();
});
it('displays correct popover message', () => {
expectPopoverMessageExists(
`This setting has been enforced by an owner of ${mockNamespace.full_name}.`,
);
});
it('displays link to ancestor namespace', () => {
expect(
findByTextInPopover(mockNamespace.full_name, {
selector: `a[href="${mockNamespace.path}"]`,
}).exists(),
).toBe(true);
});
it('sets `target` prop correctly', () => {
expectCorrectPopoverTarget(popoverMountEl);
});
});
describe('when setting is locked by an application setting and an ancestor namespace', () => {
let popoverMountEl;
beforeEach(() => {
popoverMountEl = createPopoverMountEl({
lockedByAncestor: true,
lockedByApplicationSetting: true,
});
createWrapper();
});
it('application setting takes precedence and correct message is shown', () => {
expectPopoverMessageExists('This setting has been enforced by an instance admin.');
});
it('sets `target` prop correctly', () => {
expectCorrectPopoverTarget(popoverMountEl);
});
});
describe('when setting is not locked', () => {
beforeEach(() => {
createPopoverMountEl({
lockedByAncestor: false,
lockedByApplicationSetting: false,
});
createWrapper();
});
it('does not render popover', () => {
expect(findPopover().exists()).toBe(false);
});
});
describe('when there are multiple mount elements', () => {
let popoverMountEl1;
let popoverMountEl2;
beforeEach(() => {
popoverMountEl1 = createPopoverMountEl({ lockedByApplicationSetting: true });
popoverMountEl2 = createPopoverMountEl({ lockedByAncestor: true });
createWrapper();
});
it('mounts multiple popovers', () => {
const popovers = wrapper.findAll(GlPopover).wrappers;
expectCorrectPopoverTarget(popoverMountEl1, popovers[0]);
expectCorrectPopoverTarget(popoverMountEl2, popovers[1]);
});
});
});

View File

@ -1,5 +1,5 @@
<div class='whats-new-notification-fixture-root'> <div class='whats-new-notification-fixture-root'>
<div class='app' data-storage-key='storage-key'></div> <div class='app' data-version-digest='version-digest'></div>
<div class='header-help'> <div class='header-help'>
<div class='js-whats-new-notification-count'></div> <div class='js-whats-new-notification-count'></div>
</div> </div>

View File

@ -178,6 +178,30 @@ describe('timeIntervalInWords', () => {
}); });
}); });
describe('humanizeTimeInterval', () => {
it.each`
intervalInSeconds | expected
${0} | ${'0 seconds'}
${1} | ${'1 second'}
${1.48} | ${'1.5 seconds'}
${2} | ${'2 seconds'}
${60} | ${'1 minute'}
${91} | ${'1.5 minutes'}
${120} | ${'2 minutes'}
${3600} | ${'1 hour'}
${5401} | ${'1.5 hours'}
${7200} | ${'2 hours'}
${86400} | ${'1 day'}
${129601} | ${'1.5 days'}
${172800} | ${'2 days'}
`(
'returns "$expected" when the time interval is $intervalInSeconds seconds',
({ intervalInSeconds, expected }) => {
expect(datetimeUtility.humanizeTimeInterval(intervalInSeconds)).toBe(expected);
},
);
});
describe('dateInWords', () => { describe('dateInWords', () => {
const date = new Date('07/01/2016'); const date = new Date('07/01/2016');

View File

@ -1,10 +1,11 @@
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import { GRAPHQL, STAGE_VIEW } from '~/pipelines/components/graph/constants'; import { GRAPHQL, LAYER_VIEW, STAGE_VIEW } from '~/pipelines/components/graph/constants';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import JobItem from '~/pipelines/components/graph/job_item.vue'; import JobItem from '~/pipelines/components/graph/job_item.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import { listByLayers } from '~/pipelines/components/parsing_utils';
import { import {
generateResponse, generateResponse,
mockPipelineResponse, mockPipelineResponse,
@ -17,6 +18,7 @@ describe('graph component', () => {
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer); const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent); const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const findStageNameInJob = () => wrapper.find('[data-testid="stage-name-in-job"]');
const defaultProps = { const defaultProps = {
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'), pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
@ -82,6 +84,10 @@ describe('graph component', () => {
expect(findLinksLayer().exists()).toBe(true); expect(findLinksLayer().exists()).toBe(true);
}); });
it('does not display stage name on the job in default (stage) mode', () => {
expect(findStageNameInJob().exists()).toBe(false);
});
describe('when column requests a refresh', () => { describe('when column requests a refresh', () => {
beforeEach(() => { beforeEach(() => {
findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); findStageColumns().at(0).vm.$emit('refreshPipelineGraph');
@ -93,7 +99,7 @@ describe('graph component', () => {
}); });
describe('when links are present', () => { describe('when links are present', () => {
beforeEach(async () => { beforeEach(() => {
createComponent({ createComponent({
mountFn: mount, mountFn: mount,
stubOverride: { 'job-item': false }, stubOverride: { 'job-item': false },
@ -132,4 +138,24 @@ describe('graph component', () => {
expect(findLinkedColumns()).toHaveLength(2); expect(findLinkedColumns()).toHaveLength(2);
}); });
}); });
describe('in layers mode', () => {
beforeEach(() => {
createComponent({
mountFn: mount,
stubOverride: {
'job-item': false,
'job-group-dropdown': false,
},
props: {
viewType: LAYER_VIEW,
pipelineLayers: listByLayers(defaultProps.pipeline),
},
});
});
it('displays the stage name on the job', () => {
expect(findStageNameInJob().exists()).toBe(true);
});
});
}); });

View File

@ -1,4 +1,4 @@
import { shallowMount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue'; import JobGroupDropdown from '~/pipelines/components/graph/job_group_dropdown.vue';
describe('job group dropdown component', () => { describe('job group dropdown component', () => {
@ -65,12 +65,16 @@ describe('job group dropdown component', () => {
let wrapper; let wrapper;
const findButton = () => wrapper.find('button'); const findButton = () => wrapper.find('button');
const createComponent = ({ mountFn = shallowMount }) => {
wrapper = mountFn(JobGroupDropdown, { propsData: { group } });
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(JobGroupDropdown, { propsData: { group } }); createComponent({ mountFn: mount });
}); });
it('renders button with group name and size', () => { it('renders button with group name and size', () => {

View File

@ -122,7 +122,7 @@ describe('pipeline graph job item', () => {
}, },
}); });
expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test'); expect(findJobWithoutLink().attributes('title')).toBe('test');
}); });
it('should not render status label when it is provided', () => { it('should not render status label when it is provided', () => {
@ -138,7 +138,7 @@ describe('pipeline graph job item', () => {
}, },
}); });
expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test - success'); expect(findJobWithoutLink().attributes('title')).toBe('test - success');
}); });
}); });

View File

@ -28,7 +28,7 @@ const mockGroups = Array(4)
}); });
const defaultProps = { const defaultProps = {
title: 'Fish', name: 'Fish',
groups: mockGroups, groups: mockGroups,
pipelineId: 159, pipelineId: 159,
}; };
@ -62,7 +62,7 @@ describe('stage column component', () => {
}); });
it('should render provided title', () => { it('should render provided title', () => {
expect(findStageColumnTitle().text()).toBe(defaultProps.title); expect(findStageColumnTitle().text()).toBe(defaultProps.name);
}); });
it('should render the provided groups', () => { it('should render the provided groups', () => {
@ -119,7 +119,7 @@ describe('stage column component', () => {
], ],
}, },
], ],
title: 'test <img src=x onerror=alert(document.domain)>', name: 'test <img src=x onerror=alert(document.domain)>',
}, },
}); });
}); });

View File

@ -10,6 +10,7 @@ import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_char
jest.mock('~/lib/utils/url_utility'); jest.mock('~/lib/utils/url_utility');
const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} }; const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} };
const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} };
describe('ProjectsPipelinesChartsApp', () => { describe('ProjectsPipelinesChartsApp', () => {
let wrapper; let wrapper;
@ -25,6 +26,7 @@ describe('ProjectsPipelinesChartsApp', () => {
}, },
stubs: { stubs: {
DeploymentFrequencyCharts: DeploymentFrequencyChartsStub, DeploymentFrequencyCharts: DeploymentFrequencyChartsStub,
LeadTimeCharts: LeadTimeChartsStub,
}, },
}, },
mountOptions, mountOptions,
@ -44,6 +46,7 @@ describe('ProjectsPipelinesChartsApp', () => {
const findGlTabs = () => wrapper.find(GlTabs); const findGlTabs = () => wrapper.find(GlTabs);
const findAllGlTab = () => wrapper.findAll(GlTab); const findAllGlTab = () => wrapper.findAll(GlTab);
const findGlTabAt = (i) => findAllGlTab().at(i); const findGlTabAt = (i) => findAllGlTab().at(i);
const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub);
const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub); const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub);
const findPipelineCharts = () => wrapper.find(PipelineCharts); const findPipelineCharts = () => wrapper.find(PipelineCharts);
@ -51,15 +54,23 @@ describe('ProjectsPipelinesChartsApp', () => {
expect(findPipelineCharts().exists()).toBe(true); expect(findPipelineCharts().exists()).toBe(true);
}); });
it('renders the lead time charts', () => {
expect(findLeadTimeCharts().exists()).toBe(true);
});
describe('when shouldRenderDeploymentFrequencyCharts is true', () => { describe('when shouldRenderDeploymentFrequencyCharts is true', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } }); createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } });
}); });
it('renders the deployment frequency charts in a tab', () => { it('renders the expected tabs', () => {
expect(findGlTabs().exists()).toBe(true); expect(findGlTabs().exists()).toBe(true);
expect(findGlTabAt(0).attributes('title')).toBe('Pipelines'); expect(findGlTabAt(0).attributes('title')).toBe('Pipelines');
expect(findGlTabAt(1).attributes('title')).toBe('Deployments'); expect(findGlTabAt(1).attributes('title')).toBe('Deployments');
expect(findGlTabAt(2).attributes('title')).toBe('Lead Time');
});
it('renders the deployment frequency charts', () => {
expect(findDeploymentFrequencyCharts().exists()).toBe(true); expect(findDeploymentFrequencyCharts().exists()).toBe(true);
}); });
@ -108,6 +119,7 @@ describe('ProjectsPipelinesChartsApp', () => {
describe('when provided with a query param', () => { describe('when provided with a query param', () => {
it.each` it.each`
chart | tab chart | tab
${'lead-time'} | ${'2'}
${'deployments'} | ${'1'} ${'deployments'} | ${'1'}
${'pipelines'} | ${'0'} ${'pipelines'} | ${'0'}
${'fake'} | ${'0'} ${'fake'} | ${'0'}
@ -160,8 +172,13 @@ describe('ProjectsPipelinesChartsApp', () => {
createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: false } }); createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: false } });
}); });
it('renders the expected tabs', () => {
expect(findGlTabs().exists()).toBe(true);
expect(findGlTabAt(0).attributes('title')).toBe('Pipelines');
expect(findGlTabAt(1).attributes('title')).toBe('Lead Time');
});
it('does not render the deployment frequency charts in a tab', () => { it('does not render the deployment frequency charts in a tab', () => {
expect(findGlTabs().exists()).toBe(false);
expect(findDeploymentFrequencyCharts().exists()).toBe(false); expect(findDeploymentFrequencyCharts().exists()).toBe(false);
}); });
}); });

View File

@ -1,4 +1,4 @@
import { GlDrawer, GlInfiniteScroll, GlTabs } from '@gitlab/ui'; import { GlDrawer, GlInfiniteScroll } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils'; import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
@ -21,12 +21,9 @@ describe('App', () => {
let actions; let actions;
let state; let state;
let trackingSpy; let trackingSpy;
let gitlabDotCom = true;
const buildProps = () => ({ const buildProps = () => ({
storageKey: 'storage-key', versionDigest: 'version-digest',
versions: ['3.11', '3.10'],
gitlabDotCom,
}); });
const buildWrapper = () => { const buildWrapper = () => {
@ -91,7 +88,7 @@ describe('App', () => {
}); });
it('dispatches openDrawer and tracking calls when mounted', () => { it('dispatches openDrawer and tracking calls when mounted', () => {
expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'storage-key'); expect(actions.openDrawer).toHaveBeenCalledWith(expect.any(Object), 'version-digest');
expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', { expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_whats_new_drawer', {
label: 'namespace_id', label: 'namespace_id',
value: 'namespace-840', value: 'namespace-840',
@ -176,54 +173,4 @@ describe('App', () => {
); );
}); });
}); });
describe('self managed', () => {
const findTabs = () => wrapper.find(GlTabs);
const clickSecondTab = async () => {
const secondTab = wrapper.findAll('.nav-link').at(1);
await secondTab.trigger('click');
await new Promise((resolve) => requestAnimationFrame(resolve));
};
beforeEach(() => {
gitlabDotCom = false;
setup();
});
it('renders tabs with drawer body height and content', () => {
const scroll = findInfiniteScroll();
const tabs = findTabs();
expect(scroll.exists()).toBe(false);
expect(tabs.attributes().style).toBe(`height: ${MOCK_DRAWER_BODY_HEIGHT}px;`);
expect(wrapper.find('h5').text()).toBe('Whats New Drawer');
});
describe('fetchVersion', () => {
beforeEach(() => {
actions.fetchItems.mockClear();
});
it('when version isnt fetched, clicking a tab calls fetchItems', async () => {
const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion');
await clickSecondTab();
expect(fetchVersionSpy).toHaveBeenCalledWith('3.10');
expect(actions.fetchItems).toHaveBeenCalledWith(expect.anything(), { version: '3.10' });
});
it('when version has been fetched, clicking a tab calls fetchItems', async () => {
wrapper.vm.$store.state.features.push({ title: 'GitLab Stories', release: 3.1 });
await wrapper.vm.$nextTick();
const fetchVersionSpy = jest.spyOn(wrapper.vm, 'fetchVersion');
await clickSecondTab();
expect(fetchVersionSpy).toHaveBeenCalledWith('3.10');
expect(actions.fetchItems).not.toHaveBeenCalled();
expect(wrapper.find('.tab-pane.active h5').text()).toBe('GitLab Stories');
});
});
});
}); });

View File

@ -11,9 +11,12 @@ describe('whats new actions', () => {
useLocalStorageSpy(); useLocalStorageSpy();
it('should commit openDrawer', () => { it('should commit openDrawer', () => {
testAction(actions.openDrawer, 'storage-key', {}, [{ type: types.OPEN_DRAWER }]); testAction(actions.openDrawer, 'digest-hash', {}, [{ type: types.OPEN_DRAWER }]);
expect(window.localStorage.setItem).toHaveBeenCalledWith('storage-key', 'false'); expect(window.localStorage.setItem).toHaveBeenCalledWith(
'display-whats-new-notification',
'digest-hash',
);
}); });
}); });
@ -45,12 +48,12 @@ describe('whats new actions', () => {
axiosMock.reset(); axiosMock.reset();
axiosMock axiosMock
.onGet('/-/whats_new', { params: { page: 8, version: 40 } }) .onGet('/-/whats_new', { params: { page: 8 } })
.replyOnce(200, [{ title: 'GitLab Stories' }]); .replyOnce(200, [{ title: 'GitLab Stories' }]);
testAction( testAction(
actions.fetchItems, actions.fetchItems,
{ page: 8, version: 40 }, { page: 8 },
{}, {},
expect.arrayContaining([ expect.arrayContaining([
{ type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] }, { type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] },

View File

@ -1,5 +1,5 @@
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { setNotification, getStorageKey } from '~/whats_new/utils/notification'; import { setNotification, getVersionDigest } from '~/whats_new/utils/notification';
describe('~/whats_new/utils/notification', () => { describe('~/whats_new/utils/notification', () => {
useLocalStorageSpy(); useLocalStorageSpy();
@ -33,10 +33,23 @@ describe('~/whats_new/utils/notification', () => {
expect(notificationEl.classList).toContain('with-notifications'); expect(notificationEl.classList).toContain('with-notifications');
}); });
it('removes class and count element when storage key is true', () => { it('removes class and count element when legacy storage key is false', () => {
const notificationEl = findNotificationEl(); const notificationEl = findNotificationEl();
notificationEl.classList.add('with-notifications'); notificationEl.classList.add('with-notifications');
localStorage.setItem('storage-key', 'false'); localStorage.setItem('display-whats-new-notification-13.10', 'false');
expect(findNotificationCountEl()).toExist();
subject();
expect(findNotificationCountEl()).not.toExist();
expect(notificationEl.classList).not.toContain('with-notifications');
});
it('removes class and count element when storage key has current digest', () => {
const notificationEl = findNotificationEl();
notificationEl.classList.add('with-notifications');
localStorage.setItem('display-whats-new-notification', 'version-digest');
expect(findNotificationCountEl()).toExist(); expect(findNotificationCountEl()).toExist();
@ -47,9 +60,9 @@ describe('~/whats_new/utils/notification', () => {
}); });
}); });
describe('getStorageKey', () => { describe('getVersionDigest', () => {
it('retrieves the storage key data attribute from the el', () => { it('retrieves the storage key data attribute from the el', () => {
expect(getStorageKey(getAppEl())).toBe('storage-key'); expect(getVersionDigest(getAppEl())).toBe('version-digest');
}); });
}); });
}); });

View File

@ -200,7 +200,7 @@ RSpec.describe CommitsHelper do
end end
it 'returns data for cherry picking into a project' do it 'returns data for cherry picking into a project' do
expect(helper.cherry_pick_projects_data(project)).to match_array([ expect(helper.cherry_pick_projects_data(forked_project)).to match_array([
{ id: project.id.to_s, name: project.full_path, refsUrl: refs_project_path(project) }, { id: project.id.to_s, name: project.full_path, refsUrl: refs_project_path(project) },
{ id: forked_project.id.to_s, name: forked_project.full_path, refsUrl: refs_project_path(forked_project) } { id: forked_project.id.to_s, name: forked_project.full_path, refsUrl: refs_project_path(forked_project) }
]) ])

View File

@ -194,4 +194,75 @@ RSpec.describe NamespacesHelper do
end end
end end
end end
describe '#cascading_namespace_settings_enabled?' do
subject { helper.cascading_namespace_settings_enabled? }
context 'when `cascading_namespace_settings` feature flag is enabled' do
it 'returns `true`' do
expect(subject).to be(true)
end
end
context 'when `cascading_namespace_settings` feature flag is disabled' do
before do
stub_feature_flags(cascading_namespace_settings: false)
end
it 'returns `false`' do
expect(subject).to be(false)
end
end
end
describe '#cascading_namespace_settings_popover_data' do
attribute = :delayed_project_removal
subject do
helper.cascading_namespace_settings_popover_data(
attribute,
subgroup1,
-> (locked_ancestor) { edit_group_path(locked_ancestor, anchor: 'js-permissions-settings') }
)
end
context 'when locked by an application setting' do
before do
allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_application_setting?").and_return(true)
allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_ancestor?").and_return(false)
end
it 'returns expected hash' do
expect(subject).to match({
popover_data: {
locked_by_application_setting: true,
locked_by_ancestor: false
}.to_json,
testid: 'cascading-settings-lock-icon'
})
end
end
context 'when locked by an ancestor namespace' do
before do
allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_application_setting?").and_return(false)
allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_by_ancestor?").and_return(true)
allow(subgroup1.namespace_settings).to receive("#{attribute}_locked_ancestor").and_return(admin_group.namespace_settings)
end
it 'returns expected hash' do
expect(subject).to match({
popover_data: {
locked_by_application_setting: false,
locked_by_ancestor: true,
ancestor_namespace: {
full_name: admin_group.full_name,
path: edit_group_path(admin_group, anchor: 'js-permissions-settings')
}
}.to_json,
testid: 'cascading-settings-lock-icon'
})
end
end
end
end end

View File

@ -3,25 +3,13 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe WhatsNewHelper do RSpec.describe WhatsNewHelper do
describe '#whats_new_storage_key' do describe '#whats_new_version_digest' do
subject { helper.whats_new_storage_key } let(:digest) { 'digest' }
context 'when version exist' do it 'calls ReleaseHighlight.most_recent_version_digest' do
let(:release_item) { double(:item) } expect(ReleaseHighlight).to receive(:most_recent_version_digest).and_return(digest)
before do expect(helper.whats_new_version_digest).to eq(digest)
allow(ReleaseHighlight).to receive(:versions).and_return([84.0])
end
it { is_expected.to eq('display-whats-new-notification-84.0') }
end
context 'when most recent release highlights do NOT exist' do
before do
allow(ReleaseHighlight).to receive(:versions).and_return(nil)
end
it { is_expected.to be_nil }
end end
end end
@ -44,14 +32,4 @@ RSpec.describe WhatsNewHelper do
end end
end end
end end
describe '#whats_new_versions' do
let(:versions) { [84.0] }
it 'returns ReleaseHighlight.versions' do
expect(ReleaseHighlight).to receive(:versions).and_return(versions)
expect(helper.whats_new_versions).to eq(versions)
end
end
end end

View File

@ -13,26 +13,6 @@ RSpec.describe ReleaseHighlight, :clean_gitlab_redis_cache do
ReleaseHighlight.instance_variable_set(:@file_paths, nil) ReleaseHighlight.instance_variable_set(:@file_paths, nil)
end end
describe '.for_version' do
subject { ReleaseHighlight.for_version(version: version) }
let(:version) { '1.1' }
context 'with version param that exists' do
it 'returns items from that version' do
expect(subject.items.first['title']).to eq("It's gonna be a bright")
end
end
context 'with version param that does NOT exist' do
let(:version) { '84.0' }
it 'returns nil' do
expect(subject).to be_nil
end
end
end
describe '.paginated' do describe '.paginated' do
let(:dot_com) { false } let(:dot_com) { false }
@ -143,28 +123,27 @@ RSpec.describe ReleaseHighlight, :clean_gitlab_redis_cache do
end end
end end
describe '.versions' do describe '.most_recent_version_digest' do
subject { described_class.versions } subject { ReleaseHighlight.most_recent_version_digest }
it 'uses process memory cache' do it 'uses process memory cache' do
expect(Gitlab::ProcessMemoryCache.cache_backend).to receive(:fetch).with("release_highlight:versions:#{Gitlab.revision}", { expires_in: described_class::CACHE_DURATION }) expect(Gitlab::ProcessMemoryCache.cache_backend).to receive(:fetch).with("release_highlight:most_recent_version_digest:#{Gitlab.revision}", expires_in: described_class::CACHE_DURATION)
subject subject
end end
it 'returns versions from the file paths' do context 'when recent release items exist' do
expect(subject).to eq(['1.5', '1.2', '1.1']) it 'returns a digest from the release of the first item of the most recent file' do
# this value is coming from fixture data
expect(subject).to eq(Digest::SHA256.hexdigest('01.05'))
end
end end
context 'when there are more than 12 versions' do context 'when recent release items do NOT exist' do
let(:file_paths) do it 'returns nil' do
i = 0 allow(ReleaseHighlight).to receive(:paginated).and_return(nil)
Array.new(20) { "20201225_01_#{i += 1}.yml" }
end
it 'limits to 12 versions' do expect(subject).to be_nil
allow(ReleaseHighlight).to receive(:file_paths).and_return(file_paths)
expect(subject.count).to eq(12)
end end
end end
end end

View File

@ -35,16 +35,5 @@ RSpec.describe WhatsNewController, :clean_gitlab_redis_cache do
expect(response).to have_gitlab_http_status(:not_found) expect(response).to have_gitlab_http_status(:not_found)
end end
end end
context 'with version param' do
it 'returns items without pagination headers' do
allow(ReleaseHighlight).to receive(:for_version).with(version: '42').and_return(highlights)
get whats_new_path(version: 42), xhr: true
expect(response.body).to eq(highlights.items.to_json)
expect(response.headers['X-Next-Page']).to be_nil
end
end
end end
end end

View File

@ -3,15 +3,15 @@
module CycleAnalyticsHelpers module CycleAnalyticsHelpers
include GitHelpers include GitHelpers
def wait_for_stages_to_load def wait_for_stages_to_load(selector = '.js-path-navigation')
expect(page).to have_selector '.js-stage-table' expect(page).to have_selector selector
wait_for_requests wait_for_requests
end end
def select_group(target_group) def select_group(target_group, ready_selector = '.js-path-navigation')
visit group_analytics_cycle_analytics_path(target_group) visit group_analytics_cycle_analytics_path(target_group)
wait_for_stages_to_load wait_for_stages_to_load(ready_selector)
end end
def toggle_dropdown(field) def toggle_dropdown(field)

View File

@ -0,0 +1,41 @@
# frozen_string_literal: true
RSpec.shared_examples 'a cascading setting' do
context 'when setting is enforced by an ancestor group' do
before do
visit group_path
page.within form_group_selector do
find(setting_field_selector).check
find('[data-testid="enforce-for-all-subgroups-checkbox"]').check
end
click_save_button
end
it 'disables setting in subgroups' do
visit subgroup_path
expect(find("#{setting_field_selector}[disabled]")).to be_checked
end
it 'does not show enforcement checkbox in subgroups' do
visit subgroup_path
expect(page).not_to have_selector '[data-testid="enforce-for-all-subgroups-checkbox"]'
end
it 'displays lock icon with popover', :js do
visit subgroup_path
page.within form_group_selector do
find('[data-testid="cascading-settings-lock-icon"]').click
end
page.within '[data-testid="cascading-settings-lock-popover"]' do
expect(page).to have_text 'This setting has been enforced by an owner of Foo bar.'
expect(page).to have_link 'Foo bar', href: setting_path
end
end
end
end