Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-12-02 21:09:44 +00:00
parent ad05e1db03
commit f96f2720d1
81 changed files with 1289 additions and 316 deletions

View File

@ -66,6 +66,13 @@ docs-lint links:
- bundle exec nanoc
# Check the internal links
- bundle exec nanoc check internal_links
# Delete the redirect files, rebuild, and check internal links again, to see if we are linking to redirects.
# Don't delete the documentation/index.md, which is a false positive for the simple grep.
- grep -rl "redirect_to:" /tmp/gitlab-docs/content/ee/ | grep -v "development/documentation/index.md" | xargs rm -f
- bundle exec nanoc
- echo -e "\e[96mThe following test fails when a doc links to a redirect file."
- echo -e "\e[96mMake sure all links point to the correct page."
- bundle exec nanoc check internal_links
# Check the internal anchor links
- bundle exec nanoc check internal_anchors

View File

@ -1 +1 @@
3cbd24e3e2fd09eb526d04f8a419f6d103c440dc
32bbe0bf214e052e107021742e801cffb09b8ca5

View File

@ -10,7 +10,10 @@ import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { isSingleViewStyle } from '~/helpers/diffs_helper';
import { updateHistory } from '~/lib/utils/url_utility';
import eventHub from '../../notes/event_hub';
import notesEventHub from '../../notes/event_hub';
import eventHub from '../event_hub';
import CompareVersions from './compare_versions.vue';
import DiffFile from './diff_file.vue';
import NoChanges from './no_changes.vue';
@ -22,6 +25,7 @@ import MergeConflictWarning from './merge_conflict_warning.vue';
import CollapsedFilesWarning from './collapsed_files_warning.vue';
import { diffsApp } from '../utils/performance';
import { fileByFile } from '../utils/preferences';
import {
TREE_LIST_WIDTH_STORAGE_KEY,
@ -34,6 +38,7 @@ import {
ALERT_OVERFLOW_HIDDEN,
ALERT_MERGE_CONFLICT,
ALERT_COLLAPSED_FILES,
EVT_VIEW_FILE_BY_FILE,
} from '../constants';
export default {
@ -114,7 +119,7 @@ export default {
required: false,
default: false,
},
viewDiffsFileByFile: {
fileByFileUserPreference: {
type: Boolean,
required: false,
default: false,
@ -154,6 +159,7 @@ export default {
'conflictResolutionPath',
'canMerge',
'hasConflicts',
'viewDiffsFileByFile',
]),
...mapGetters('diffs', ['whichCollapsedTypes', 'isParallelView', 'currentDiffIndex']),
...mapGetters(['isNotesFetched', 'getNoteableData']),
@ -254,7 +260,7 @@ export default {
projectPath: this.projectPath,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
viewDiffsFileByFile: this.viewDiffsFileByFile,
viewDiffsFileByFile: fileByFile(this.fileByFileUserPreference),
});
if (this.shouldShow) {
@ -278,8 +284,10 @@ export default {
created() {
this.adjustView();
eventHub.$once('fetchDiffData', this.fetchData);
eventHub.$on('refetchDiffData', this.refetchDiffData);
notesEventHub.$once('fetchDiffData', this.fetchData);
notesEventHub.$on('refetchDiffData', this.refetchDiffData);
eventHub.$on(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
this.CENTERED_LIMITED_CONTAINER_CLASSES = CENTERED_LIMITED_CONTAINER_CLASSES;
this.unwatchDiscussions = this.$watch(
@ -300,8 +308,10 @@ export default {
beforeDestroy() {
diffsApp.deinstrument();
eventHub.$off('fetchDiffData', this.fetchData);
eventHub.$off('refetchDiffData', this.refetchDiffData);
eventHub.$off(EVT_VIEW_FILE_BY_FILE, this.fileByFileListener);
notesEventHub.$off('refetchDiffData', this.refetchDiffData);
notesEventHub.$off('fetchDiffData', this.fetchData);
this.removeEventListeners();
},
methods: {
@ -319,7 +329,11 @@ export default {
'scrollToFile',
'setShowTreeList',
'navigateToDiffFileIndex',
'setFileByFile',
]),
fileByFileListener({ setting } = {}) {
this.setFileByFile({ fileByFile: setting });
},
navigateToDiffFileNumber(number) {
this.navigateToDiffFileIndex(number - 1);
},
@ -371,7 +385,7 @@ export default {
}
if (!this.isNotesFetched) {
eventHub.$emit('fetchNotesData');
notesEventHub.$emit('fetchNotesData');
}
},
setDiscussions() {

View File

@ -1,16 +1,38 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlButtonGroup, GlButton, GlDropdown } from '@gitlab/ui';
import { GlButtonGroup, GlButton, GlDropdown, GlFormCheckbox } from '@gitlab/ui';
import eventHub from '../event_hub';
import { EVT_VIEW_FILE_BY_FILE } from '../constants';
import { SETTINGS_DROPDOWN } from '../i18n';
export default {
i18n: SETTINGS_DROPDOWN,
components: {
GlButtonGroup,
GlButton,
GlDropdown,
GlFormCheckbox,
},
data() {
return {
checked: false,
};
},
computed: {
...mapGetters('diffs', ['isInlineView', 'isParallelView']),
...mapState('diffs', ['renderTreeList', 'showWhitespace']),
...mapState('diffs', ['renderTreeList', 'showWhitespace', 'viewDiffsFileByFile']),
},
watch: {
viewDiffsFileByFile() {
this.checked = this.viewDiffsFileByFile;
},
checked() {
eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: this.checked });
},
},
created() {
this.checked = this.viewDiffsFileByFile;
},
methods: {
...mapActions('diffs', [
@ -19,6 +41,9 @@ export default {
'setRenderTreeList',
'setShowWhitespace',
]),
toggleFileByFile() {
eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting: !this.viewDiffsFileByFile });
},
},
};
</script>
@ -84,5 +109,10 @@ export default {
{{ __('Show whitespace changes') }}
</label>
</div>
<div class="gl-mt-3 gl-px-3">
<gl-form-checkbox v-model="checked" data-testid="file-by-file" class="gl-mb-0">
{{ $options.i18n.fileByFile }}
</gl-form-checkbox>
</div>
</gl-dropdown>
</template>

View File

@ -77,6 +77,11 @@ export const ALERT_COLLAPSED_FILES = 'collapsed';
export const DIFF_FILE_AUTOMATIC_COLLAPSE = 'automatic';
export const DIFF_FILE_MANUAL_COLLAPSE = 'manual';
// Diff view single file mode
export const DIFF_FILE_BY_FILE_COOKIE_NAME = 'fileViewMode';
export const DIFF_VIEW_FILE_BY_FILE = 'single';
export const DIFF_VIEW_ALL_FILES = 'all';
// State machine states
export const STATE_IDLING = 'idle';
export const STATE_LOADING = 'loading';
@ -98,6 +103,7 @@ export const RENAMED_DIFF_TRANSITIONS = {
// MR Diffs known events
export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles';
export const EVT_VIEW_FILE_BY_FILE = 'mr:diffs:preference:fileByFile';
export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart';
export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd';
export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart';

View File

@ -16,3 +16,7 @@ export const DIFF_FILE = {
autoCollapsed: __('Files with large changes are collapsed by default.'),
expand: __('Expand file'),
};
export const SETTINGS_DROPDOWN = {
fileByFile: __('Show one file at a time'),
};

View File

@ -116,7 +116,7 @@ export default function initDiffsApp(store) {
isFluidLayout: this.isFluidLayout,
dismissEndpoint: this.dismissEndpoint,
showSuggestPopover: this.showSuggestPopover,
viewDiffsFileByFile: this.viewDiffsFileByFile,
fileByFileUserPreference: this.viewDiffsFileByFile,
},
});
},

View File

@ -44,6 +44,9 @@ import {
EVT_PERF_MARK_FILE_TREE_START,
EVT_PERF_MARK_FILE_TREE_END,
EVT_PERF_MARK_DIFF_FILES_START,
DIFF_VIEW_FILE_BY_FILE,
DIFF_VIEW_ALL_FILES,
DIFF_FILE_BY_FILE_COOKIE_NAME,
} from '../constants';
import { diffViewerModes } from '~/ide/constants';
import { isCollapsed } from '../diff_file';
@ -57,6 +60,7 @@ export const setBaseConfig = ({ commit }, options) => {
projectPath,
dismissEndpoint,
showSuggestPopover,
viewDiffsFileByFile,
} = options;
commit(types.SET_BASE_CONFIG, {
endpoint,
@ -66,6 +70,7 @@ export const setBaseConfig = ({ commit }, options) => {
projectPath,
dismissEndpoint,
showSuggestPopover,
viewDiffsFileByFile,
});
};
@ -694,3 +699,14 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => {
commit(types.VIEW_DIFF_FILE, fileHash);
};
export const setFileByFile = ({ commit }, { fileByFile }) => {
const fileViewMode = fileByFile ? DIFF_VIEW_FILE_BY_FILE : DIFF_VIEW_ALL_FILES;
commit(types.SET_FILE_BY_FILE, fileByFile);
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, fileViewMode);
historyPushState(
mergeUrlParams({ [DIFF_FILE_BY_FILE_COOKIE_NAME]: fileViewMode }, window.location.href),
);
};

View File

@ -5,6 +5,8 @@ import {
DIFF_VIEW_COOKIE_NAME,
DIFF_WHITESPACE_COOKIE_NAME,
} from '../../constants';
import { fileByFile } from '../../utils/preferences';
import { getDefaultWhitespace } from '../utils';
const viewTypeFromQueryString = getParameterValues('view')[0];
@ -39,6 +41,7 @@ export default () => ({
highlightedRow: null,
renderTreeList: true,
showWhitespace: getDefaultWhitespace(whiteSpaceFromQueryString, whiteSpaceFromCookie),
viewDiffsFileByFile: fileByFile(),
fileFinderVisible: false,
dismissEndpoint: '',
showSuggestPopover: true,

View File

@ -28,6 +28,7 @@ export const SET_HIGHLIGHTED_ROW = 'SET_HIGHLIGHTED_ROW';
export const SET_TREE_DATA = 'SET_TREE_DATA';
export const SET_RENDER_TREE_LIST = 'SET_RENDER_TREE_LIST';
export const SET_SHOW_WHITESPACE = 'SET_SHOW_WHITESPACE';
export const SET_FILE_BY_FILE = 'SET_FILE_BY_FILE';
export const TOGGLE_FILE_FINDER_VISIBLE = 'TOGGLE_FILE_FINDER_VISIBLE';
export const REQUEST_FULL_DIFF = 'REQUEST_FULL_DIFF';

View File

@ -36,6 +36,7 @@ export default {
projectPath,
dismissEndpoint,
showSuggestPopover,
viewDiffsFileByFile,
} = options;
Object.assign(state, {
endpoint,
@ -45,6 +46,7 @@ export default {
projectPath,
dismissEndpoint,
showSuggestPopover,
viewDiffsFileByFile,
});
},
@ -352,4 +354,7 @@ export default {
[types.SET_SHOW_SUGGEST_POPOVER](state) {
state.showSuggestPopover = false;
},
[types.SET_FILE_BY_FILE](state, fileByFile) {
state.viewDiffsFileByFile = fileByFile;
},
};

View File

@ -0,0 +1,22 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import { DIFF_FILE_BY_FILE_COOKIE_NAME, DIFF_VIEW_FILE_BY_FILE } from '../constants';
export function fileByFile(pref = false) {
const search = getParameterValues(DIFF_FILE_BY_FILE_COOKIE_NAME)?.[0];
const cookie = Cookies.get(DIFF_FILE_BY_FILE_COOKIE_NAME);
let viewFileByFile = pref;
// use the cookie first, if it exists
if (cookie) {
viewFileByFile = cookie === DIFF_VIEW_FILE_BY_FILE;
}
// the search parameter of the URL should override, if it exists
if (search) {
viewFileByFile = search === DIFF_VIEW_FILE_BY_FILE;
}
return viewFileByFile;
}

View File

@ -12,10 +12,12 @@ import {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
} from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper';
import { s__, __, n__ } from '~/locale';
import axios from '~/lib/utils/axios_utils';
import { redirectTo } from '~/lib/utils/url_utility';
@ -46,6 +48,7 @@ export default {
GlLink,
GlDropdown,
GlDropdownItem,
GlDropdownSectionHeader,
GlSearchBoxByType,
GlSprintf,
GlLoadingIcon,
@ -59,11 +62,19 @@ export default {
type: String,
required: true,
},
defaultBranch: {
type: String,
required: true,
},
projectId: {
type: String,
required: true,
},
refs: {
branches: {
type: Array,
required: true,
},
tags: {
type: Array,
required: true,
},
@ -94,7 +105,9 @@ export default {
data() {
return {
searchTerm: '',
refValue: this.refParam,
refValue: {
shortName: this.refParam,
},
form: {},
error: null,
warnings: [],
@ -104,9 +117,21 @@ export default {
};
},
computed: {
filteredRefs() {
const lowerCasedSearchTerm = this.searchTerm.toLowerCase();
return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm));
lowerCasedSearchTerm() {
return this.searchTerm.toLowerCase();
},
filteredBranches() {
return this.branches.filter(branch =>
branch.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
);
},
filteredTags() {
return this.tags.filter(tag =>
tag.shortName.toLowerCase().includes(this.lowerCasedSearchTerm),
);
},
hasTags() {
return this.tags.length > 0;
},
overMaxWarningsLimit() {
return this.totalWarnings > this.maxWarnings;
@ -120,14 +145,27 @@ export default {
shouldShowWarning() {
return this.warnings.length > 0 && !this.isWarningDismissed;
},
refShortName() {
return this.refValue.shortName;
},
refFullName() {
return this.refValue.fullName;
},
variables() {
return this.form[this.refValue]?.variables ?? [];
return this.form[this.refFullName]?.variables ?? [];
},
descriptions() {
return this.form[this.refValue]?.descriptions ?? {};
return this.form[this.refFullName]?.descriptions ?? {};
},
},
created() {
// this is needed until we add support for ref type in url query strings
// ensure default branch is called with full ref on load
// https://gitlab.com/gitlab-org/gitlab/-/issues/287815
if (this.refValue.shortName === this.defaultBranch) {
this.refValue.fullName = `refs/heads/${this.refValue.shortName}`;
}
this.setRefSelected(this.refValue);
},
methods: {
@ -170,19 +208,19 @@ export default {
setRefSelected(refValue) {
this.refValue = refValue;
if (!this.form[refValue]) {
this.fetchConfigVariables(refValue)
if (!this.form[this.refFullName]) {
this.fetchConfigVariables(this.refFullName || this.refShortName)
.then(({ descriptions, params }) => {
Vue.set(this.form, refValue, {
Vue.set(this.form, this.refFullName, {
variables: [],
descriptions,
});
// Add default variables from yml
this.setVariableParams(refValue, VARIABLE_TYPE, params);
this.setVariableParams(this.refFullName, VARIABLE_TYPE, params);
})
.catch(() => {
Vue.set(this.form, refValue, {
Vue.set(this.form, this.refFullName, {
variables: [],
descriptions: {},
});
@ -190,20 +228,19 @@ export default {
.finally(() => {
// Add/update variables, e.g. from query string
if (this.variableParams) {
this.setVariableParams(refValue, VARIABLE_TYPE, this.variableParams);
this.setVariableParams(this.refFullName, VARIABLE_TYPE, this.variableParams);
}
if (this.fileParams) {
this.setVariableParams(refValue, FILE_TYPE, this.fileParams);
this.setVariableParams(this.refFullName, FILE_TYPE, this.fileParams);
}
// Adds empty var at the end of the form
this.addEmptyVariable(refValue);
this.addEmptyVariable(this.refFullName);
});
}
},
isSelected(ref) {
return ref === this.refValue;
return ref.fullName === this.refValue.fullName;
},
removeVariable(index) {
this.variables.splice(index, 1);
@ -211,7 +248,6 @@ export default {
canRemove(index) {
return index < this.variables.length - 1;
},
fetchConfigVariables(refValue) {
if (!gon?.features?.newPipelineFormPrefilledVars) {
return Promise.resolve({ params: {}, descriptions: {} });
@ -251,9 +287,11 @@ export default {
return { params, descriptions };
})
.catch(() => {
.catch(error => {
this.isLoading = false;
Sentry.captureException(error);
return { params: {}, descriptions: {} };
});
},
@ -268,7 +306,9 @@ export default {
return axios
.post(this.pipelinesPath, {
ref: this.refValue,
// send shortName as fall back for query params
// https://gitlab.com/gitlab-org/gitlab/-/issues/287815
ref: this.refValue.fullName || this.refShortName,
variables_attributes: filteredVariables,
})
.then(({ data }) => {
@ -326,20 +366,29 @@ export default {
</details>
</gl-alert>
<gl-form-group :label="s__('Pipeline|Run for')">
<gl-dropdown :text="refValue" block>
<gl-search-box-by-type
v-model.trim="searchTerm"
:placeholder="__('Search branches and tags')"
/>
<gl-dropdown :text="refShortName" block>
<gl-search-box-by-type v-model.trim="searchTerm" :placeholder="__('Search refs')" />
<gl-dropdown-section-header>{{ __('Branches') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="(ref, index) in filteredRefs"
:key="index"
v-for="branch in filteredBranches"
:key="branch.fullName"
class="gl-font-monospace"
is-check-item
:is-checked="isSelected(ref)"
@click="setRefSelected(ref)"
:is-checked="isSelected(branch)"
@click="setRefSelected(branch)"
>
{{ ref }}
{{ branch.shortName }}
</gl-dropdown-item>
<gl-dropdown-section-header v-if="hasTags">{{ __('Tags') }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="tag in filteredTags"
:key="tag.fullName"
class="gl-font-monospace"
is-check-item
:is-checked="isSelected(tag)"
@click="setRefSelected(tag)"
>
{{ tag.shortName }}
</gl-dropdown-item>
</gl-dropdown>
@ -372,7 +421,7 @@ export default {
:placeholder="s__('CiVariables|Input variable key')"
:class="$options.formElementClasses"
data-testid="pipeline-form-ci-variable-key"
@change="addEmptyVariable(refValue)"
@change="addEmptyVariable(refFullName)"
/>
<gl-form-input
v-model="variable.value"

View File

@ -1,3 +1,5 @@
export const VARIABLE_TYPE = 'env_var';
export const FILE_TYPE = 'file';
export const CONFIG_VARIABLES_TIMEOUT = 5000;
export const BRANCH_REF_TYPE = 'branch';
export const TAG_REF_TYPE = 'tag';

View File

@ -1,5 +1,6 @@
import Vue from 'vue';
import PipelineNewForm from './components/pipeline_new_form.vue';
import formatRefs from './utils/format_refs';
export default () => {
const el = document.getElementById('js-new-pipeline');
@ -7,17 +8,20 @@ export default () => {
projectId,
pipelinesPath,
configVariablesPath,
defaultBranch,
refParam,
varParam,
fileParam,
refNames,
branchRefs,
tagRefs,
settingsLink,
maxWarnings,
} = el?.dataset;
const variableParams = JSON.parse(varParam);
const fileParams = JSON.parse(fileParam);
const refs = JSON.parse(refNames);
const branches = formatRefs(JSON.parse(branchRefs), 'branch');
const tags = formatRefs(JSON.parse(tagRefs), 'tag');
return new Vue({
el,
@ -27,10 +31,12 @@ export default () => {
projectId,
pipelinesPath,
configVariablesPath,
defaultBranch,
refParam,
variableParams,
fileParams,
refs,
branches,
tags,
settingsLink,
maxWarnings: Number(maxWarnings),
},

View File

@ -0,0 +1,18 @@
import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '../constants';
export default (refs, type) => {
let fullName;
return refs.map(ref => {
if (type === BRANCH_REF_TYPE) {
fullName = `refs/heads/${ref}`;
} else if (type === TAG_REF_TYPE) {
fullName = `refs/tags/${ref}`;
}
return {
shortName: ref,
fullName,
};
});
};

View File

@ -36,7 +36,8 @@ export default {
},
placeholder: {
type: String,
required: true,
required: false,
default: '',
},
description: {
type: String,

View File

@ -1,6 +1,6 @@
<script>
import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
import { isEqual, get } from 'lodash';
import { isEqual, get, isEmpty } from 'lodash';
import expirationPolicyQuery from '../graphql/queries/get_expiration_policy.graphql';
import { FETCH_SETTINGS_ERROR_MESSAGE } from '../../shared/constants';
@ -60,6 +60,9 @@ export default {
return this.isAdmin ? UNAVAILABLE_ADMIN_FEATURE_TEXT : UNAVAILABLE_USER_FEATURE_TEXT;
},
isEdited() {
if (isEmpty(this.containerExpirationPolicy) && isEmpty(this.workingCopy)) {
return false;
}
return !isEqual(this.containerExpirationPolicy, this.workingCopy);
},
},

View File

@ -1,21 +1,43 @@
<script>
import { GlCard, GlButton } from '@gitlab/ui';
import { GlCard, GlButton, GlSprintf } from '@gitlab/ui';
import Tracking from '~/tracking';
import {
UPDATE_SETTINGS_ERROR_MESSAGE,
UPDATE_SETTINGS_SUCCESS_MESSAGE,
} from '../../shared/constants';
import ExpirationPolicyFields from '../../shared/components/expiration_policy_fields.vue';
import { SET_CLEANUP_POLICY_BUTTON, CLEANUP_POLICY_CARD_HEADER } from '../constants';
} from '~/registry/shared/constants';
import {
SET_CLEANUP_POLICY_BUTTON,
KEEP_HEADER_TEXT,
KEEP_INFO_TEXT,
KEEP_N_LABEL,
NAME_REGEX_KEEP_LABEL,
NAME_REGEX_KEEP_DESCRIPTION,
REMOVE_HEADER_TEXT,
REMOVE_INFO_TEXT,
EXPIRATION_SCHEDULE_LABEL,
NAME_REGEX_LABEL,
NAME_REGEX_PLACEHOLDER,
NAME_REGEX_DESCRIPTION,
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
} from '~/registry/settings/constants';
import { formOptionsGenerator } from '~/registry/shared/utils';
import updateContainerExpirationPolicyMutation from '../graphql/mutations/update_container_expiration_policy.graphql';
import { updateContainerExpirationPolicy } from '../graphql/utils/cache_update';
import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql';
import { updateContainerExpirationPolicy } from '~/registry/settings/graphql/utils/cache_update';
import ExpirationDropdown from './expiration_dropdown.vue';
import ExpirationTextarea from './expiration_textarea.vue';
import ExpirationToggle from './expiration_toggle.vue';
import ExpirationRunText from './expiration_run_text.vue';
export default {
components: {
GlCard,
GlButton,
ExpirationPolicyFields,
GlSprintf,
ExpirationDropdown,
ExpirationTextarea,
ExpirationToggle,
ExpirationRunText,
},
mixins: [Tracking.mixin()],
inject: ['projectPath'],
@ -35,22 +57,31 @@ export default {
default: false,
},
},
labelsConfig: {
cols: 3,
align: 'right',
},
formOptions: formOptionsGenerator(),
i18n: {
CLEANUP_POLICY_CARD_HEADER,
KEEP_HEADER_TEXT,
KEEP_INFO_TEXT,
KEEP_N_LABEL,
NAME_REGEX_KEEP_LABEL,
SET_CLEANUP_POLICY_BUTTON,
NAME_REGEX_KEEP_DESCRIPTION,
REMOVE_HEADER_TEXT,
REMOVE_INFO_TEXT,
EXPIRATION_SCHEDULE_LABEL,
NAME_REGEX_LABEL,
NAME_REGEX_PLACEHOLDER,
NAME_REGEX_DESCRIPTION,
CADENCE_LABEL,
EXPIRATION_POLICY_FOOTER_NOTE,
},
data() {
return {
tracking: {
label: 'docker_container_retention_and_expiration_policies',
},
fieldsAreValid: true,
apiErrors: null,
apiErrors: {},
localErrors: {},
mutationLoading: false,
};
},
@ -66,12 +97,18 @@ export default {
showLoadingIcon() {
return this.isLoading || this.mutationLoading;
},
fieldsAreValid() {
return Object.values(this.localErrors).every(error => error);
},
isSubmitButtonDisabled() {
return !this.fieldsAreValid || this.showLoadingIcon;
},
isCancelButtonDisabled() {
return !this.isEdited || this.isLoading || this.mutationLoading;
},
isFieldDisabled() {
return this.showLoadingIcon || !this.value.enabled;
},
mutationVariables() {
return {
projectPath: this.projectPath,
@ -90,7 +127,8 @@ export default {
},
reset() {
this.track('reset_form');
this.apiErrors = null;
this.apiErrors = {};
this.localErrors = {};
this.$emit('reset');
},
setApiErrors(response) {
@ -101,9 +139,15 @@ export default {
return acc;
}, {});
},
setLocalErrors(state, model) {
this.localErrors = {
...this.localErrors,
[model]: state,
};
},
submit() {
this.track('submit_form');
this.apiErrors = null;
this.apiErrors = {};
this.mutationLoading = true;
return this.$apollo
.mutate({
@ -129,11 +173,9 @@ export default {
this.mutationLoading = false;
});
},
onModelChange(changePayload) {
this.$emit('input', changePayload.newValue);
if (this.apiErrors) {
this.apiErrors[changePayload.modified] = undefined;
}
onModelChange(newValue, model) {
this.$emit('input', { ...this.value, [model]: newValue });
this.apiErrors[model] = undefined;
},
},
};
@ -141,42 +183,129 @@ export default {
<template>
<form ref="form-element" @submit.prevent="submit" @reset.prevent="reset">
<gl-card>
<expiration-toggle
:value="prefilledForm.enabled"
:disabled="showLoadingIcon"
class="gl-mb-0!"
data-testid="enable-toggle"
@input="onModelChange($event, 'enabled')"
/>
<div class="gl-display-flex gl-mt-7">
<expiration-dropdown
v-model="prefilledForm.cadence"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.cadence"
:label="$options.i18n.CADENCE_LABEL"
name="cadence"
class="gl-mr-7 gl-mb-0!"
data-testid="cadence-dropdown"
@input="onModelChange($event, 'cadence')"
/>
<expiration-run-text :value="prefilledForm.nextRunAt" class="gl-mb-0!" />
</div>
<gl-card class="gl-mt-7">
<template #header>
{{ $options.i18n.CLEANUP_POLICY_CARD_HEADER }}
{{ $options.i18n.KEEP_HEADER_TEXT }}
</template>
<template #default>
<expiration-policy-fields
:value="prefilledForm"
:form-options="$options.formOptions"
:is-loading="isLoading"
:api-errors="apiErrors"
@validated="fieldsAreValid = true"
@invalidated="fieldsAreValid = false"
@input="onModelChange"
/>
</template>
<template #footer>
<gl-button
ref="cancel-button"
type="reset"
class="gl-mr-3 gl-display-block float-right"
:disabled="isCancelButtonDisabled"
>
{{ __('Cancel') }}
</gl-button>
<gl-button
ref="save-button"
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
variant="success"
category="primary"
class="js-no-auto-disable"
>
{{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
</gl-button>
<div>
<p>
<gl-sprintf :message="$options.i18n.KEEP_INFO_TEXT">
<template #strong="{content}">
<strong>{{ content }}</strong>
</template>
<template #secondStrong="{content}">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<expiration-dropdown
v-model="prefilledForm.keepN"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.keepN"
:label="$options.i18n.KEEP_N_LABEL"
name="keep-n"
data-testid="keep-n-dropdown"
@input="onModelChange($event, 'keepN')"
/>
<expiration-textarea
v-model="prefilledForm.nameRegexKeep"
:error="apiErrors.nameRegexKeep"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_KEEP_LABEL"
:description="$options.i18n.NAME_REGEX_KEEP_DESCRIPTION"
name="keep-regex"
data-testid="keep-regex-textarea"
@input="onModelChange($event, 'nameRegexKeep')"
@validation="setLocalErrors($event, 'nameRegexKeep')"
/>
</div>
</template>
</gl-card>
<gl-card class="gl-mt-7">
<template #header>
{{ $options.i18n.REMOVE_HEADER_TEXT }}
</template>
<template #default>
<div>
<p>
<gl-sprintf :message="$options.i18n.REMOVE_INFO_TEXT">
<template #strong="{content}">
<strong>{{ content }}</strong>
</template>
<template #secondStrong="{content}">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<expiration-dropdown
v-model="prefilledForm.olderThan"
:disabled="isFieldDisabled"
:form-options="$options.formOptions.olderThan"
:label="$options.i18n.EXPIRATION_SCHEDULE_LABEL"
name="older-than"
data-testid="older-than-dropdown"
@input="onModelChange($event, 'olderThan')"
/>
<expiration-textarea
v-model="prefilledForm.nameRegex"
:error="apiErrors.nameRegex"
:disabled="isFieldDisabled"
:label="$options.i18n.NAME_REGEX_LABEL"
:placeholder="$options.i18n.NAME_REGEX_PLACEHOLDER"
:description="$options.i18n.NAME_REGEX_DESCRIPTION"
name="remove-regex"
data-testid="remove-regex-textarea"
@input="onModelChange($event, 'nameRegex')"
@validation="setLocalErrors($event, 'nameRegex')"
/>
</div>
</template>
</gl-card>
<div class="gl-mt-7 gl-display-flex gl-align-items-center">
<gl-button
data-testid="save-button"
type="submit"
:disabled="isSubmitButtonDisabled"
:loading="showLoadingIcon"
variant="success"
category="primary"
class="js-no-auto-disable gl-mr-4"
>
{{ $options.i18n.SET_CLEANUP_POLICY_BUTTON }}
</gl-button>
<gl-button
data-testid="cancel-button"
type="reset"
:disabled="isCancelButtonDisabled"
class="gl-mr-4"
>
{{ __('Cancel') }}
</gl-button>
<span class="gl-font-style-italic gl-text-gray-400">{{
$options.i18n.EXPIRATION_POLICY_FOOTER_NOTE
}}</span>
</div>
</form>
</template>

View File

@ -1,7 +1,6 @@
import { s__, __ } from '~/locale';
export const SET_CLEANUP_POLICY_BUTTON = __('Save');
export const CLEANUP_POLICY_CARD_HEADER = s__('ContainerRegistry|Tag expiration policy');
export const UNAVAILABLE_FEATURE_TITLE = s__(
`ContainerRegistry|Cleanup policy for tags is disabled`,
);
@ -19,34 +18,33 @@ export const TEXT_AREA_INVALID_FEEDBACK = s__(
export const KEEP_HEADER_TEXT = s__('ContainerRegistry|Keep these tags');
export const KEEP_INFO_TEXT = s__(
'ContainerRegistry|Tags that match these rules will always be %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag will always be kept.',
'ContainerRegistry|Tags that match these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag is always kept.',
);
export const KEEP_N_LABEL = s__('ContainerRegistry|Keep the most recent:');
export const NAME_REGEX_KEEP_LABEL = s__('ContainerRegistry|Keep tags matching:');
export const NAME_REGEX_KEEP_PLACEHOLDER = 'production-v.*';
export const NAME_REGEX_KEEP_DESCRIPTION = s__(
'ContainerRegistry|Tags with names matching this regex pattern will be kept. %{linkStart}More information%{linkEnd}',
'ContainerRegistry|Tags with names that match this regex pattern are kept. %{linkStart}More information%{linkEnd}',
);
export const REMOVE_HEADER_TEXT = s__('ContainerRegistry|Remove these tags');
export const REMOVE_INFO_TEXT = s__(
'ContainerRegistry|Tags that match these rules will be %{strongStart}removed%{strongEnd}, unless kept by a rule above.',
'ContainerRegistry|Tags that match these rules are %{strongStart}removed%{strongEnd}, unless a rule above says to keep them.',
);
export const EXPIRATION_SCHEDULE_LABEL = s__('ContainerRegistry|Remove tags older than:');
export const NAME_REGEX_LABEL = s__('ContainerRegistry|Remove tags matching:');
export const NAME_REGEX_PLACEHOLDER = '.*';
export const NAME_REGEX_DESCRIPTION = s__(
'ContainerRegistry|Tags with names matching this regex pattern will be removed. %{linkStart}More information%{linkEnd}',
'ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}',
);
export const ENABLED_TEXT = __('Enabled');
export const DISABLED_TEXT = __('Disabled');
export const ENABLE_TOGGLE_DESCRIPTION = s__(
'ContainerRegistry|%{toggleStatus} - Tags matching the rules defined below will be automatically scheduled for deletion.',
'ContainerRegistry|%{toggleStatus} - Tags that match the rules on this page are automatically scheduled for deletion.',
);
export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup every:');
export const CADENCE_LABEL = s__('ContainerRegistry|Run cleanup:');
export const NEXT_CLEANUP_LABEL = s__('ContainerRegistry|Next cleanup scheduled to run on:');
export const NOT_SCHEDULED_POLICY_TEXT = s__('ContainerRegistry|Not yet scheduled');

View File

@ -21,12 +21,7 @@ export const mapComputedToEvent = (list, root) => {
return result;
};
export const olderThanTranslationGenerator = variable =>
n__(
'%d day until tags are automatically removed',
'%d days until tags are automatically removed',
variable,
);
export const olderThanTranslationGenerator = variable => n__('%d day', '%d days', variable);
export const keepNTranslationGenerator = variable =>
n__('%d tag per image name', '%d tags per image name', variable);

View File

@ -15,7 +15,7 @@ module SnippetsActions
skip_before_action :verify_authenticity_token,
if: -> { action_name == 'show' && js_request? }
track_redis_hll_event :show, name: 'i_snippets_show', feature: :usage_data_i_snippets_show, feature_default_enabled: false
track_redis_hll_event :show, name: 'i_snippets_show', feature: :usage_data_i_snippets_show, feature_default_enabled: true
respond_to :html
end

View File

@ -88,7 +88,7 @@ module HasRepository
group_branch_default_name = group&.default_branch_name if respond_to?(:group)
group_branch_default_name || Gitlab::CurrentSettings.default_branch_name
(group_branch_default_name || Gitlab::CurrentSettings.default_branch_name).presence
end
def reload_default_branch

View File

@ -17,7 +17,7 @@ class CustomEmoji < ApplicationRecord
uniqueness: { scope: [:namespace_id, :name] },
presence: true,
length: { maximum: 36 },
format: { with: /\A([a-z0-9]+[-_]?)+[a-z0-9]+\z/ }
format: { with: /\A[a-z0-9][a-z0-9\-_]*[a-z0-9]\z/ }
private

View File

@ -3,12 +3,6 @@
module Terraform
class State < ApplicationRecord
include UsageStatistics
include IgnorableColumns
# These columns are being removed since geo replication falls to the versioned state
# Tracking in https://gitlab.com/gitlab-org/gitlab/-/issues/258262
ignore_columns %i[verification_failure verification_retry_at verified_at verification_retry_count verification_checksum],
remove_with: '13.7',
remove_after: '2020-12-22'
HEX_REGEXP = %r{\A\h+\z}.freeze
UUID_LENGTH = 32

View File

@ -10,10 +10,12 @@
#js-new-pipeline{ data: { project_id: @project.id,
pipelines_path: project_pipelines_path(@project),
config_variables_path: config_variables_namespace_project_pipelines_path(@project.namespace, @project),
default_branch: @project.default_branch,
ref_param: params[:ref] || @project.default_branch,
var_param: params[:var].to_json,
file_param: params[:file_var].to_json,
ref_names: @project.repository.ref_names.to_json.html_safe,
branch_refs: @project.repository.branch_names.to_json.html_safe,
tag_refs: @project.repository.tag_names.to_json.html_safe,
settings_link: project_settings_ci_cd_path(@project),
max_warnings: ::Gitlab::Ci::Warnings::MAX_LIMIT } }

View File

@ -66,11 +66,11 @@
%section.settings.no-animate#js-registry-policies{ class: ('expanded' if expanded) }
.settings-header
%h4
= _("Cleanup policy for tags")
= _("Clean up image tags")
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p
= _("Save space and find tags in the Container Registry more easily. Enable the cleanup policy to remove stale tags and keep only the ones you need.")
= _("Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want.")
= link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer')
.settings-content
= render 'projects/registry/settings/index'

View File

@ -0,0 +1,5 @@
---
title: Update Design of the Container Registry Cleanup Policy for tags
merge_request: 48243
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Add MAU counter for snippet show action
merge_request: 48477
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fix regular expression backtracking issue in custom emoji name validation
merge_request:
author:
type: security

View File

@ -0,0 +1,5 @@
---
title: Added epic boards and epic board labels tables
merge_request: 48658
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Toggle File-By-File setting from the MR settings dropdown
merge_request: 47726
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Ensure default_branch from settings is not blank
merge_request: 49018
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add uuid column into security_findings table
merge_request: 48968
author: Harrison Brock @harrisonbrock
type: added

View File

@ -0,0 +1,5 @@
---
title: Finish removing unused replication columns from terraform state
merge_request: 48839
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Manually trigger pipelines correctly when branches and tags have the same name. Separate tags and branches in trigger pipeline form.
merge_request: 48142
author:
type: fixed

View File

@ -5,4 +5,4 @@ rollout_issue_url:
milestone: '13.7'
type: development
group: group::editor
default_enabled: false
default_enabled: true

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class AddEpicBoards < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
with_lock_retries do
create_table :boards_epic_boards do |t|
t.boolean :hide_backlog_list, default: false, null: false
t.boolean :hide_closed_list, default: false, null: false
t.references :group, index: true, foreign_key: { to_table: :namespaces, on_delete: :cascade }, null: false
t.timestamps_with_timezone
t.text :name, default: 'Development', null: false
end
end
add_text_limit :boards_epic_boards, :name, 255
end
def down
with_lock_retries do
drop_table :boards_epic_boards
end
end
end

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
class AddEpicBoardLabels < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
with_lock_retries do
create_table :boards_epic_board_labels do |t|
t.references :epic_board, index: true, foreign_key: { to_table: :boards_epic_boards, on_delete: :cascade }, null: false
t.references :label, index: true, foreign_key: { on_delete: :cascade }, null: false
end
end
end
def down
with_lock_retries do
drop_table :boards_epic_board_labels
end
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
#
class AddColumnToSecurityFindings < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :security_findings, :uuid, :uuid
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class AddIndexToSecurityFindingsUuid < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_security_findings_on_uuid'
disable_ddl_transaction!
def up
add_concurrent_index :security_findings, :uuid, name: INDEX_NAME, unique: true
end
def down
remove_concurrent_index_by_name :security_findings, INDEX_NAME
end
end

View File

@ -0,0 +1 @@
a68c609800f5bdb0a77e39f706b410477493e7b7db3af11e4b2a67534df31079

View File

@ -0,0 +1 @@
65935afe9b4ad195aaf31cddb915dcd62b23674e278e93ce7ff9b4ae98e32331

View File

@ -0,0 +1 @@
3b0e685327e2199e0a6721e00d1fa3c9fee3a173ce1cf5ddd99df3349a28fea9

View File

@ -0,0 +1 @@
d0706f4a60ae6f26be206aee80fdeb4a7e5c4c0b99e518140ae3cb8c47ed7a82

View File

@ -9849,6 +9849,41 @@ CREATE TABLE boards (
hide_closed_list boolean DEFAULT false NOT NULL
);
CREATE TABLE boards_epic_board_labels (
id bigint NOT NULL,
epic_board_id bigint NOT NULL,
label_id bigint NOT NULL
);
CREATE SEQUENCE boards_epic_board_labels_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE boards_epic_board_labels_id_seq OWNED BY boards_epic_board_labels.id;
CREATE TABLE boards_epic_boards (
id bigint NOT NULL,
hide_backlog_list boolean DEFAULT false NOT NULL,
hide_closed_list boolean DEFAULT false NOT NULL,
group_id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
name text DEFAULT 'Development'::text NOT NULL,
CONSTRAINT check_bcbbffe601 CHECK ((char_length(name) <= 255))
);
CREATE SEQUENCE boards_epic_boards_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE boards_epic_boards_id_seq OWNED BY boards_epic_boards.id;
CREATE TABLE boards_epic_user_preferences (
id bigint NOT NULL,
board_id bigint NOT NULL,
@ -16202,6 +16237,7 @@ CREATE TABLE security_findings (
project_fingerprint text NOT NULL,
deduplicated boolean DEFAULT false NOT NULL,
"position" integer,
uuid uuid,
CONSTRAINT check_b9508c6df8 CHECK ((char_length(project_fingerprint) <= 40))
);
@ -17878,6 +17914,10 @@ ALTER TABLE ONLY board_user_preferences ALTER COLUMN id SET DEFAULT nextval('boa
ALTER TABLE ONLY boards ALTER COLUMN id SET DEFAULT nextval('boards_id_seq'::regclass);
ALTER TABLE ONLY boards_epic_board_labels ALTER COLUMN id SET DEFAULT nextval('boards_epic_board_labels_id_seq'::regclass);
ALTER TABLE ONLY boards_epic_boards ALTER COLUMN id SET DEFAULT nextval('boards_epic_boards_id_seq'::regclass);
ALTER TABLE ONLY boards_epic_user_preferences ALTER COLUMN id SET DEFAULT nextval('boards_epic_user_preferences_id_seq'::regclass);
ALTER TABLE ONLY broadcast_messages ALTER COLUMN id SET DEFAULT nextval('broadcast_messages_id_seq'::regclass);
@ -18906,6 +18946,12 @@ ALTER TABLE ONLY board_project_recent_visits
ALTER TABLE ONLY board_user_preferences
ADD CONSTRAINT board_user_preferences_pkey PRIMARY KEY (id);
ALTER TABLE ONLY boards_epic_board_labels
ADD CONSTRAINT boards_epic_board_labels_pkey PRIMARY KEY (id);
ALTER TABLE ONLY boards_epic_boards
ADD CONSTRAINT boards_epic_boards_pkey PRIMARY KEY (id);
ALTER TABLE ONLY boards_epic_user_preferences
ADD CONSTRAINT boards_epic_user_preferences_pkey PRIMARY KEY (id);
@ -20525,6 +20571,12 @@ CREATE INDEX index_board_user_preferences_on_user_id ON board_user_preferences U
CREATE UNIQUE INDEX index_board_user_preferences_on_user_id_and_board_id ON board_user_preferences USING btree (user_id, board_id);
CREATE INDEX index_boards_epic_board_labels_on_epic_board_id ON boards_epic_board_labels USING btree (epic_board_id);
CREATE INDEX index_boards_epic_board_labels_on_label_id ON boards_epic_board_labels USING btree (label_id);
CREATE INDEX index_boards_epic_boards_on_group_id ON boards_epic_boards USING btree (group_id);
CREATE INDEX index_boards_epic_user_preferences_on_board_id ON boards_epic_user_preferences USING btree (board_id);
CREATE UNIQUE INDEX index_boards_epic_user_preferences_on_board_user_epic_unique ON boards_epic_user_preferences USING btree (board_id, user_id, epic_id);
@ -22213,6 +22265,8 @@ CREATE INDEX index_security_findings_on_scanner_id ON security_findings USING bt
CREATE INDEX index_security_findings_on_severity ON security_findings USING btree (severity);
CREATE UNIQUE INDEX index_security_findings_on_uuid ON security_findings USING btree (uuid);
CREATE INDEX index_self_managed_prometheus_alert_events_on_environment_id ON self_managed_prometheus_alert_events USING btree (environment_id);
CREATE INDEX index_sent_notifications_on_noteable_type_noteable_id ON sent_notifications USING btree (noteable_id) WHERE ((noteable_type)::text = 'Issue'::text);
@ -23844,6 +23898,9 @@ ALTER TABLE ONLY group_group_links
ALTER TABLE ONLY geo_repository_updated_events
ADD CONSTRAINT fk_rails_2b70854c08 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY boards_epic_board_labels
ADD CONSTRAINT fk_rails_2bedeb8799 FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE;
ALTER TABLE ONLY protected_branch_unprotect_access_levels
ADD CONSTRAINT fk_rails_2d2aba21ef FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
@ -24249,6 +24306,9 @@ ALTER TABLE ONLY slack_integrations
ALTER TABLE ONLY custom_emoji
ADD CONSTRAINT fk_rails_745925b412 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY boards_epic_board_labels
ADD CONSTRAINT fk_rails_7471128a8e FOREIGN KEY (epic_board_id) REFERENCES boards_epic_boards(id) ON DELETE CASCADE;
ALTER TABLE ONLY dast_site_profiles
ADD CONSTRAINT fk_rails_747dc64abc FOREIGN KEY (dast_site_id) REFERENCES dast_sites(id) ON DELETE CASCADE;
@ -24333,6 +24393,9 @@ ALTER TABLE ONLY clusters_applications_crossplane
ALTER TABLE ONLY packages_package_file_build_infos
ADD CONSTRAINT fk_rails_871ca3ae21 FOREIGN KEY (package_file_id) REFERENCES packages_package_files(id) ON DELETE CASCADE;
ALTER TABLE ONLY boards_epic_boards
ADD CONSTRAINT fk_rails_874c573878 FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
ALTER TABLE ONLY ci_runner_namespaces
ADD CONSTRAINT fk_rails_8767676b7a FOREIGN KEY (runner_id) REFERENCES ci_runners(id) ON DELETE CASCADE;

View File

@ -115,7 +115,7 @@ The following documentation relates to the DevOps **Plan** stage:
| Plan topics | Description |
|:-----------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------|
| [Burndown charts](user/project/milestones/burndown_charts.md) **(STARTER)** | Watch your project's progress throughout a specific milestone. |
| [Burndown charts](user/project/milestones/burndown_and_burnup_charts.md) **(STARTER)** | Watch your project's progress throughout a specific milestone. |
| [Discussions](user/discussions/index.md) | Threads, comments, and resolvable threads in issues, commits, and merge requests. |
| [Due dates](user/project/issues/due_dates.md) | Keep track of issue deadlines. |
| [Epics](user/group/epics/index.md) **(ULTIMATE)** | Tracking groups of issues that share a theme. |

View File

@ -65,7 +65,7 @@ GET /users?active=true
GET /users?blocked=true
```
GitLab supports bot users such as the [alert bot](../operations/incident_management/generic_alerts.md)
GitLab supports bot users such as the [alert bot](../operations/incident_management/alert_integrations.md)
or the [support bot](../user/project/service_desk.md#support-bot-user).
To exclude these users from the users' list, you can use the parameter `exclude_internal=true`
([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/241144) in GitLab 13.4).

View File

@ -259,7 +259,7 @@ To access environment variables, use the syntax for your runner's [shell](https:
|----------------------|------------------------------------------|
| bash/sh | `$variable` |
| PowerShell | `$env:variable` (primary) or `$variable` |
| Windows Batch | `%variable%` |
| Windows Batch | `%variable%`, or `!variable!` for [delayed expansion](https://ss64.com/nt/delayedexpansion.html), which can be used for variables that contain white spaces or newlines. |
### Bash

View File

@ -98,7 +98,7 @@ from:
- [Shell commands](shell_commands.md) in the GitLab codebase
- [`Gemfile` guidelines](gemfile.md)
- [Pry debugging](pry_debugging.md)
- [Sidekiq debugging](sidekiq_debugging.md)
- [Sidekiq debugging](../administration/troubleshooting/sidekiq.md)
- [Accessing session data](session.md)
- [Gotchas](gotchas.md) to avoid
- [Avoid modules with instance variables](module_with_instance_variables.md) if possible

View File

@ -39,6 +39,9 @@ go get github.com/Arkweid/lefthook
## Or with Rubygems
gem install lefthook
### You may need to run the following if you're using rbenv
rbenv rehash
# 3. Install the Git hooks
lefthook install -f
```

View File

@ -11,7 +11,7 @@ blocks of Ruby code. Method instrumentation is the primary form of
instrumentation with block-based instrumentation only being used when we want to
drill down to specific regions of code within a method.
Please refer to [Product Analytics](product_analytics/index.md) if you are tracking product usage patterns.
Please refer to [Product Analytics](https://about.gitlab.com/handbook/product/product-analytics-guide/) if you are tracking product usage patterns.
## Instrumenting Methods

View File

@ -50,6 +50,22 @@ bundle exec guard
When using spring and guard together, use `SPRING=1 bundle exec guard` instead to make use of spring.
### Ruby warnings
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47767) in GitLab 13.7.
We've enabled [deprecation warnings](https://ruby-doc.org/core-2.7.2/Warning.html)
by default when running specs. Making these warnings more visible to developers
helps upgrading to newer Ruby versions.
You can silence deprecation warnings by setting the environment variable
`SILENCE_DEPRECATIONS`, for example:
```shell
# silence all deprecation warnings
SILENCE_DEPRECATIONS=1 bin/rspec spec/models/project_spec.rb
```
### Test speed
GitLab has a massive test suite that, without [parallelization](ci.md#test-suite-parallelization-on-the-ci), can take hours

View File

@ -181,7 +181,7 @@ the GitLab alerts list is scheduled for deprecation following the 13.7 release o
You can monitor alerts using a GitLab integration with [Opsgenie](https://www.atlassian.com/software/opsgenie).
If you enable the Opsgenie integration, you can't have other GitLab alert
services, such as [Generic Alerts](generic_alerts.md) or Prometheus alerts,
services, such as [Generic Alerts](alert_integrations.md) or Prometheus alerts,
active at the same time.
To enable Opsgenie integration:

View File

@ -80,7 +80,7 @@ Prometheus. The value of this should match the name of your environment in GitLa
In GitLab versions 13.1 and greater, you can configure your manually configured
Prometheus server to use the
[Generic alerts integration](../incident_management/generic_alerts.md).
[Generic alerts integration](../incident_management/alert_integrations.md).
## Trigger actions from alerts **(ULTIMATE)**

View File

@ -60,7 +60,7 @@ With GitLab Enterprise Edition, you can also:
- [Multiple Assignees for Issues](project/issues/multiple_assignees_for_issues.md). **(STARTER)**
- [Multiple Issue Boards](project/issue_board.md#multiple-issue-boards).
- Create formal relationships between issues with [Related Issues](project/issues/related_issues.md).
- Use [Burndown Charts](project/milestones/burndown_charts.md) to track progress during a sprint or while working on a new version of their software.
- Use [Burndown Charts](project/milestones/burndown_and_burnup_charts.md) to track progress during a sprint or while working on a new version of their software.
- Leverage [Elasticsearch](../integration/elasticsearch.md) with [Advanced Search](search/advanced_global_search.md) and [Advanced Search Syntax](search/advanced_search_syntax.md) for faster, more advanced code search across your entire GitLab instance.
- [Authenticate users with Kerberos](../integration/kerberos.md).
- [Mirror a repository](project/repository/repository_mirroring.md) from elsewhere on your local server.

View File

@ -513,24 +513,24 @@ You can create a cleanup policy in [the API](#use-the-cleanup-policy-api) or the
To create a cleanup policy in the UI:
1. For your project, go to **Settings > CI/CD**.
1. Expand the **Cleanup policy for tags** section.
1. Expand the **Clean up image tags** section.
1. Complete the fields.
| Field | Description |
|---------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------|
| **Cleanup policy** | Turn the policy on or off. |
| **Expiration interval** | How long tags are exempt from being deleted. |
| **Expiration schedule** | How often the policy should run. |
| **Number of tags to retain** | How many tags to _always_ keep for each image. |
| **Tags with names matching this regex pattern expire:** | The regex pattern that determines which tags to remove. This value cannot be blank. For all tags, use `.*`. See other [regex pattern examples](#regex-pattern-examples). |
| **Tags with names matching this regex pattern are preserved:** | The regex pattern that determines which tags to preserve. The `latest` tag is always preserved. For all tags, use `.*`. See other [regex pattern examples](#regex-pattern-examples). |
| **Toggle** | Turn the policy on or off. |
| **Run cleanup** | How often the policy should run. |
| **Keep the most recent** | How many tags to _always_ keep for each image. |
| **Keep tags matching** | The regex pattern that determines which tags to preserve. The `latest` tag is always preserved. For all tags, use `.*`. See other [regex pattern examples](#regex-pattern-examples). |
| **Remove tags older than** | Remove only tags older than X days. |
| **Remove tags matching** | The regex pattern that determines which tags to remove. This value cannot be blank. For all tags, use `.*`. See other [regex pattern examples](#regex-pattern-examples). |
1. Click **Set cleanup policy**.
1. Click **Save**.
Depending on the interval you chose, the policy is scheduled to run.
NOTE: **Note:**
If you edit the policy and click **Set cleanup policy** again, the interval is reset.
If you edit the policy and click **Save** again, the interval is reset.
### Regex pattern examples

View File

@ -39,7 +39,7 @@ Click on the service links to see further configuration instructions and details
| [Emails on push](emails_on_push.md) | Email the commits and diff of each push to a list of recipients | No |
| External Wiki | Replaces the link to the internal wiki with a link to an external wiki | No |
| Flowdock | Flowdock is a collaboration web app for technical teams | No |
| [Generic alerts](../../../operations/incident_management/generic_alerts.md) **(ULTIMATE)** | Receive alerts on GitLab from any source | No |
| [Generic alerts](../../../operations/incident_management/alert_integrations.md) **(ULTIMATE)** | Receive alerts on GitLab from any source | No |
| [GitHub](github.md) **(PREMIUM)** | Sends pipeline notifications to GitHub | No |
| [Hangouts Chat](hangouts_chat.md) | Receive events notifications in Google Hangouts Chat | No |
| [HipChat](hipchat.md) | Private group chat and IM | No |

View File

@ -92,6 +92,17 @@ From there, when reviewing merge requests' **Changes** tab, you will see only on
![File-by-file diff navigation](img/file_by_file_v13_2.png)
From [GitLab 13.7](https://gitlab.com/gitlab-org/gitlab/-/issues/233898) onwards, if you want to change
this behavior, you can do so from your **User preferences** (as explained above) or directly in a
merge request:
1. Go to the merge request's **Changes** tab.
1. Click the cog icon (**{settings}**) to reveal the merge request's settings dropdown.
1. Select or unselect the checkbox **Show one file at a time** to change the setting accordingly.
This change overrides the choice you made in your user preferences and persists until you clear your
browser's cookies or change this behavior again.
#### Enable or disable file-by-file diff navigation **(CORE ONLY)**
File-by-file diff navigation is under development but ready for production use. It is

View File

@ -1,5 +1,5 @@
---
redirect_to: './burndown_and_burnup_charts.md'
redirect_to: 'burndown_and_burnup_charts.md'
---
This document was moved to [another location](burndown_and_burnup_charts.md).

View File

@ -148,13 +148,13 @@ There are also tabs below these that show the following:
### Project Burndown Charts **(STARTER)**
For project milestones in [GitLab Starter](https://about.gitlab.com/pricing/), a [burndown chart](burndown_charts.md) is in the milestone view, showing the progress of completing a milestone.
For project milestones in [GitLab Starter](https://about.gitlab.com/pricing/), a [burndown chart](burndown_and_burnup_charts.md) is in the milestone view, showing the progress of completing a milestone.
![burndown chart](img/burndown_chart_v13_6.png)
### Group Burndown Charts **(STARTER)**
For group milestones in [GitLab Starter](https://about.gitlab.com/pricing/), a [burndown chart](burndown_charts.md) is in the milestone view, showing the progress of completing a milestone.
For group milestones in [GitLab Starter](https://about.gitlab.com/pricing/), a [burndown chart](burndown_and_burnup_charts.md) is in the milestone view, showing the progress of completing a milestone.
### Milestone sidebar

View File

@ -80,6 +80,9 @@ module Gitlab
},
jobs_empty_state: {
tracking_category: 'Growth::Activation::Experiment::JobsEmptyState'
},
remove_known_trial_form_fields: {
tracking_category: 'Growth::Conversion::Experiment::RemoveKnownTrialFormFields'
}
}.freeze

View File

@ -170,11 +170,6 @@ msgid_plural "%d days"
msgstr[0] ""
msgstr[1] ""
msgid "%d day until tags are automatically removed"
msgid_plural "%d days until tags are automatically removed"
msgstr[0] ""
msgstr[1] ""
msgid "%d error"
msgid_plural "%d errors"
msgstr[0] ""
@ -5571,7 +5566,7 @@ msgstr ""
msgid "ClassificationLabelUnavailable|is unavailable: %{reason}"
msgstr ""
msgid "Cleanup policy for tags"
msgid "Clean up image tags"
msgstr ""
msgid "Cleanup policy maximum processing time (seconds)"
@ -7266,7 +7261,7 @@ msgstr ""
msgid "ContainerRegistry|%{toggleStatus} - Tags matching the patterns defined below will be scheduled for deletion"
msgstr ""
msgid "ContainerRegistry|%{toggleStatus} - Tags matching the rules defined below will be automatically scheduled for deletion."
msgid "ContainerRegistry|%{toggleStatus} - Tags that match the rules on this page are automatically scheduled for deletion."
msgstr ""
msgid "ContainerRegistry|Build an image"
@ -7403,7 +7398,7 @@ msgstr ""
msgid "ContainerRegistry|Remove these tags"
msgstr ""
msgid "ContainerRegistry|Run cleanup every:"
msgid "ContainerRegistry|Run cleanup:"
msgstr ""
msgid "ContainerRegistry|Some tags were not deleted"
@ -7436,19 +7431,16 @@ msgstr ""
msgid "ContainerRegistry|Sorry, your filter produced no results."
msgstr ""
msgid "ContainerRegistry|Tag expiration policy"
msgstr ""
msgid "ContainerRegistry|Tag successfully marked for deletion."
msgstr ""
msgid "ContainerRegistry|Tags successfully marked for deletion."
msgstr ""
msgid "ContainerRegistry|Tags that match these rules will always be %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag will always be kept."
msgid "ContainerRegistry|Tags that match these rules are %{strongStart}kept%{strongEnd}, even if they match a removal rule below. The %{secondStrongStart}latest%{secondStrongEnd} tag is always kept."
msgstr ""
msgid "ContainerRegistry|Tags that match these rules will be %{strongStart}removed%{strongEnd}, unless kept by a rule above."
msgid "ContainerRegistry|Tags that match these rules are %{strongStart}removed%{strongEnd}, unless a rule above says to keep them."
msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}be preserved:%{italicEnd}"
@ -7457,10 +7449,10 @@ msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will %{italicStart}expire:%{italicEnd}"
msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will be kept. %{linkStart}More information%{linkEnd}"
msgid "ContainerRegistry|Tags with names that match this regex pattern are kept. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "ContainerRegistry|Tags with names matching this regex pattern will be removed. %{linkStart}More information%{linkEnd}"
msgid "ContainerRegistry|Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}"
msgstr ""
msgid "ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}"
@ -23757,7 +23749,7 @@ msgstr ""
msgid "Save pipeline schedule"
msgstr ""
msgid "Save space and find tags in the Container Registry more easily. Enable the cleanup policy to remove stale tags and keep only the ones you need."
msgid "Save space and find images in the Container Registry. Remove unneeded tags and keep only the ones you want."
msgstr ""
msgid "Saved scan settings and target site settings which are reusable."
@ -23919,6 +23911,9 @@ msgstr ""
msgid "Search projects..."
msgstr ""
msgid "Search refs"
msgstr ""
msgid "Search requirements"
msgstr ""
@ -25104,6 +25099,9 @@ msgstr ""
msgid "Show me the basics"
msgstr ""
msgid "Show one file at a time"
msgstr ""
msgid "Show only direct members"
msgstr ""

View File

@ -26,20 +26,20 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
subject
settings_block = find('#js-registry-policies')
expect(settings_block).to have_text 'Cleanup policy for tags'
expect(settings_block).to have_text 'Clean up image tags'
end
it 'saves cleanup policy submit the form' do
subject
within '#js-registry-policies' do
within '.gl-card-body' do
select('7 days until tags are automatically removed', from: 'Expiration interval:')
select('Every day', from: 'Expiration schedule:')
select('50 tags per image name', from: 'Number of tags to retain:')
fill_in('Tags with names matching this regex pattern will expire:', with: '.*-production')
end
submit_button = find('.gl-card-footer .btn.btn-success')
select('Every day', from: 'Run cleanup')
select('50 tags per image name', from: 'Keep the most recent:')
fill_in('Keep tags matching:', with: 'stable')
select('7 days', from: 'Remove tags older than:')
fill_in('Remove tags matching:', with: '.*-production')
submit_button = find('.btn.btn-success')
expect(submit_button).not_to be_disabled
submit_button.click
end
@ -51,10 +51,9 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
subject
within '#js-registry-policies' do
within '.gl-card-body' do
fill_in('Tags with names matching this regex pattern will expire:', with: '*-production')
end
submit_button = find('.gl-card-footer .btn.btn-success')
fill_in('Remove tags matching:', with: '*-production')
submit_button = find('.btn.btn-success')
expect(submit_button).not_to be_disabled
submit_button.click
end
@ -85,7 +84,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p
within '#js-registry-policies' do
case result
when :available_section
expect(find('.gl-card-header')).to have_content('Tag expiration policy')
expect(find('[data-testid="enable-toggle"]')).to have_content('Tags that match the rules on this page are automatically scheduled for deletion.')
when :disabled_message
expect(find('.gl-alert-title')).to have_content('Cleanup policy for tags is disabled')
end

View File

@ -17,10 +17,14 @@ import axios from '~/lib/utils/axios_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import diffsMockData from '../mock_data/merge_request_diffs';
import { EVT_VIEW_FILE_BY_FILE } from '~/diffs/constants';
import eventHub from '~/diffs/event_hub';
const mergeRequestDiff = { version_index: 1 };
const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`;
const COMMIT_URL = '[BASE URL]/OLD';
const UPDATED_COMMIT_URL = '[BASE URL]/NEW';
const COMMIT_URL = `${TEST_HOST}/COMMIT/OLD`;
const UPDATED_COMMIT_URL = `${TEST_HOST}/COMMIT/NEW`;
function getCollapsedFilesWarning(wrapper) {
return wrapper.find(CollapsedFilesWarning);
@ -61,7 +65,7 @@ describe('diffs/components/app', () => {
changesEmptyStateIllustration: '',
dismissEndpoint: '',
showSuggestPopover: true,
viewDiffsFileByFile: false,
fileByFileUserPreference: false,
...props,
},
provide,
@ -700,12 +704,14 @@ describe('diffs/components/app', () => {
});
describe('file-by-file', () => {
it('renders a single diff', () => {
createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
it('renders a single diff', async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
state.diffs.diffFiles.push({ file_hash: '123' });
state.diffs.diffFiles.push({ file_hash: '312' });
});
await wrapper.vm.$nextTick();
expect(wrapper.findAll(DiffFile).length).toBe(1);
});
@ -713,31 +719,37 @@ describe('diffs/components/app', () => {
const fileByFileNav = () => wrapper.find('[data-testid="file-by-file-navigation"]');
const paginator = () => fileByFileNav().find(GlPagination);
it('sets previous button as disabled', () => {
createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
it('sets previous button as disabled', async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
});
await wrapper.vm.$nextTick();
expect(paginator().attributes('prevpage')).toBe(undefined);
expect(paginator().attributes('nextpage')).toBe('2');
});
it('sets next button as disabled', () => {
createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
it('sets next button as disabled', async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
state.diffs.currentDiffFileId = '312';
});
await wrapper.vm.$nextTick();
expect(paginator().attributes('prevpage')).toBe('1');
expect(paginator().attributes('nextpage')).toBe(undefined);
});
it("doesn't display when there's fewer than 2 files", () => {
createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
it("doesn't display when there's fewer than 2 files", async () => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
state.diffs.diffFiles.push({ file_hash: '123' });
state.diffs.currentDiffFileId = '123';
});
await wrapper.vm.$nextTick();
expect(fileByFileNav().exists()).toBe(false);
});
@ -748,11 +760,13 @@ describe('diffs/components/app', () => {
`(
'it calls navigateToDiffFileIndex with $index when $link is clicked',
async ({ currentDiffFileId, targetFile }) => {
createComponent({ viewDiffsFileByFile: true }, ({ state }) => {
createComponent({ fileByFileUserPreference: true }, ({ state }) => {
state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' });
state.diffs.currentDiffFileId = currentDiffFileId;
});
await wrapper.vm.$nextTick();
jest.spyOn(wrapper.vm, 'navigateToDiffFileIndex');
paginator().vm.$emit('input', targetFile);
@ -763,5 +777,24 @@ describe('diffs/components/app', () => {
},
);
});
describe('control via event stream', () => {
it.each`
setting
${true}
${false}
`(
'triggers the action with the new fileByFile setting - $setting - when the event with that setting is received',
async ({ setting }) => {
createComponent();
await wrapper.vm.$nextTick();
eventHub.$emit(EVT_VIEW_FILE_BY_FILE, { setting });
await wrapper.vm.$nextTick();
expect(store.state.diffs.viewDiffsFileByFile).toBe(setting);
},
);
});
});
});

View File

@ -2,12 +2,18 @@ import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import diffModule from '~/diffs/store/modules';
import SettingsDropdown from '~/diffs/components/settings_dropdown.vue';
import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constants';
import {
EVT_VIEW_FILE_BY_FILE,
PARALLEL_DIFF_VIEW_TYPE,
INLINE_DIFF_VIEW_TYPE,
} from '~/diffs/constants';
import eventHub from '~/diffs/event_hub';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Diff settings dropdown component', () => {
let wrapper;
let vm;
let actions;
@ -25,10 +31,15 @@ describe('Diff settings dropdown component', () => {
extendStore(store);
vm = mount(SettingsDropdown, {
wrapper = mount(SettingsDropdown, {
localVue,
store,
});
vm = wrapper.vm;
}
function getFileByFileCheckbox(vueWrapper) {
return vueWrapper.find('[data-testid="file-by-file"]');
}
beforeEach(() => {
@ -41,14 +52,14 @@ describe('Diff settings dropdown component', () => {
});
afterEach(() => {
vm.destroy();
wrapper.destroy();
});
describe('tree view buttons', () => {
it('list view button dispatches setRenderTreeList with false', () => {
createComponent();
vm.find('.js-list-view').trigger('click');
wrapper.find('.js-list-view').trigger('click');
expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false);
});
@ -56,7 +67,7 @@ describe('Diff settings dropdown component', () => {
it('tree view button dispatches setRenderTreeList with true', () => {
createComponent();
vm.find('.js-tree-view').trigger('click');
wrapper.find('.js-tree-view').trigger('click');
expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true);
});
@ -68,8 +79,8 @@ describe('Diff settings dropdown component', () => {
});
});
expect(vm.find('.js-list-view').classes('selected')).toBe(true);
expect(vm.find('.js-tree-view').classes('selected')).toBe(false);
expect(wrapper.find('.js-list-view').classes('selected')).toBe(true);
expect(wrapper.find('.js-tree-view').classes('selected')).toBe(false);
});
it('sets tree button as selected when renderTreeList is true', () => {
@ -79,8 +90,8 @@ describe('Diff settings dropdown component', () => {
});
});
expect(vm.find('.js-list-view').classes('selected')).toBe(false);
expect(vm.find('.js-tree-view').classes('selected')).toBe(true);
expect(wrapper.find('.js-list-view').classes('selected')).toBe(false);
expect(wrapper.find('.js-tree-view').classes('selected')).toBe(true);
});
});
@ -92,8 +103,8 @@ describe('Diff settings dropdown component', () => {
});
});
expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(true);
expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(false);
expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(true);
expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(false);
});
it('sets parallel button as selected', () => {
@ -103,14 +114,14 @@ describe('Diff settings dropdown component', () => {
});
});
expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(false);
expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(true);
expect(wrapper.find('.js-inline-diff-button').classes('selected')).toBe(false);
expect(wrapper.find('.js-parallel-diff-button').classes('selected')).toBe(true);
});
it('calls setInlineDiffViewType when clicking inline button', () => {
createComponent();
vm.find('.js-inline-diff-button').trigger('click');
wrapper.find('.js-inline-diff-button').trigger('click');
expect(actions.setInlineDiffViewType).toHaveBeenCalled();
});
@ -118,7 +129,7 @@ describe('Diff settings dropdown component', () => {
it('calls setParallelDiffViewType when clicking parallel button', () => {
createComponent();
vm.find('.js-parallel-diff-button').trigger('click');
wrapper.find('.js-parallel-diff-button').trigger('click');
expect(actions.setParallelDiffViewType).toHaveBeenCalled();
});
@ -132,7 +143,7 @@ describe('Diff settings dropdown component', () => {
});
});
expect(vm.find('#show-whitespace').element.checked).toBe(false);
expect(wrapper.find('#show-whitespace').element.checked).toBe(false);
});
it('sets as checked when showWhitespace is true', () => {
@ -142,13 +153,13 @@ describe('Diff settings dropdown component', () => {
});
});
expect(vm.find('#show-whitespace').element.checked).toBe(true);
expect(wrapper.find('#show-whitespace').element.checked).toBe(true);
});
it('calls setShowWhitespace on change', () => {
createComponent();
const checkbox = vm.find('#show-whitespace');
const checkbox = wrapper.find('#show-whitespace');
checkbox.element.checked = true;
checkbox.trigger('change');
@ -159,4 +170,52 @@ describe('Diff settings dropdown component', () => {
});
});
});
describe('file-by-file toggle', () => {
beforeEach(() => {
jest.spyOn(eventHub, '$emit');
});
it.each`
fileByFile | checked
${true} | ${true}
${false} | ${false}
`(
'sets { checked: $checked } if the fileByFile setting is $fileByFile',
async ({ fileByFile, checked }) => {
createComponent(store => {
Object.assign(store.state.diffs, {
viewDiffsFileByFile: fileByFile,
});
});
await vm.$nextTick();
expect(vm.checked).toBe(checked);
},
);
it.each`
start | emit
${true} | ${false}
${false} | ${true}
`(
'when the file by file setting starts as $start, toggling the checkbox should emit an event set to $emit',
async ({ start, emit }) => {
createComponent(store => {
Object.assign(store.state.diffs, {
viewDiffsFileByFile: start,
});
});
await vm.$nextTick();
getFileByFileCheckbox(wrapper).trigger('click');
await vm.$nextTick();
expect(eventHub.$emit).toHaveBeenCalledWith(EVT_VIEW_FILE_BY_FILE, { setting: emit });
},
);
});
});

View File

@ -48,6 +48,7 @@ import {
moveToNeighboringCommit,
setCurrentDiffFileIdFromNote,
navigateToDiffFileIndex,
setFileByFile,
} from '~/diffs/store/actions';
import eventHub from '~/notes/event_hub';
import * as types from '~/diffs/store/mutation_types';
@ -1455,4 +1456,20 @@ describe('DiffsStoreActions', () => {
);
});
});
describe('setFileByFile', () => {
it.each`
value
${true}
${false}
`('commits SET_FILE_BY_FILE with the new value $value', ({ value }) => {
return testAction(
setFileByFile,
{ fileByFile: value },
{ viewDiffsFileByFile: null },
[{ type: types.SET_FILE_BY_FILE, payload: value }],
[],
);
});
});
});

View File

@ -892,4 +892,18 @@ describe('DiffsStoreMutations', () => {
expect(state.showSuggestPopover).toBe(false);
});
});
describe('SET_FILE_BY_FILE', () => {
it.each`
value | opposite
${true} | ${false}
${false} | ${true}
`('sets viewDiffsFileByFile to $value', ({ value, opposite }) => {
const state = { viewDiffsFileByFile: opposite };
mutations[types.SET_FILE_BY_FILE](state, value);
expect(state.viewDiffsFileByFile).toBe(value);
});
});
});

View File

@ -0,0 +1,40 @@
import Cookies from 'js-cookie';
import { getParameterValues } from '~/lib/utils/url_utility';
import { fileByFile } from '~/diffs/utils/preferences';
import {
DIFF_FILE_BY_FILE_COOKIE_NAME,
DIFF_VIEW_FILE_BY_FILE,
DIFF_VIEW_ALL_FILES,
} from '~/diffs/constants';
jest.mock('~/lib/utils/url_utility');
describe('diffs preferences', () => {
describe('fileByFile', () => {
it.each`
result | preference | cookie | searchParam
${false} | ${false} | ${undefined} | ${undefined}
${true} | ${true} | ${undefined} | ${undefined}
${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${undefined}
${false} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${undefined}
${true} | ${false} | ${undefined} | ${[DIFF_VIEW_FILE_BY_FILE]}
${false} | ${true} | ${undefined} | ${[DIFF_VIEW_ALL_FILES]}
${true} | ${false} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_FILE_BY_FILE]}
${true} | ${true} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_FILE_BY_FILE]}
${false} | ${false} | ${DIFF_VIEW_ALL_FILES} | ${[DIFF_VIEW_ALL_FILES]}
${false} | ${true} | ${DIFF_VIEW_FILE_BY_FILE} | ${[DIFF_VIEW_ALL_FILES]}
`(
'should return $result when { preference: $preference, cookie: $cookie, search: $searchParam }',
({ result, preference, cookie, searchParam }) => {
if (cookie) {
Cookies.set(DIFF_FILE_BY_FILE_COOKIE_NAME, cookie);
}
getParameterValues.mockReturnValue(searchParam);
expect(fileByFile(preference)).toBe(result);
},
);
});
});

View File

@ -5,7 +5,14 @@ import waitForPromises from 'helpers/wait_for_promises';
import httpStatusCodes from '~/lib/utils/http_status';
import axios from '~/lib/utils/axios_utils';
import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue';
import { mockRefs, mockParams, mockPostParams, mockProjectId, mockError } from '../mock_data';
import {
mockBranches,
mockTags,
mockParams,
mockPostParams,
mockProjectId,
mockError,
} from '../mock_data';
import { redirectTo } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
@ -37,6 +44,10 @@ describe('Pipeline New Form', () => {
const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]');
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data);
const changeRef = i =>
findDropdownItems()
.at(i)
.vm.$emit('click');
const createComponent = (term = '', props = {}, method = shallowMount) => {
wrapper = method(PipelineNewForm, {
@ -44,7 +55,8 @@ describe('Pipeline New Form', () => {
projectId: mockProjectId,
pipelinesPath,
configVariablesPath,
refs: mockRefs,
branches: mockBranches,
tags: mockTags,
defaultBranch: 'master',
settingsLink: '',
maxWarnings: 25,
@ -76,8 +88,11 @@ describe('Pipeline New Form', () => {
});
it('displays dropdown with all branches and tags', () => {
const refLength = mockBranches.length + mockTags.length;
createComponent();
expect(findDropdownItems()).toHaveLength(mockRefs.length);
expect(findDropdownItems()).toHaveLength(refLength);
});
it('when user enters search term the list is filtered', () => {
@ -130,15 +145,6 @@ describe('Pipeline New Form', () => {
expect(findVariableRows()).toHaveLength(2);
});
it('creates a pipeline on submit', async () => {
findForm().vm.$emit('submit', dummySubmitEvent);
await waitForPromises();
expect(getExpectedPostParams()).toEqual(mockPostParams);
expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`);
});
it('creates blank variable on input change event', async () => {
const input = findKeyInputs().at(2);
input.element.value = 'test_var_2';
@ -150,45 +156,81 @@ describe('Pipeline New Form', () => {
expect(findKeyInputs().at(3).element.value).toBe('');
expect(findValueInputs().at(3).element.value).toBe('');
});
});
describe('when the form has been modified', () => {
const selectRef = i =>
findDropdownItems()
.at(i)
.vm.$emit('click');
describe('Pipeline creation', () => {
beforeEach(async () => {
mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, postResponse);
beforeEach(async () => {
const input = findKeyInputs().at(0);
input.element.value = 'test_var_2';
input.trigger('change');
await waitForPromises();
});
it('creates pipeline with full ref and variables', async () => {
createComponent();
findRemoveIcons()
.at(1)
.trigger('click');
changeRef(0);
await wrapper.vm.$nextTick();
});
findForm().vm.$emit('submit', dummySubmitEvent);
it('form values are restored when the ref changes', async () => {
expect(findVariableRows()).toHaveLength(2);
await waitForPromises();
selectRef(1);
await waitForPromises();
expect(getExpectedPostParams().ref).toEqual(wrapper.vm.$data.refValue.fullName);
expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`);
});
it('creates a pipeline with short ref and variables', async () => {
// query params are used
createComponent('', mockParams);
expect(findVariableRows()).toHaveLength(3);
expect(findKeyInputs().at(0).element.value).toBe('test_var');
});
await waitForPromises();
it('form values are restored again when the ref is reverted', async () => {
selectRef(1);
await waitForPromises();
findForm().vm.$emit('submit', dummySubmitEvent);
selectRef(2);
await waitForPromises();
await waitForPromises();
expect(findVariableRows()).toHaveLength(2);
expect(findKeyInputs().at(0).element.value).toBe('test_var_2');
});
expect(getExpectedPostParams()).toEqual(mockPostParams);
expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`);
});
});
describe('When the ref has been changed', () => {
beforeEach(async () => {
createComponent('', {}, mount);
await waitForPromises();
});
it('variables persist between ref changes', async () => {
changeRef(0); // change to master
await waitForPromises();
const masterInput = findKeyInputs().at(0);
masterInput.element.value = 'build_var';
masterInput.trigger('change');
await wrapper.vm.$nextTick();
changeRef(1); // change to branch-1
await waitForPromises();
const branchOneInput = findKeyInputs().at(0);
branchOneInput.element.value = 'deploy_var';
branchOneInput.trigger('change');
await wrapper.vm.$nextTick();
changeRef(0); // change back to master
await waitForPromises();
expect(findKeyInputs().at(0).element.value).toBe('build_var');
expect(findVariableRows().length).toBe(2);
changeRef(1); // change back to branch-1
await waitForPromises();
expect(findKeyInputs().at(0).element.value).toBe('deploy_var');
expect(findVariableRows().length).toBe(2);
});
});
@ -321,6 +363,7 @@ describe('Pipeline New Form', () => {
it('shows the correct warning title', () => {
const { length } = mockError.warnings;
expect(findWarningAlertSummary().attributes('message')).toBe(`${length} warnings found:`);
});

View File

@ -1,4 +1,14 @@
export const mockRefs = ['master', 'branch-1', 'tag-1'];
export const mockBranches = [
{ shortName: 'master', fullName: 'refs/heads/master' },
{ shortName: 'branch-1', fullName: 'refs/heads/branch-1' },
{ shortName: 'branch-2', fullName: 'refs/heads/branch-2' },
];
export const mockTags = [
{ shortName: '1.0.0', fullName: 'refs/tags/1.0.0' },
{ shortName: '1.1.0', fullName: 'refs/tags/1.1.0' },
{ shortName: '1.2.0', fullName: 'refs/tags/1.2.0' },
];
export const mockParams = {
refParam: 'tag-1',
@ -31,3 +41,7 @@ export const mockError = {
],
total_warnings: 7,
};
export const mockBranchRefs = ['master', 'dev', 'release'];
export const mockTagRefs = ['1.0.0', '1.1.0', '1.2.0'];

View File

@ -0,0 +1,21 @@
import formatRefs from '~/pipeline_new/utils/format_refs';
import { BRANCH_REF_TYPE, TAG_REF_TYPE } from '~/pipeline_new/constants';
import { mockBranchRefs, mockTagRefs } from '../mock_data';
describe('Format refs util', () => {
it('formats branch ref correctly', () => {
expect(formatRefs(mockBranchRefs, BRANCH_REF_TYPE)).toEqual([
{ fullName: 'refs/heads/master', shortName: 'master' },
{ fullName: 'refs/heads/dev', shortName: 'dev' },
{ fullName: 'refs/heads/release', shortName: 'release' },
]);
});
it('formats tag ref correctly', () => {
expect(formatRefs(mockTagRefs, TAG_REF_TYPE)).toEqual([
{ fullName: 'refs/tags/1.0.0', shortName: '1.0.0' },
{ fullName: 'refs/tags/1.1.0', shortName: '1.1.0' },
{ fullName: 'refs/tags/1.2.0', shortName: '1.2.0' },
]);
});
});

View File

@ -0,0 +1,64 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Settings Form Cadence matches snapshot 1`] = `
<expiration-dropdown-stub
class="gl-mr-7 gl-mb-0!"
data-testid="cadence-dropdown"
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Run cleanup:"
name="cadence"
value="EVERY_DAY"
/>
`;
exports[`Settings Form Enable matches snapshot 1`] = `
<expiration-toggle-stub
class="gl-mb-0!"
data-testid="enable-toggle"
value="true"
/>
`;
exports[`Settings Form Keep N matches snapshot 1`] = `
<expiration-dropdown-stub
data-testid="keep-n-dropdown"
formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]"
label="Keep the most recent:"
name="keep-n"
value="TEN_TAGS"
/>
`;
exports[`Settings Form Keep Regex matches snapshot 1`] = `
<expiration-textarea-stub
data-testid="keep-regex-textarea"
description="Tags with names that match this regex pattern are kept. %{linkStart}More information%{linkEnd}"
error=""
label="Keep tags matching:"
name="keep-regex"
placeholder=""
value="sss"
/>
`;
exports[`Settings Form OlderThan matches snapshot 1`] = `
<expiration-dropdown-stub
data-testid="older-than-dropdown"
formoptions="[object Object],[object Object],[object Object],[object Object]"
label="Remove tags older than:"
name="older-than"
value="FOURTEEN_DAYS"
/>
`;
exports[`Settings Form Remove regex matches snapshot 1`] = `
<expiration-textarea-stub
data-testid="remove-regex-textarea"
description="Tags with names that match this regex pattern are removed. %{linkStart}More information%{linkEnd}"
error=""
label="Remove tags matching:"
name="remove-regex"
placeholder=".*"
value="asdasdssssdfdf"
/>
`;

View File

@ -11,7 +11,11 @@ import {
UNAVAILABLE_USER_FEATURE_TEXT,
} from '~/registry/settings/constants';
import { expirationPolicyPayload, emptyExpirationPolicyPayload } from '../mock_data';
import {
expirationPolicyPayload,
emptyExpirationPolicyPayload,
containerExpirationPolicyData,
} from '../mock_data';
const localVue = createLocalVue();
@ -62,6 +66,29 @@ describe('Registry Settings App', () => {
wrapper.destroy();
});
describe('isEdited status', () => {
it.each`
description | apiResponse | workingCopy | result
${'empty response and no changes from user'} | ${emptyExpirationPolicyPayload()} | ${{}} | ${false}
${'empty response and changes from user'} | ${emptyExpirationPolicyPayload()} | ${{ enabled: true }} | ${true}
${'response and no changes'} | ${expirationPolicyPayload()} | ${containerExpirationPolicyData()} | ${false}
${'response and changes'} | ${expirationPolicyPayload()} | ${{ ...containerExpirationPolicyData(), nameRegex: '12345' }} | ${true}
${'response and empty'} | ${expirationPolicyPayload()} | ${{}} | ${true}
`('$description', async ({ apiResponse, workingCopy, result }) => {
const requests = mountComponentWithApollo({
provide: { ...defaultProvidedValues, enableHistoricEntries: true },
resolver: jest.fn().mockResolvedValue(apiResponse),
});
await Promise.all(requests);
findSettingsComponent().vm.$emit('input', workingCopy);
await wrapper.vm.$nextTick();
expect(findSettingsComponent().props('isEdited')).toBe(result);
});
});
it('renders the setting form', async () => {
const requests = mountComponentWithApollo({
resolver: jest.fn().mockResolvedValue(expirationPolicyPayload()),

View File

@ -4,7 +4,6 @@ import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Tracking from '~/tracking';
import component from '~/registry/settings/components/settings_form.vue';
import expirationPolicyFields from '~/registry/shared/components/expiration_policy_fields.vue';
import updateContainerExpirationPolicyMutation from '~/registry/settings/graphql/mutations/update_container_expiration_policy.graphql';
import expirationPolicyQuery from '~/registry/settings/graphql/queries/get_expiration_policy.graphql';
import {
@ -39,9 +38,15 @@ describe('Settings Form', () => {
};
const findForm = () => wrapper.find({ ref: 'form-element' });
const findFields = () => wrapper.find(expirationPolicyFields);
const findCancelButton = () => wrapper.find({ ref: 'cancel-button' });
const findSaveButton = () => wrapper.find({ ref: 'save-button' });
const findCancelButton = () => wrapper.find('[data-testid="cancel-button"');
const findSaveButton = () => wrapper.find('[data-testid="save-button"');
const findEnableToggle = () => wrapper.find('[data-testid="enable-toggle"]');
const findCadenceDropdown = () => wrapper.find('[data-testid="cadence-dropdown"]');
const findKeepNDropdown = () => wrapper.find('[data-testid="keep-n-dropdown"]');
const findKeepRegexTextarea = () => wrapper.find('[data-testid="keep-regex-textarea"]');
const findOlderThanDropdown = () => wrapper.find('[data-testid="older-than-dropdown"]');
const findRemoveRegexTextarea = () => wrapper.find('[data-testid="remove-regex-textarea"]');
const mountComponent = ({
props = defaultProps,
@ -109,45 +114,136 @@ describe('Settings Form', () => {
wrapper.destroy();
});
describe('data binding', () => {
it('v-model change update the settings property', () => {
describe.each`
model | finder | fieldName | type | defaultValue
${'enabled'} | ${findEnableToggle} | ${'Enable'} | ${'toggle'} | ${false}
${'cadence'} | ${findCadenceDropdown} | ${'Cadence'} | ${'dropdown'} | ${'EVERY_DAY'}
${'keepN'} | ${findKeepNDropdown} | ${'Keep N'} | ${'dropdown'} | ${'TEN_TAGS'}
${'nameRegexKeep'} | ${findKeepRegexTextarea} | ${'Keep Regex'} | ${'textarea'} | ${''}
${'olderThan'} | ${findOlderThanDropdown} | ${'OlderThan'} | ${'dropdown'} | ${'NINETY_DAYS'}
${'nameRegex'} | ${findRemoveRegexTextarea} | ${'Remove regex'} | ${'textarea'} | ${''}
`('$fieldName', ({ model, finder, type, defaultValue }) => {
it('matches snapshot', () => {
mountComponent();
findFields().vm.$emit('input', { newValue: 'foo' });
expect(wrapper.emitted('input')).toEqual([['foo']]);
expect(finder().element).toMatchSnapshot();
});
it('v-model change update the api error property', () => {
const apiErrors = { baz: 'bar' };
mountComponent({ data: { apiErrors } });
expect(findFields().props('apiErrors')).toEqual(apiErrors);
findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' });
expect(findFields().props('apiErrors')).toEqual({});
it('input event triggers a model update', () => {
mountComponent();
finder().vm.$emit('input', 'foo');
expect(wrapper.emitted('input')[0][0]).toMatchObject({
[model]: 'foo',
});
});
it('shows the default option when none are selected', () => {
mountComponent({ props: { value: {} } });
expect(findFields().props('value')).toEqual({
cadence: 'EVERY_DAY',
keepN: 'TEN_TAGS',
olderThan: 'NINETY_DAYS',
});
expect(finder().props('value')).toEqual(defaultValue);
});
if (type !== 'toggle') {
it.each`
isLoading | mutationLoading | enabledValue
${false} | ${false} | ${false}
${true} | ${false} | ${false}
${true} | ${true} | ${true}
${false} | ${true} | ${true}
${false} | ${false} | ${false}
`(
'is disabled when is loading is $isLoading, mutationLoading is $mutationLoading and enabled is $enabledValue',
({ isLoading, mutationLoading, enabledValue }) => {
mountComponent({
props: { isLoading, value: { enabled: enabledValue } },
data: { mutationLoading },
});
expect(finder().props('disabled')).toEqual(true);
},
);
} else {
it.each`
isLoading | mutationLoading
${true} | ${false}
${true} | ${true}
${false} | ${true}
`(
'is disabled when is loading is $isLoading and mutationLoading is $mutationLoading',
({ isLoading, mutationLoading }) => {
mountComponent({
props: { isLoading, value: {} },
data: { mutationLoading },
});
expect(finder().props('disabled')).toEqual(true);
},
);
}
if (type === 'textarea') {
it('input event updates the api error property', async () => {
const apiErrors = { [model]: 'bar' };
mountComponent({ data: { apiErrors } });
finder().vm.$emit('input', 'foo');
expect(finder().props('error')).toEqual('bar');
await wrapper.vm.$nextTick();
expect(finder().props('error')).toEqual('');
});
it('validation event updates buttons disabled state', async () => {
mountComponent();
expect(findSaveButton().props('disabled')).toBe(false);
finder().vm.$emit('validation', false);
await wrapper.vm.$nextTick();
expect(findSaveButton().props('disabled')).toBe(true);
});
}
if (type === 'dropdown') {
it('has the correct formOptions', () => {
mountComponent();
expect(finder().props('formOptions')).toEqual(wrapper.vm.$options.formOptions[model]);
});
}
});
describe('form', () => {
describe('form reset event', () => {
beforeEach(() => {
it('calls the appropriate function', () => {
mountComponent();
findForm().trigger('reset');
});
it('calls the appropriate function', () => {
expect(wrapper.emitted('reset')).toEqual([[]]);
});
it('tracks the reset event', () => {
mountComponent();
findForm().trigger('reset');
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'reset_form', trackingPayload);
});
it('resets the errors objects', async () => {
mountComponent({
data: { apiErrors: { nameRegex: 'bar' }, localErrors: { nameRegexKeep: false } },
});
findForm().trigger('reset');
await wrapper.vm.$nextTick();
expect(findKeepRegexTextarea().props('error')).toBe('');
expect(findRemoveRegexTextarea().props('error')).toBe('');
expect(findSaveButton().props('disabled')).toBe(false);
});
});
describe('form submit event ', () => {
@ -209,6 +305,7 @@ describe('Settings Form', () => {
});
});
});
describe('global errors', () => {
it('shows an error', async () => {
const handlers = mountComponentWithApollo({
@ -230,7 +327,7 @@ describe('Settings Form', () => {
graphQLErrors: [
{
extensions: {
problems: [{ path: ['name'], message: 'baz' }],
problems: [{ path: ['nameRegexKeep'], message: 'baz' }],
},
},
],
@ -241,7 +338,7 @@ describe('Settings Form', () => {
await waitForPromises();
await wrapper.vm.$nextTick();
expect(findFields().props('apiErrors')).toEqual({ name: 'baz' });
expect(findKeepRegexTextarea().props('error')).toEqual('baz');
});
});
});
@ -257,23 +354,21 @@ describe('Settings Form', () => {
});
it.each`
isLoading | isEdited | mutationLoading | isDisabled
${true} | ${true} | ${true} | ${true}
${false} | ${true} | ${true} | ${true}
${false} | ${false} | ${true} | ${true}
${true} | ${false} | ${false} | ${true}
${false} | ${false} | ${false} | ${true}
${false} | ${true} | ${false} | ${false}
isLoading | isEdited | mutationLoading
${true} | ${true} | ${true}
${false} | ${true} | ${true}
${false} | ${false} | ${true}
${true} | ${false} | ${false}
${false} | ${false} | ${false}
`(
'when isLoading is $isLoading and isEdited is $isEdited and mutationLoading is $mutationLoading is $isDisabled that the is disabled',
({ isEdited, isLoading, mutationLoading, isDisabled }) => {
'when isLoading is $isLoading, isEdited is $isEdited and mutationLoading is $mutationLoading is disabled',
({ isEdited, isLoading, mutationLoading }) => {
mountComponent({
props: { ...defaultProps, isEdited, isLoading },
data: { mutationLoading },
});
const expectation = isDisabled ? 'true' : undefined;
expect(findCancelButton().attributes('disabled')).toBe(expectation);
expect(findCancelButton().props('disabled')).toBe(true);
},
);
});
@ -284,24 +379,24 @@ describe('Settings Form', () => {
expect(findSaveButton().attributes('type')).toBe('submit');
});
it.each`
isLoading | fieldsAreValid | mutationLoading | isDisabled
${true} | ${true} | ${true} | ${true}
${false} | ${true} | ${true} | ${true}
${false} | ${false} | ${true} | ${true}
${true} | ${false} | ${false} | ${true}
${false} | ${false} | ${false} | ${true}
${false} | ${true} | ${false} | ${false}
isLoading | localErrors | mutationLoading
${true} | ${{}} | ${true}
${true} | ${{}} | ${false}
${false} | ${{}} | ${true}
${false} | ${{ foo: false }} | ${true}
${true} | ${{ foo: false }} | ${false}
${false} | ${{ foo: false }} | ${false}
`(
'when isLoading is $isLoading and fieldsAreValid is $fieldsAreValid and mutationLoading is $mutationLoading is $isDisabled that the is disabled',
({ fieldsAreValid, isLoading, mutationLoading, isDisabled }) => {
'when isLoading is $isLoading, localErrors is $localErrors and mutationLoading is $mutationLoading is disabled',
({ localErrors, isLoading, mutationLoading }) => {
mountComponent({
props: { ...defaultProps, isLoading },
data: { mutationLoading, fieldsAreValid },
data: { mutationLoading, localErrors },
});
const expectation = isDisabled ? 'true' : undefined;
expect(findSaveButton().attributes('disabled')).toBe(expectation);
expect(findSaveButton().props('disabled')).toBe(true);
},
);

View File

@ -76,25 +76,25 @@ Array [
Object {
"default": false,
"key": "SEVEN_DAYS",
"label": "7 days until tags are automatically removed",
"label": "7 days",
"variable": 7,
},
Object {
"default": false,
"key": "FOURTEEN_DAYS",
"label": "14 days until tags are automatically removed",
"label": "14 days",
"variable": 14,
},
Object {
"default": false,
"key": "THIRTY_DAYS",
"label": "30 days until tags are automatically removed",
"label": "30 days",
"variable": 30,
},
Object {
"default": true,
"key": "NINETY_DAYS",
"label": "90 days until tags are automatically removed",
"label": "90 days",
"variable": 90,
},
]

View File

@ -11,10 +11,7 @@ describe('Utils', () => {
[{ variable: 1 }, { variable: 2 }],
olderThanTranslationGenerator,
);
expect(result).toEqual([
{ variable: 1, label: '1 day until tags are automatically removed' },
{ variable: 2, label: '2 days until tags are automatically removed' },
]);
expect(result).toEqual([{ variable: 1, label: '1 day' }, { variable: 2, label: '2 days' }]);
});
});

View File

@ -86,6 +86,7 @@ label:
- issues
- merge_requests
- priorities
- epic_board_labels
milestone:
- group
- project

View File

@ -22,6 +22,15 @@ RSpec.describe CustomEmoji do
expect(new_emoji.errors.messages).to eq(name: ["#{emoji_name} is already being used for another emoji"])
end
it 'disallows very long invalid emoji name without regular expression backtracking issues' do
new_emoji = build(:custom_emoji, name: 'a' * 10000 + '!', group: group)
Timeout.timeout(1) do
expect(new_emoji).not_to be_valid
expect(new_emoji.errors.messages).to eq(name: ["is too long (maximum is 36 characters)", "is invalid"])
end
end
it 'disallows duplicate custom emoji names within namespace' do
old_emoji = create(:custom_emoji, group: group)
new_emoji = build(:custom_emoji, name: old_emoji.name, namespace: old_emoji.namespace, group: group)

View File

@ -5071,11 +5071,11 @@ RSpec.describe Project, factory_default: :keep do
end
end
describe "#default_branch" do
context "with an empty repository" do
describe '#default_branch' do
context 'with an empty repository' do
let_it_be(:project) { create(:project_empty_repo) }
context "group.default_branch_name is available" do
context 'group.default_branch_name is available' do
let(:project_group) { create(:group) }
let(:project) { create(:project, path: 'avatar', namespace: project_group) }
@ -5088,19 +5088,19 @@ RSpec.describe Project, factory_default: :keep do
.and_return('example_branch')
end
it "returns the group default value" do
expect(project.default_branch).to eq("example_branch")
it 'returns the group default value' do
expect(project.default_branch).to eq('example_branch')
end
end
context "Gitlab::CurrentSettings.default_branch_name is available" do
context 'Gitlab::CurrentSettings.default_branch_name is available' do
before do
expect(Gitlab::CurrentSettings)
.to receive(:default_branch_name)
.and_return(example_branch_name)
end
context "is missing or nil" do
context 'is missing or nil' do
let(:example_branch_name) { nil }
it "returns nil" do
@ -5108,10 +5108,18 @@ RSpec.describe Project, factory_default: :keep do
end
end
context "is present" do
let(:example_branch_name) { "example_branch_name" }
context 'is blank' do
let(:example_branch_name) { '' }
it "returns the expected branch name" do
it 'returns nil' do
expect(project.default_branch).to be_nil
end
end
context 'is present' do
let(:example_branch_name) { 'example_branch_name' }
it 'returns the expected branch name' do
expect(project.default_branch).to eq(example_branch_name)
end
end

View File

@ -8,6 +8,10 @@ if $".include?(File.expand_path('fast_spec_helper.rb', __dir__))
abort 'Aborting...'
end
# Enable deprecation warnings by default and make them more visible
# to developers to ease upgrading to newer Ruby versions.
Warning[:deprecated] = true unless ENV.key?('SILENCE_DEPRECATIONS')
require './spec/deprecation_toolkit_env'
require './spec/simplecov_env'