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

View File

@ -254,6 +254,37 @@ export const timeIntervalInWords = (intervalInSeconds) => {
: 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) => {
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 TransferDropdown from '~/groups/transfer_dropdown';
import groupsSelect from '~/groups_select';
import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
import projectSelect from '~/project_select';
import initSearchSettings from '~/search_settings';
@ -26,6 +27,7 @@ document.addEventListener('DOMContentLoaded', () => {
projectSelect();
initSearchSettings();
initCascadingSettingsLockPopovers();
return new TransferDropdown();
});

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,19 @@ const unwrapGroups = (stages) => {
const {
groups: { nodes: groups },
} = 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
v-if="canCherryPick"
data-testid="cherry-pick-link"
data-qa-selector="cherry_pick_button"
@click="showModal($options.openCherryPickModal)"
>
{{ 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 PipelineCharts from './pipeline_charts.vue';
const charts = ['pipelines', 'deployments'];
export default {
components: {
GlTabs,
@ -12,6 +10,8 @@ export default {
PipelineCharts,
DeploymentFrequencyCharts: () =>
import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'),
LeadTimeCharts: () =>
import('ee_component/projects/pipelines/charts/components/lead_time_charts.vue'),
},
inject: {
shouldRenderDeploymentFrequencyCharts: {
@ -24,20 +24,29 @@ export default {
selectedTab: 0,
};
},
computed: {
charts() {
if (this.shouldRenderDeploymentFrequencyCharts) {
return ['pipelines', 'deployments', 'lead-time'];
}
return ['pipelines', 'lead-time'];
},
},
created() {
this.selectTab();
window.addEventListener('popstate', this.selectTab);
},
methods: {
selectTab() {
const [chart] = getParameterValues('chart') || charts;
const tab = charts.indexOf(chart);
const [chart] = getParameterValues('chart') || this.charts;
const tab = this.charts.indexOf(chart);
this.selectedTab = tab >= 0 ? tab : 0;
},
onTabChange(index) {
if (index !== this.selectedTab) {
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 });
}
},
@ -46,14 +55,16 @@ export default {
</script>
<template>
<div>
<gl-tabs v-if="shouldRenderDeploymentFrequencyCharts" :value="selectedTab" @input="onTabChange">
<gl-tabs :value="selectedTab" @input="onTabChange">
<gl-tab :title="__('Pipelines')">
<pipeline-charts />
</gl-tab>
<gl-tab :title="__('Deployments')">
<gl-tab v-if="shouldRenderDeploymentFrequencyCharts" :title="__('Deployments')">
<deployment-frequency-charts />
</gl-tab>
<gl-tab :title="__('Lead Time')">
<lead-time-charts />
</gl-tab>
</gl-tabs>
<pipeline-charts v-else />
</div>
</template>

View File

@ -44,7 +44,8 @@ export default {
:checked="value"
:disabled="isDisabled"
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"
@change="(checked) => $emit('input', checked)"
>

View File

@ -1,13 +1,5 @@
<script>
import {
GlDrawer,
GlInfiniteScroll,
GlResizeObserverDirective,
GlTabs,
GlTab,
GlBadge,
GlLoadingIcon,
} from '@gitlab/ui';
import { GlDrawer, GlInfiniteScroll, GlResizeObserverDirective } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import Tracking from '~/tracking';
import { getDrawerBodyHeight } from '../utils/get_drawer_body_height';
@ -20,37 +12,24 @@ export default {
components: {
GlDrawer,
GlInfiniteScroll,
GlTabs,
GlTab,
SkeletonLoader,
Feature,
GlBadge,
GlLoadingIcon,
},
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [trackingMixin],
props: {
storageKey: {
versionDigest: {
type: String,
required: true,
},
versions: {
type: Array,
required: true,
},
gitlabDotCom: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['open', 'features', 'pageInfo', 'drawerBodyHeight', 'fetching']),
},
mounted() {
this.openDrawer(this.storageKey);
this.openDrawer(this.versionDigest);
this.fetchItems();
const body = document.querySelector('body');
@ -70,16 +49,6 @@ export default {
const height = getDrawerBodyHeight(this.$refs.drawer.$el);
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>
@ -99,7 +68,6 @@ export default {
</template>
<template v-if="features.length">
<gl-infinite-scroll
v-if="gitlabDotCom"
:fetched-items="features.length"
:max-list-height="drawerBodyHeight"
class="gl-p-0"
@ -109,26 +77,6 @@ export default {
<feature v-for="feature in features" :key="feature.title" :feature="feature" />
</template>
</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>
<div v-else class="gl-mt-5">
<skeleton-loader />

View File

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

View File

@ -1,19 +1,20 @@
import axios from '~/lib/utils/axios_utils';
import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils';
import { STORAGE_KEY } from '../utils/notification';
import * as types from './mutation_types';
export default {
closeDrawer({ commit }) {
commit(types.CLOSE_DRAWER);
},
openDrawer({ commit }, storageKey) {
openDrawer({ commit }, versionDigest) {
commit(types.OPEN_DRAWER);
if (storageKey) {
localStorage.setItem(storageKey, JSON.stringify(false));
if (versionDigest) {
localStorage.setItem(STORAGE_KEY, versionDigest);
}
},
fetchItems({ commit, state }, { page, version } = { page: null, version: null }) {
fetchItems({ commit, state }, { page } = { page: null }) {
if (state.fetching) {
return false;
}
@ -24,7 +25,6 @@ export default {
.get('/-/whats_new', {
params: {
page,
version,
},
})
.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) => {
const storageKey = getStorageKey(appEl);
const versionDigest = getVersionDigest(appEl);
const notificationEl = document.querySelector('.header-help');
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');
if (notificationCountEl) {
notificationCountEl.parentElement.removeChild(notificationCountEl);

View File

@ -148,7 +148,19 @@
}
.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 {

View File

@ -5,19 +5,23 @@ module CreatesCommit
include Gitlab::Utils::StrongMemoize
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil)
if user_access(@project).can_push_to_branch?(branch_name_or_ref)
@project_to_commit_into = @project
def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil, target_project: nil)
target_project ||= @project
if user_access(target_project).can_push_to_branch?(branch_name_or_ref)
@project_to_commit_into = target_project
@branch_name ||= @ref
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')
end
@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(
start_project: @project,
start_project: start_project,
start_branch: @start_branch,
branch_name: @branch_name
)
@ -27,7 +31,7 @@ module CreatesCommit
if result[:status] == :success
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|
format.html { redirect_to success_path }
@ -79,9 +83,9 @@ module CreatesCommit
end
end
def final_success_path(success_path)
def final_success_path(success_path, target_project)
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
success_path = success_path.call if success_path.respond_to?(:call)
@ -90,12 +94,12 @@ module CreatesCommit
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def new_merge_request_path
def new_merge_request_path(target_project)
project_new_merge_request_path(
@project_to_commit_into,
merge_request: {
source_project_id: @project_to_commit_into.id,
target_project_id: @project.id,
target_project_id: target_project.id,
source_branch: @branch_name,
target_branch: @start_branch
}

View File

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

View File

@ -114,7 +114,7 @@ class Projects::CommitController < Projects::ApplicationController
@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.",
success_path: -> { successful_change_path }, failure_path: failed_change_path)
success_path: -> { successful_change_path(@project) }, failure_path: failed_change_path)
end
def cherry_pick
@ -122,10 +122,15 @@ class Projects::CommitController < Projects::ApplicationController
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
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
private
@ -138,8 +143,8 @@ class Projects::CommitController < Projects::ApplicationController
params[:create_merge_request].present? || !can?(current_user, :push_code, @project)
end
def successful_change_path
referenced_merge_request_url || project_commits_url(@project, @branch_name)
def successful_change_path(target_project)
referenced_merge_request_url || project_commits_url(target_project, @branch_name)
end
def failed_change_path
@ -218,4 +223,14 @@ class Projects::CommitController < Projects::ApplicationController
@start_branch = params[:start_branch]
@commit_params = { commit: @commit }
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

View File

@ -5,7 +5,7 @@ class WhatsNewController < ApplicationController
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
@ -29,19 +29,11 @@ class WhatsNewController < ApplicationController
def highlights
strong_memoize(:highlights) do
if has_version_param?
ReleaseHighlight.for_version(version: params[:version])
else
ReleaseHighlight.paginated(page: current_page)
end
ReleaseHighlight.paginated(page: current_page)
end
end
def set_pagination_headers
response.set_header('X-Next-Page', highlights.next_page)
end
def has_version_param?
params[:version].present?
end
end

View File

@ -139,7 +139,7 @@ module CommitsHelper
def cherry_pick_projects_data(project)
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,
name: project.full_path,

View File

@ -56,6 +56,33 @@ module NamespacesHelper
namespaces_options(selected, **options)
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
# Many importers create a temporary Group, so use the real

View File

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

View File

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

View File

@ -2,10 +2,9 @@
- page_title _("Broadcast Messages")
%h3.page-title
Broadcast Messages
= _('Broadcast Messages')
%p.light
Broadcast messages are displayed for every user and can be used to notify
users about scheduled maintenance, recent upgrades and more.
= _('Broadcast messages are displayed for every user and can be used to notify users about scheduled maintenance, recent upgrades and more.')
= render 'form'
@ -15,12 +14,12 @@
%table.table.table-responsive
%thead
%tr
%th Status
%th Preview
%th Starts
%th Ends
%th Target Path
%th Type
%th= _('Status')
%th= _('Preview')
%th= _('Starts')
%th= _('Ends')
%th= _(' Target Path')
%th= _(' Type')
%th &nbsp;
%tbody
- @broadcast_messages.each do |message|
@ -38,7 +37,7 @@
%td
= message.broadcast_type.capitalize
%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('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('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'
= paginate @broadcast_messages, theme: 'gitlab'

View File

@ -18,11 +18,11 @@
= nav_link(page: [dashboard_projects_path, root_path]) do
= link_to dashboard_projects_path, class: 'shortcuts-activity', data: {placement: 'right'} do
= _("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
= link_to starred_dashboard_projects_path, data: {placement: 'right'} do
= _("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
= link_to explore_root_path, data: {placement: 'right'} do
= _("Explore projects")

View File

@ -2,15 +2,15 @@
- if @resource.unconfirmed_email.present? || !@resource.created_recently?
#content
= 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
= link_to 'Confirm your email address', confirmation_link
= link_to _('Confirm your email address'), confirmation_link
- else
#content
- if Gitlab.com?
= email_default_heading('Thanks for signing up to GitLab!')
= email_default_heading(_('Thanks for signing up to GitLab!'))
- else
= email_default_heading("Welcome, #{@resource.name}!")
%p To get started, click the link below to confirm your account.
= email_default_heading(_("Welcome, %{name}!") % { name: @resource.name })
%p= _("To get started, click the link below to confirm your account.")
#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
- expanded = expanded_by_default?
= render 'shared/namespaces/cascading_settings/lock_popovers'
%section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded') }
.settings-header

View File

@ -8,27 +8,27 @@
= render 'shared/allow_request_access', form: f
.form-group.gl-mb-3
.form-check
= f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group), class: 'form-check-input'
= f.label :share_with_group_lock, class: 'form-check-label' do
%span.d-block
.gl-form-checkbox.custom-control.custom-checkbox
= 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: 'custom-control-label' do
%span
- 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 }
%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-check
= f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'form-check-input'
= f.label :emails_disabled, class: 'form-check-label' do
%span.d-block= 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.')
.gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :emails_disabled, checked: @group.emails_disabled?, disabled: !can_disable_group_emails?(@group), class: 'custom-control-input'
= f.label :emails_disabled, class: 'custom-control-label' do
%span= s_('GroupSettings|Disable email notifications')
%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-check
= f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'form-check-input'
= f.label :mentions_disabled, class: 'form-check-label' do
%span.d-block= s_('GroupSettings|Disable group mentions')
%span.text-muted= s_('GroupSettings|This setting will prevent group members from being notified if the group is mentioned.')
.gl-form-checkbox.custom-control.custom-checkbox
= f.check_box :mentions_disabled, checked: @group.mentions_disabled?, class: 'custom-control-input'
= f.label :mentions_disabled, class: 'custom-control-label' do
%span= s_('GroupSettings|Disable group mentions')
%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_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)
.form-group.gl-mb-3
.form-check
= 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.label :resource_access_token_creation_allowed, class: 'form-check-label' do
%span.gl-display-block= s_('GroupSettings|Allow project access token creation')
.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: 'custom-control-input', data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' }
= f.label :resource_access_token_creation_allowed, class: 'custom-control-label' do
%span= s_('GroupSettings|Allow project access token creation')
- 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 }
%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('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)
.js-set-status-modal-wrapper{ data: user_status_data }

View File

@ -1,5 +1,5 @@
%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")
%span.js-whats-new-notification-count.whats-new-notification-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. |
| 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
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
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 |
|:-----------|:-------|:---------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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). |
### 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.
@ -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:
@ -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-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
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`
`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
@ -186,17 +244,19 @@ export default {
return {
expanded: false,
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
export default {
name: 'RightSidebar',
mixins: [Tracking.mixin({ label: 'right_sidebar' })],
data() {
return {
@ -206,26 +266,84 @@ export default {
methods: {
toggle() {
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
<template>
<div>
<a class="toggle" @click.prevent="toggle">Toggle</a>
<button data-testid="toggle" @click="toggle">Toggle</button>
<div v-if="expanded">
<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>
</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
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';
const button = document.getElementById('create_from_template_button');
button.addEventListener('click', () => {
Tracking.event('dashboard:projects:index', 'click_button', {
label: 'create_from_template',
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
import { mockTracking, triggerEvent } from 'spec/helpers/tracking_helper';
import Tracking from '~/tracking';
describe('my component', () => {
let trackingSpy;
describe('MyTracking', () => {
let wrapper;
beforeEach(() => {
trackingSpy = mockTracking('_category_', vm.$el, spyOn);
jest.spyOn(Tracking, 'event');
});
const triggerEvent = () => {
// action which should trigger a event
};
const findButton = () => wrapper.find('[data-testid="create_from_template"]');
it('tracks an event when toggled', () => {
expect(trackingSpy).not.toHaveBeenCalled();
it('tracks event', () => {
findButton().trigger('click');
triggerEvent('a.toggle');
expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_edit_button', {
label: 'right_sidebar',
property: 'confidentiality',
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'create_from_template',
property: 'template_preview',
});
});
});
@ -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
### 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 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
The merge request **author** should:
#### The merge request **author** should
- Decide whether a Product Intelligence review is needed.
- 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.
- 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.
- 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):
- Check the Redis slot.
- 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:
- Check the metric `description`.
- Check the metrics `key_path`.

View File

@ -55,29 +55,46 @@ Feature.disable(:security_orchestration_policies_configuration, Project.find(<pr
## Security Policies project
The Security Policies feature is a repository to store policies. All security policies are stored in
the `.gitlab/security-policies` directory as a YAML file with this format:
The Security Policies feature is a repository to store policies. All security policies are stored as
the `.gitlab/security-policies/policy.yml` YAML file with this format:
```yaml
---
type: scan_execution_policy
name: Enforce DAST in every pipeline
description: This policy enforces pipeline configuration to have a job with DAST scan
enabled: true
rules:
- type: pipeline
branch: master
actions:
- scan: dast
scanner_profile: Scanner Profile A
site_profile: Site Profile B
scan_execution_policy:
- name: Enforce DAST in every pipeline
description: This policy enforces pipeline configuration to have a job with DAST scan
enabled: true
rules:
- type: pipeline
branch: master
actions:
- scan: dast
scanner_profile: Scanner Profile A
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
| Field | Type | Possible values | Description |
|-------|------|-----------------|-------------|
| `type` | `string` | `scan_execution_policy` | The policy's type. |
| `name` | `string` | | Name of the policy. |
| `description` (optional) | `string` | | Description of 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:
- 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.
- 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
@ -117,22 +134,37 @@ Here's an example:
```yaml
---
type: scan_execution_policy
name: Enforce DAST in every pipeline
description: This policy enforces pipeline configuration to have a job with DAST scan
enabled: true
rules:
- type: pipeline
branch: release/*
actions:
- scan: dast
scanner_profile: Scanner Profile A
site_profile: Site Profile B
scan_execution_policy:
- name: Enforce DAST in every release pipeline
description: This policy enforces pipeline configuration to have a job with DAST scan for release branches
enabled: true
rules:
- type: pipeline
branch: release/*
actions:
- scan: dast
scanner_profile: Scanner Profile A
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
profile `Site Profile B`. The scan runs for every pipeline executed on branches that match the
`release/*` wildcard (for example, branch name `release/v1.2.1`).
profile `Site Profile B` for every pipeline executed on branches that match the
`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

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/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.

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
[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
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),
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:
@ -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
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
subscription levels, and [groups](project/wiki/index.md#group-wikis) for
[GitLab Premium](https://about.gitlab.com/pricing).

View File

@ -31,9 +31,15 @@ msgstr ""
msgid " Please sign in."
msgstr ""
msgid " Target Path"
msgstr ""
msgid " Try to %{action} this file again."
msgstr ""
msgid " Type"
msgstr ""
msgid " You need to do this before %{grace_period_deadline}."
msgstr ""
@ -5245,6 +5251,9 @@ msgstr ""
msgid "Broadcast Messages"
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"
msgstr ""
@ -5682,6 +5691,21 @@ msgstr ""
msgid "Capacity threshold"
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"
msgstr ""
@ -6378,6 +6402,9 @@ msgstr ""
msgid "Click the button below."
msgstr ""
msgid "Click the link below to confirm your email address."
msgstr ""
msgid "Click to expand it."
msgstr ""
@ -8153,6 +8180,12 @@ msgstr ""
msgid "Confirm"
msgstr ""
msgid "Confirm your account"
msgstr ""
msgid "Confirm your email address"
msgstr ""
msgid "Confirmation email sent to %{email}"
msgstr ""
@ -9666,18 +9699,39 @@ msgstr ""
msgid "DORA4Metrics|Date"
msgstr ""
msgid "DORA4Metrics|Days from merge to deploy"
msgstr ""
msgid "DORA4Metrics|Deployments"
msgstr ""
msgid "DORA4Metrics|Deployments charts"
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"
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."
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"
msgstr ""
@ -11850,6 +11904,9 @@ msgstr ""
msgid "End Time"
msgstr ""
msgid "Ends"
msgstr ""
msgid "Ends at (UTC)"
msgstr ""
@ -15350,7 +15407,16 @@ msgstr ""
msgid "GroupSettings|Prevent sharing a project within %{group} with other groups"
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 ""
msgid "GroupSettings|Select a sub-group as the custom project template source for this group."
@ -25473,6 +25539,9 @@ msgstr ""
msgid "Purchase more storage"
msgstr ""
msgid "PurchaseStep|An error occured in the purchase step. If the problem persists please contact support@gitlab.com."
msgstr ""
msgid "Push"
msgstr ""
@ -29512,6 +29581,9 @@ msgstr ""
msgid "Starting..."
msgstr ""
msgid "Starts"
msgstr ""
msgid "Starts %{startsIn}"
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."
msgstr ""
msgid "Thanks for signing up to GitLab!"
msgstr ""
msgid "Thanks for your purchase!"
msgstr ""
@ -34907,6 +34982,9 @@ msgstr ""
msgid "Welcome to the guided GitLab tour"
msgstr ""
msgid "Welcome, %{name}!"
msgstr ""
msgid "What are you searching for?"
msgstr ""
@ -35908,9 +35986,6 @@ msgstr ""
msgid "Your U2F device was registered!"
msgstr ""
msgid "Your Version"
msgstr ""
msgid "Your WebAuthn device did not send a valid JSON response."
msgstr ""

View File

@ -132,7 +132,7 @@ module QA
all(element_selector_css(name), **kwargs)
end
def check_element(name)
def check_element(name, click_by_js = false)
if find_element(name, visible: false).checked?
QA::Runtime::Logger.debug("#{name} is already checked")
@ -140,7 +140,7 @@ module QA
end
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?
QA::Runtime::Logger.debug(checked ? "#{name} was checked" : "#{name} was not checked")
@ -149,7 +149,7 @@ module QA
end
end
def uncheck_element(name)
def uncheck_element(name, click_by_js = false)
unless find_element(name, visible: false).checked?
QA::Runtime::Logger.debug("#{name} is already unchecked")
@ -157,7 +157,7 @@ module QA
end
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?
QA::Runtime::Logger.debug(unchecked ? "#{name} was unchecked" : "#{name} was not unchecked")
@ -175,9 +175,7 @@ module QA
end
retry_until(sleep_interval: 1) do
radio = find_element(name, visible: false)
# 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
click_checkbox_or_radio(name, click_by_js)
selected = find_element(name, visible: false).checked?
QA::Runtime::Logger.debug(selected ? "#{name} was selected" : "#{name} was not selected")
@ -423,6 +421,14 @@ module QA
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

View File

@ -238,18 +238,12 @@ module QA
end
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
wait_until(reload: false) do
!find_element(:squash_checkbox).disabled?
!find_element(:squash_checkbox, visible: false).disabled?
end
# TODO: Fix workaround for data-qa-selector failure
click_element(:squash_checkbox)
check_element(:squash_checkbox, true)
end
def merge!

View File

@ -11,10 +11,17 @@ module QA
view 'app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue' do
element :options_button
element :cherry_pick_button
element :email_patches
element :plain_diff
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
click_element :options_button
visit_link_in_element :email_patches

View File

@ -13,9 +13,9 @@ module QA
end
view 'app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue' do
element :service_jira_issue_transition_enabled
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_false, ':data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}`"' # rubocop:disable QA/ElementWithPattern
element :service_jira_issue_transition_enabled_checkbox
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_radio, ':data-qa-selector="`service_jira_issue_transition_automatic_${issueTransitionOption.value}_radio`"' # rubocop:disable QA/ElementWithPattern
element :service_jira_issue_transition_id_field
end
@ -55,15 +55,15 @@ module QA
end
def enable_transitions
click_element_coordinates(:service_jira_issue_transition_enabled, visible: false)
check_element(:service_jira_issue_transition_enabled_checkbox, true)
end
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
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
def set_transition_ids(transition_ids)

View File

@ -22,7 +22,7 @@ module QA
Flow::Login.sign_in
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!
Page::MergeRequest::Show.perform do |merge_request|
@ -30,8 +30,11 @@ module QA
merge_request.cherry_pick!
end
Page::MergeRequest::New.perform do |merge_request|
expect(merge_request).to have_content('The merge request has been successfully cherry-picked')
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(feature_mr.file_name)
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
end
def check_element(name)
def check_element(name, click_by_js = nil)
log("checking :#{name}")
super
end
def uncheck_element(name)
def uncheck_element(name, click_by_js = nil)
log("unchecking :#{name}")
super

View File

@ -57,4 +57,16 @@ RSpec.describe RendersCommits do
end.not_to exceed_all_query_limit(control_count)
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

View File

@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe Projects::CommitController do
include ProjectForksHelper
let_it_be(:project) { create(:project, :repository) }
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.')
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
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='app' data-storage-key='storage-key'></div>
<div class='app' data-version-digest='version-digest'></div>
<div class='header-help'>
<div class='js-whats-new-notification-count'></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', () => {
const date = new Date('07/01/2016');

View File

@ -1,10 +1,11 @@
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 JobItem from '~/pipelines/components/graph/job_item.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import { listByLayers } from '~/pipelines/components/parsing_utils';
import {
generateResponse,
mockPipelineResponse,
@ -17,6 +18,7 @@ describe('graph component', () => {
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const findStageNameInJob = () => wrapper.find('[data-testid="stage-name-in-job"]');
const defaultProps = {
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
@ -82,6 +84,10 @@ describe('graph component', () => {
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', () => {
beforeEach(() => {
findStageColumns().at(0).vm.$emit('refreshPipelineGraph');
@ -93,7 +99,7 @@ describe('graph component', () => {
});
describe('when links are present', () => {
beforeEach(async () => {
beforeEach(() => {
createComponent({
mountFn: mount,
stubOverride: { 'job-item': false },
@ -132,4 +138,24 @@ describe('graph component', () => {
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';
describe('job group dropdown component', () => {
@ -65,12 +65,16 @@ describe('job group dropdown component', () => {
let wrapper;
const findButton = () => wrapper.find('button');
const createComponent = ({ mountFn = shallowMount }) => {
wrapper = mountFn(JobGroupDropdown, { propsData: { group } });
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
wrapper = shallowMount(JobGroupDropdown, { propsData: { group } });
createComponent({ mountFn: mount });
});
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', () => {
@ -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 = {
title: 'Fish',
name: 'Fish',
groups: mockGroups,
pipelineId: 159,
};
@ -62,7 +62,7 @@ describe('stage column component', () => {
});
it('should render provided title', () => {
expect(findStageColumnTitle().text()).toBe(defaultProps.title);
expect(findStageColumnTitle().text()).toBe(defaultProps.name);
});
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');
const DeploymentFrequencyChartsStub = { name: 'DeploymentFrequencyCharts', render: () => {} };
const LeadTimeChartsStub = { name: 'LeadTimeCharts', render: () => {} };
describe('ProjectsPipelinesChartsApp', () => {
let wrapper;
@ -25,6 +26,7 @@ describe('ProjectsPipelinesChartsApp', () => {
},
stubs: {
DeploymentFrequencyCharts: DeploymentFrequencyChartsStub,
LeadTimeCharts: LeadTimeChartsStub,
},
},
mountOptions,
@ -44,6 +46,7 @@ describe('ProjectsPipelinesChartsApp', () => {
const findGlTabs = () => wrapper.find(GlTabs);
const findAllGlTab = () => wrapper.findAll(GlTab);
const findGlTabAt = (i) => findAllGlTab().at(i);
const findLeadTimeCharts = () => wrapper.find(LeadTimeChartsStub);
const findDeploymentFrequencyCharts = () => wrapper.find(DeploymentFrequencyChartsStub);
const findPipelineCharts = () => wrapper.find(PipelineCharts);
@ -51,15 +54,23 @@ describe('ProjectsPipelinesChartsApp', () => {
expect(findPipelineCharts().exists()).toBe(true);
});
it('renders the lead time charts', () => {
expect(findLeadTimeCharts().exists()).toBe(true);
});
describe('when shouldRenderDeploymentFrequencyCharts is true', () => {
beforeEach(() => {
createComponent({ provide: { shouldRenderDeploymentFrequencyCharts: true } });
});
it('renders the deployment frequency charts in a tab', () => {
it('renders the expected tabs', () => {
expect(findGlTabs().exists()).toBe(true);
expect(findGlTabAt(0).attributes('title')).toBe('Pipelines');
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);
});
@ -108,6 +119,7 @@ describe('ProjectsPipelinesChartsApp', () => {
describe('when provided with a query param', () => {
it.each`
chart | tab
${'lead-time'} | ${'2'}
${'deployments'} | ${'1'}
${'pipelines'} | ${'0'}
${'fake'} | ${'0'}
@ -160,8 +172,13 @@ describe('ProjectsPipelinesChartsApp', () => {
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', () => {
expect(findGlTabs().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 Vuex from 'vuex';
import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper';
@ -21,12 +21,9 @@ describe('App', () => {
let actions;
let state;
let trackingSpy;
let gitlabDotCom = true;
const buildProps = () => ({
storageKey: 'storage-key',
versions: ['3.11', '3.10'],
gitlabDotCom,
versionDigest: 'version-digest',
});
const buildWrapper = () => {
@ -91,7 +88,7 @@ describe('App', () => {
});
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', {
label: 'namespace_id',
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();
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
.onGet('/-/whats_new', { params: { page: 8, version: 40 } })
.onGet('/-/whats_new', { params: { page: 8 } })
.replyOnce(200, [{ title: 'GitLab Stories' }]);
testAction(
actions.fetchItems,
{ page: 8, version: 40 },
{ page: 8 },
{},
expect.arrayContaining([
{ type: types.ADD_FEATURES, payload: [{ title: 'GitLab Stories' }] },

View File

@ -1,5 +1,5 @@
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', () => {
useLocalStorageSpy();
@ -33,10 +33,23 @@ describe('~/whats_new/utils/notification', () => {
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();
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();
@ -47,9 +60,9 @@ describe('~/whats_new/utils/notification', () => {
});
});
describe('getStorageKey', () => {
describe('getVersionDigest', () => {
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
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: 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
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

View File

@ -3,25 +3,13 @@
require 'spec_helper'
RSpec.describe WhatsNewHelper do
describe '#whats_new_storage_key' do
subject { helper.whats_new_storage_key }
describe '#whats_new_version_digest' do
let(:digest) { 'digest' }
context 'when version exist' do
let(:release_item) { double(:item) }
it 'calls ReleaseHighlight.most_recent_version_digest' do
expect(ReleaseHighlight).to receive(:most_recent_version_digest).and_return(digest)
before do
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 }
expect(helper.whats_new_version_digest).to eq(digest)
end
end
@ -44,14 +32,4 @@ RSpec.describe WhatsNewHelper do
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

View File

@ -13,26 +13,6 @@ RSpec.describe ReleaseHighlight, :clean_gitlab_redis_cache do
ReleaseHighlight.instance_variable_set(:@file_paths, nil)
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
let(:dot_com) { false }
@ -143,28 +123,27 @@ RSpec.describe ReleaseHighlight, :clean_gitlab_redis_cache do
end
end
describe '.versions' do
subject { described_class.versions }
describe '.most_recent_version_digest' do
subject { ReleaseHighlight.most_recent_version_digest }
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
end
it 'returns versions from the file paths' do
expect(subject).to eq(['1.5', '1.2', '1.1'])
context 'when recent release items exist' do
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
context 'when there are more than 12 versions' do
let(:file_paths) do
i = 0
Array.new(20) { "20201225_01_#{i += 1}.yml" }
end
context 'when recent release items do NOT exist' do
it 'returns nil' do
allow(ReleaseHighlight).to receive(:paginated).and_return(nil)
it 'limits to 12 versions' do
allow(ReleaseHighlight).to receive(:file_paths).and_return(file_paths)
expect(subject.count).to eq(12)
expect(subject).to be_nil
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)
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

View File

@ -3,15 +3,15 @@
module CycleAnalyticsHelpers
include GitHelpers
def wait_for_stages_to_load
expect(page).to have_selector '.js-stage-table'
def wait_for_stages_to_load(selector = '.js-path-navigation')
expect(page).to have_selector selector
wait_for_requests
end
def select_group(target_group)
def select_group(target_group, ready_selector = '.js-path-navigation')
visit group_analytics_cycle_analytics_path(target_group)
wait_for_stages_to_load
wait_for_stages_to_load(ready_selector)
end
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