Commit 9bb21f0d authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents a0f5d7f7 83e64811
8302f636d0a3f1b83cb7e5420b2720e83e564306
397a8aa41c8b1b159a667fb262aebc644719e074
......@@ -15,7 +15,7 @@ export default {
canDelete: {
type: Boolean,
required: false,
default: false,
default: true,
},
showDelete: {
type: Boolean,
......
......@@ -138,6 +138,11 @@ export default {
</script>
<template>
<!--
This component should be replaced with a variant developed
as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
The variant will create a dropdown with an icon, no text and no caret
-->
<gl-new-dropdown
v-gl-tooltip
data-testid="actions-menu"
......
......@@ -394,15 +394,21 @@ export default {
data-qa-selector="prometheus_graph_widgets"
>
<div data-testid="dropdown-wrapper" class="d-flex align-items-center">
<!--
This component should be replaced with a variant developed
as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
The variant will create a dropdown with an icon, no text and no caret
-->
<gl-dropdown
v-gl-tooltip
toggle-class="shadow-none border-0"
toggle-class="gl-px-3!"
no-caret
data-qa-selector="prometheus_widgets_dropdown"
right
:title="__('More actions')"
>
<template slot="button-content">
<gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" />
<template #button-content>
<gl-icon class="gl-mr-0!" name="ellipsis_v" />
</template>
<gl-dropdown-item
v-if="expandBtnAvailable"
......
<script>
import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql';
import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue';
import {
......@@ -27,22 +28,57 @@ export default {
GlEmptyState,
GlButton,
},
props: {
graphUrl: {
type: String,
required: false,
default: '',
inject: {
dagDocPath: {
default: null,
},
emptySvgPath: {
type: String,
required: true,
default: '',
},
dagDocPath: {
type: String,
required: true,
pipelineIid: {
default: '',
},
pipelineProjectPath: {
default: '',
},
},
apollo: {
graphData: {
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
query: getDagVisData,
variables() {
return {
projectPath: this.pipelineProjectPath,
iid: this.pipelineIid,
};
},
update(data) {
const {
stages: { nodes: stages },
} = data.project.pipeline;
const unwrappedGroups = stages
.map(({ name, groups: { nodes: groups } }) => {
return groups.map(group => {
return { category: name, ...group };
});
})
.flat(2);
const nodes = unwrappedGroups.map(group => {
const jobs = group.jobs.nodes.map(({ name, needs }) => {
return { name, needs: needs.nodes.map(need => need.name) };
});
return { ...group, jobs };
});
return nodes;
},
error() {
this.reportFailure(LOAD_FAILURE);
},
},
},
data() {
return {
......@@ -90,31 +126,19 @@ export default {
default:
return {
text: this.$options.errorTexts[DEFAULT],
vatiant: 'danger',
variant: 'danger',
};
}
},
processedData() {
return this.processGraphData(this.graphData);
},
shouldDisplayAnnotations() {
return !isEmpty(this.annotationsMap);
},
shouldDisplayGraph() {
return Boolean(!this.showFailureAlert && this.graphData);
},
return Boolean(!this.showFailureAlert && !this.hasNoDependentJobs && this.graphData);
},
mounted() {
const { processGraphData, reportFailure } = this;
if (!this.graphUrl) {
reportFailure();
return;
}
axios
.get(this.graphUrl)
.then(response => {
processGraphData(response.data);
})
.catch(() => reportFailure(LOAD_FAILURE));
},
methods: {
addAnnotationToMap({ uid, source, target }) {
......@@ -124,25 +148,25 @@ export default {
let parsed;
try {
parsed = parseData(data.stages);
parsed = parseData(data);
} catch {
this.reportFailure(PARSE_FAILURE);
return;
return {};
}
if (parsed.links.length === 1) {
this.reportFailure(UNSUPPORTED_DATA);
return;
return {};
}
// If there are no links, we don't report failure
// as it simply means the user does not use job dependencies
if (parsed.links.length === 0) {
this.hasNoDependentJobs = true;
return;
return {};
}
this.graphData = parsed;
return parsed;
},
hideAlert() {
this.showFailureAlert = false;
......@@ -182,7 +206,7 @@ export default {
<dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" />
<dag-graph
v-if="shouldDisplayGraph"
:graph-data="graphData"
:graph-data="processedData"
@onFailure="reportFailure"
@update-annotation="updateAnnotation"
/>
......@@ -209,7 +233,7 @@ export default {
</p>
</div>
</template>
<template #actions>
<template v-if="dagDocPath" #actions>
<gl-button :href="dagDocPath" target="__blank" variant="success">
{{ $options.emptyStateTexts.button }}
</gl-button>
......
......@@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash';
received from the endpoint into the format the d3 graph expects.
Input is of the form:
[stages]
stages: {name, groups}
groups: [{ name, size, jobs }]
[nodes]
nodes: [{category, name, jobs, size}]
category is the stage name
name is a group name; in the case that the group has one job, it is
also the job name
size is the number of parallel jobs
jobs: [{ name, needs}]
job name is either the same as the group name or group x/y
needs: [job-names]
needs is an array of job-name strings
Output is of the form:
{ nodes: [node], links: [link] }
......@@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash';
link: { source, target, value }, with source & target being node names
and value being a constant
We create nodes, create links, and then dedupe the links, so that in the case where
We create nodes in the GraphQL update function, and then here we create the node dictionary,
then create links, and then dedupe the links, so that in the case where
job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link
from job 1 to job 2 then another from job 2 to job 4.
CREATE NODES
stage.name -> node.category
stage.group.name -> node.name (this is the group name if there are parallel jobs)
stage.group.jobs -> node.jobs
stage.group.size -> node.size
CREATE LINKS
stages.groups.name -> target
stages.groups.needs.each -> source (source is the name of the group, not the parallel job)
nodes.name -> target
nodes.name.needs.each -> source (source is the name of the group, not the parallel job)
10 -> value (constant)
*/
export const createNodes = data => {
return data.flatMap(({ groups, name }) => {
return groups.map(group => {
return { ...group, category: name };
});
});
};
export const createNodeDict = nodes => {
return nodes.reduce((acc, node) => {
const newNode = {
......@@ -62,13 +51,6 @@ export const createNodeDict = nodes => {
}, {});
};
export const createNodesStructure = data => {
const nodes = createNodes(data);
const nodeDict = createNodeDict(nodes);
return { nodes, nodeDict };
};
export const makeLinksFromNodes = (nodes, nodeDict) => {
const constantLinkValue = 10; // all links are the same weight
return nodes
......@@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) =>
return !allAncestors.includes(source);
});
export const parseData = data => {
const { nodes, nodeDict } = createNodesStructure(data);
export const parseData = nodes => {
const nodeDict = createNodeDict(nodes);
const allLinks = makeLinksFromNodes(nodes, nodeDict);
const filteredLinks = filterByAncestors(allLinks, nodeDict);
const links = uniqWith(filteredLinks, isEqual);
......
query getDagVisData($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) {
pipeline(iid: $iid) {
stages {
nodes {
name
groups {
nodes {
name
size
jobs {
nodes {
name
needs {
nodes {
name
}
}
}
}
}
}
}
}
}
}
}
......@@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue';
import Dag from './components/dag/dag.vue';
import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
......@@ -114,32 +114,6 @@ const createTestDetails = () => {
});
};
const createDagApp = () => {
if (!window.gon?.features?.dagPipelineTab) {
return;
}
const el = document.querySelector('#js-pipeline-dag-vue');
const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
Dag,
},
render(createElement) {
return createElement('dag', {
props: {
graphUrl: pipelineDataPath,
emptySvgPath,
dagDocPath,
},
});
},
});
};
export default () => {
const { dataset } = document.querySelector('.js-pipeline-details-vue');
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import Dag from './components/dag/dag.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
const createDagApp = () => {
if (!window.gon?.features?.dagPipelineTab) {
return;
}
const el = document.querySelector('#js-pipeline-dag-vue');
const { pipelineProjectPath, pipelineIid, emptySvgPath, dagDocPath } = el?.dataset;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
Dag,
},
apolloProvider,
provide: {
pipelineProjectPath,
pipelineIid,
emptySvgPath,
dagDocPath,
},
render(createElement) {
return createElement('dag', {});
},
});
};
export default createDagApp;
......@@ -14,9 +14,6 @@ import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR,
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '../constants';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
......@@ -56,25 +53,20 @@ export default {
},
data() {
return {
blobsActions: {},
isUpdating: false,
newSnippet: false,
actions: [],
};
},
computed: {
getActionsEntries() {
return Object.values(this.blobsActions);
hasBlobChanges() {
return this.actions.length > 0;
},
allBlobsHaveContent() {
const entries = this.getActionsEntries;
return entries.length > 0 && !entries.find(action => !action.content);
},
allBlobChangesRegistered() {
const entries = this.getActionsEntries;
return entries.length > 0 && !entries.find(action => action.action === '');
hasValidBlobs() {
return this.actions.every(x => x.filePath && x.content);
},
updatePrevented() {
return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating;
return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating;
},
isProjectSnippet() {
return Boolean(this.projectPath);
......@@ -85,7 +77,7 @@ export default {
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.getActionsEntries.filter(entry => entry.action !== ''),
blobActions: this.actions,
};
},
saveButtonLabel() {
......@@ -120,48 +112,11 @@ export default {
onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?');
if (!this.allBlobChangesRegistered || this.isUpdating) return undefined;
if (!this.hasBlobChanges || this.isUpdating) return undefined;
Object.assign(e, { returnValue });
return returnValue;
},
updateBlobActions(args = {}) {
// `_constants` is the internal prop that
// should not be sent to the mutation. Hence we filter it out from
// the argsToUpdateAction that is the data-basis for the mutation.
const { _constants: blobConstants, ...argsToUpdateAction } = args;
const { previousPath, filePath, content } = argsToUpdateAction;
let actionEntry = this.blobsActions[blobConstants.id] || {};
let tunedActions = {
action: '',
previousPath,
};
if (this.newSnippet) {
// new snippet, hence new blob
tunedActions = {
action: SNIPPET_BLOB_ACTION_CREATE,
previousPath: '',
};
} else if (previousPath && filePath) {
// renaming of a blob + renaming & content update
const renamedToOriginal = filePath === blobConstants.originalPath;
tunedActions = {
action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE,
previousPath: !renamedToOriginal ? blobConstants.originalPath : '',
};
} else if (content !== blobConstants.originalContent) {
// content update only
tunedActions = {
action: SNIPPET_BLOB_ACTION_UPDATE,
previousPath: '',
};
}
actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions };
this.$set(this.blobsActions, blobConstants.id, actionEntry);
},
flashAPIFailure(err) {
const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR
......@@ -218,7 +173,6 @@ export default {
if (errors.length) {
this.flashAPIFailure(errors[0]);
} else {
this.originalContent = this.content;
redirectTo(baseObj.snippet.webUrl);
}
})
......@@ -226,6 +180,9 @@ export default {
this.flashAPIFailure(e);
});
},
updateActions(actions) {
this.actions = actions;
},
},
newSnippetSchema: {
title: '',
......@@ -261,7 +218,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
<snippet-blob-actions-edit :blobs="blobs" @blob-updated="updateBlobActions" />
<snippet-blob-actions-edit :init-blobs="blobs" @actions="updateActions" />
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
......
<script>
import { GlButton } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { s__, sprintf } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SnippetBlobEdit from './snippet_blob_edit.vue';
import { SNIPPET_MAX_BLOBS } from '../constants';
import { createBlob, decorateBlob, diffAll } from '../utils/blob';
export default {
components: {
SnippetBlobEdit,
GlButton,
},
mixins: [glFeatureFlagsMixin()],
props: {
blobs: {
initBlobs: {
type: Array,
required: true,
},
},
data() {
return {
// This is a dictionary (by .id) of the original blobs and
// is used as the baseline for calculating diffs
// (e.g., what has been deleted, changed, renamed, etc.)
blobsOrig: {},
// This is a dictionary (by .id) of the current blobs and
// is updated as the user makes changes.
blobs: {},
// This is a list of blob ID's in order how they should be
// presented.
blobIds: [],
};
},
computed: {
actions() {
return diffAll(this.blobs, this.blobsOrig);
},
count() {
return this.blobIds.length;
},
addLabel() {
return sprintf(s__('Snippets|Add another file %{num}/%{total}'), {
num: this.count,
total: SNIPPET_MAX_BLOBS,
});
},
canDelete() {
return this.count > 1;
},
canAdd() {
return this.count < SNIPPET_MAX_BLOBS;
},
hasMultiFilesEnabled() {
return this.glFeatures.snippetMultipleFiles;
},
filesLabel() {
return this.hasMultiFilesEnabled ? s__('Snippets|Files') : s__('Snippets|File');
},
firstInputId() {
const blobId = this.blobIds[0];
if (!blobId) {
return '';
}
return `${blobId}_file_path`;
},
},
watch: {
actions: {
immediate: true,
handler(val) {
this.$emit('actions', val);
},
},
},
created() {
const blobs = this.initBlobs.map(decorateBlob);
const blobsById = blobs.reduce((acc, x) => Object.assign(acc, { [x.id]: x }), {});
this.blobsOrig = blobsById;
this.blobs = cloneDeep(blobsById);
this.blobIds = blobs.map(x => x.id);
// Show 1 empty blob if none exist
if (!this.blobIds.length) {
this.addBlob();
}
},
methods: {
updateBlobContent(id, content) {
const origBlob = this.blobsOrig[id];
const blob = this.blobs[id];
blob.content = content;
// If we've received content, but we haven't loaded the content before
// then this is also the original content.
if (origBlob && !origBlob.isLoaded) {
blob.isLoaded = true;
origBlob.isLoaded = true;
origBlob.content = content;
}
},
updateBlobFilePath(id, path) {
const blob = this.blobs[id];
blob.path = path;
},
addBlob() {
const blob = createBlob();
this.$set(this.blobs, blob.id, blob);
this.blobIds.push(blob.id);
},
deleteBlob(id) {
this.blobIds = this.blobIds.filter(x => x !== id);
this.$delete(this.blobs, id);
},
updateBlob(id, args) {
if ('content' in args) {
this.updateBlobContent(id, args.content);
}
if ('path' in args) {
this.updateBlobFilePath(id, args.path);
}
},
},
};
</script>
<template>
<div class="form-group file-editor">
<label for="snippet_file_path">{{ s__('Snippets|File') }}</label>
<template v-if="blobs.length">
<snippet-blob-edit v-for="blob in blobs" :key="blob.name" :blob="blob" v-on="$listeners" />
</template>
<snippet-blob-edit v-else v-on="$listeners" />
<label :for="firstInputId">{{ filesLabel }}</label>
<snippet-blob-edit
v-for="(blobId, index) in blobIds"
:key="blobId"
:class="{ 'gl-mt-3': index > 0 }"
:blob="blobs[blobId]"
:can-delete="canDelete"
:show-delete="hasMultiFilesEnabled"
@blob-updated="updateBlob(blobId, $event)"
@delete="deleteBlob(blobId)"
/>
<gl-button
v-if="hasMultiFilesEnabled"
:disabled="!canAdd"
data-testid="add_button"
class="gl-my-3"
variant="dashed"
@click="addBlob"
>{{ addLabel }}</gl-button
>
</div>
</template>
......@@ -8,12 +8,6 @@ import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
import Flash from '~/flash';
import { sprintf } from '~/locale';
function localId() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
export default {
components: {
BlobHeaderEdit,
......@@ -24,49 +18,35 @@ export default {
props: {
blob: {
type: Object,
required: false,
default: null,
validator: ({ rawPath }) => Boolean(rawPath),
required: true,
},
canDelete: {
type: Boolean,
required: false,
default: true,
},
data() {
return {
id: localId(),
filePath: this.blob?.path || '',
previousPath: '',
originalPath: this.blob?.path || '',
content: this.blob?.content || '',
originalContent: '',
isContentLoading: this.blob,
};
showDelete: {
type: Boolean,
required: false,
default: false,
},
watch: {
filePath(filePath, previousPath) {
this.previousPath = previousPath;
this.notifyAboutUpdates({ previousPath });
},
content() {
this.notifyAboutUpdates();
computed: {
inputId() {
return `${this.blob.id}_file_path`;
},
},
mounted() {
if (this.blob) {
if (!this.blob.isLoaded) {
this.fetchBlobContent();
}
},
methods: {
notifyAboutUpdates(args = {}) {
const { filePath, previousPath } = args;
this.$emit('blob-updated', {
filePath: filePath || this.filePath,
previousPath: previousPath || this.previousPath,
content: this.content,
_constants: {
originalPath: this.originalPath,
originalContent: this.originalContent,
id: this.id,
onDelete() {
this.$emit('delete');
},
});
notifyAboutUpdates(args = {}) {
this.$emit('blob-updated', args);
},
fetchBlobContent() {
const baseUrl = getBaseURL();
......@@ -75,17 +55,12 @@ export default {
axios
.get(url)
.then(res => {
this.originalContent = res.data;
this.content = res.data;
this.notifyAboutUpdates({ content: res.data });
})
.catch(e => this.flashAPIFailure(e))
.finally(() => {
this.isContentLoading = false;
});
.catch(e => this.flashAPIFailure(e));
},
flashAPIFailure(err) {
Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
this.isContentLoading = false;
},
},
};
......@@ -93,16 +68,26 @@ export default {
<template>
<div class="file-holder snippet">
<blob-header-edit
id="snippet_file_path"
v-model="filePath"
:id="inputId"
:value="blob.path"
data-qa-selector="file_name_field"
:can-delete="canDelete"
:show-delete="showDelete"
@input="notifyAboutUpdates({ path: $event })"
@delete="onDelete"
/>
<gl-loading-icon
v-if="isContentLoading"
v-if="!blob.isLoaded"
:label="__('Loading snippet')"
size="lg"
class="loading-animation prepend-top-20 append-bottom-20"
/>
<blob-content-edit v-else v-model="content" :file-global-id="id" :file-name="filePath" />
<blob-content-edit
v-else
:value="blob.content"
:file-global-id="blob.id"
:file-name="blob.path"
@input="notifyAboutUpdates({ content: $event })"
/>
</div>
</template>
......@@ -31,3 +31,5 @@ export const SNIPPET_BLOB_ACTION_CREATE = 'create';
export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
export const SNIPPET_BLOB_ACTION_MOVE = 'move';
export const SNIPPET_BLOB_ACTION_DELETE = 'delete';
export const SNIPPET_MAX_BLOBS = 10;
......@@ -3,6 +3,11 @@ import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import getStateQuery from '../../queries/get_state.query.graphql';
import workInProgressQuery from '../../queries/states/work_in_progress.query.graphql';
import removeWipMutation from '../../queries/toggle_wip.mutation.graphql';
import StatusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
......@@ -16,17 +21,105 @@ export default {
directives: {
tooltip,
},
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
userPermissions: {
query: workInProgressQuery,
skip() {
return !this.glFeatures.mergeRequestWidgetGraphql;
},
variables() {
return this.mergeRequestQueryVariables;
},
update: data => data.project.mergeRequest.userPermissions,
},
},
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
data() {
return {
userPermissions: {},
isMakingRequest: false,
};
},
computed: {
canUpdate() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
return this.userPermissions.updateMergeRequest;
}
return Boolean(this.mr.removeWIPPath);
},
},
methods: {
removeWipMutation() {
this.isMakingRequest = true;
this.$apollo
.mutate({
mutation: removeWipMutation,
variables: {
...this.mergeRequestQueryVariables,
wip: false,
},
update(
store,
{
data: {
mergeRequestSetWip: {
errors,
mergeRequest: { workInProgress, title },
},
},
},
) {
if (errors?.length) {
createFlash(__('Something went wrong. Please try again.'));
return;
}
const data = store.readQuery({
query: getStateQuery,
variables: this.mergeRequestQueryVariables,
});
data.project.mergeRequest.workInProgress = workInProgress;
data.project.mergeRequest.title = title;
store.writeQuery({
query: getStateQuery,
data,
variables: this.mergeRequestQueryVariables,
});
},
optimisticResponse: {
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Mutation',
mergeRequestSetWip: {
__typename: 'MergeRequestSetWipPayload',
errors: [],
mergeRequest: {
__typename: 'MergeRequest',
title: this.mr.title,
workInProgress: false,
},
},
},
})
.then(({ data: { mergeRequestSetWip: { mergeRequest: { title } } } }) => {
createFlash(__('The merge request can now be merged.'), 'notice');
$('.merge-request .detail-page-description .title').text(title);
})
.catch(() => createFlash(__('Something went wrong. Please try again.')))
.finally(() => {
this.isMakingRequest = false;
});
},
handleRemoveWIP() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
this.removeWipMutation();
} else {
this.isMakingRequest = true;
this.service
.removeWIP()
......@@ -40,6 +133,7 @@ export default {
this.isMakingRequest = false;
createFlash(__('Something went wrong. Please try again.'));
});
}
},
},
};
......@@ -47,7 +141,7 @@ export default {
<template>
<div class="mr-widget-body media">
<status-icon :show-disabled-button="Boolean(mr.removeWIPPath)" status="warning" />
<status-icon :show-disabled-button="canUpdate" status="warning" />
<div class="media-body">
<div class="gl-ml-3 float-left">
<span class="gl-font-weight-bold">
......@@ -58,7 +152,7 @@ export default {
}}</span>
</div>
<gl-button
v-if="mr.removeWIPPath"
v-if="canUpdate"
size="small"
:disabled="isMakingRequest"
:loading="isMakingRequest"
......
export default {
computed: {
mergeRequestQueryVariables() {
return {
projectPath: this.mr.targetProjectFullPath,
iid: `${this.mr.iid}`,
};
},
},
};
......@@ -8,6 +8,7 @@ import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
import createFlash from '../flash';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import Loading from './components/loading.vue';
import WidgetHeader from './components/mr_widget_header.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
......@@ -42,6 +43,7 @@ import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import getStateQuery from './queries/get_state.query.graphql';
export default {
el: '#js-vue-mr-widget',
......@@ -83,6 +85,27 @@ export default {
GroupedAccessibilityReportsApp,
MrWidgetApprovals,
},
apollo: {
state: {
query: getStateQuery,
manual: true,
pollInterval: 10 * 1000,
skip() {
return !this.mr || !window.gon?.features?.mergeRequestWidgetGraphql;
},
variables() {
return this.mergeRequestQueryVariables;
},
result({
data: {
project: { mergeRequest },
},
}) {
this.mr.setGraphqlData(mergeRequest);
},
},
},
mixins: [mergeRequestQueryVariablesMixin],
props: {
mrData: {
type: Object,
......
query getState($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
title
workInProgress
}
}
}
query workInProgressQuery($projectPath: ID!, $iid: String!) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
userPermissions {
updateMergeRequest
}
}
}
}
mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) {
mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) {
mergeRequest {
title
workInProgress
}
errors
}
}
import { stateKey } from './state_maps';
export default function deviseState(data) {
if (data.project_archived) {
export default function deviseState() {
if (this.projectArchived) {
return stateKey.archived;
} else if (data.branch_missing) {
} else if (this.branchMissing) {
return stateKey.missingBranch;
} else if (!data.commits_count) {
} else if (!this.commitsCount) {
return stateKey.nothingToMerge;
} else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') {
return stateKey.checking;
} else if (data.has_conflicts) {
} else if (this.hasConflicts) {
return stateKey.conflicts;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
} else if (data.work_in_progress) {
} else if (this.workInProgress) {
return stateKey.workInProgress;
} else if (this.hasMergeableDiscussionsState) {
return stateKey.unresolvedDiscussions;
......
......@@ -60,6 +60,9 @@ export default class MergeRequestStore {
this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path;
this.approvalsWidgetType = data.approvals_widget_type;
this.projectArchived = data.project_archived;
this.branchMissing = data.branch_missing;
this.hasConflicts = data.has_conflicts;
if (data.issues_links) {
const links = data.issues_links;
......@@ -90,7 +93,8 @@ export default class MergeRequestStore {
this.ffOnlyEnabled = data.ff_only_enabled;
this.shouldBeRebased = Boolean(data.should_be_rebased);
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
this.isOpen = data.state === 'opened';
this.mergeRequestState = data.state;
this.isOpen = this.mergeRequestState === 'opened';
this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.latestSHA = data.diff_head_sha;
......@@ -133,6 +137,10 @@ export default class MergeRequestStore {
this.mergeCommitPath = data.merge_commit_path;
this.canPushToSourceBranch = data.can_push_to_source_branch;
if (data.work_in_progress !== undefined) {
this.workInProgress = data.work_in_progress;
}
const currentUser = data.current_user;
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
......@@ -143,19 +151,25 @@ export default class MergeRequestStore {
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
this.setState(data);
this.setState();
}
setGraphqlData(data) {
this.workInProgress = data.workInProgress;
this.setState();
}
setState(data) {
setState() {
if (this.mergeOngoing) {
this.state = 'merging';
return;
}
if (this.isOpen) {
this.state = getStateKey.call(this, data);
this.state = getStateKey.call(this);
} else {
switch (data.state) {
switch (this.mergeRequestState) {
case 'merged':
this.state = 'merged';
break;
......
......@@ -307,23 +307,6 @@
color: $gl-text-color;
border-color: $border-color;
}
svg {
height: 14px;
width: 14px;
vertical-align: middle;
margin-bottom: 4px;
}
.dropdown-toggle-text {
display: inline-block;
color: inherit;
.fa {
vertical-align: middle;
color: inherit;
}
}
}
.filtered-search-history-dropdown {
......
......@@ -39,6 +39,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:auto_expand_collapsed_diffs, @project, default_enabled: true)
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project)
end
before_action do
......
......@@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :authorize_update_snippet!, only: [:edit, :update]
before_action :authorize_admin_snippet!, only: [:destroy]
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index
@snippet_counts = ::Snippets::CountService
.new(current_user, project: @project)
......
......@@ -44,6 +44,10 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:service_desk_custom_address, @project)
end
before_action only: [:edit] do
push_frontend_feature_flag(:approval_suggestions, @project)
end
layout :determine_layout
def index
......
......@@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController
layout 'snippets'
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index
if params[:username].present?
@user = UserFinder.new(params[:username]).find_by_username!
......
......@@ -51,14 +51,16 @@ class MergeRequestDiff < ApplicationRecord
scope :by_commit_sha, ->(sha) do
joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
end
scope :has_diff_files, -> { where(id: MergeRequestDiffFile.select(:merge_request_diff_id)) }
scope :by_project_id, -> (project_id) do
joins(:merge_request).where(merge_requests: { target_project_id: project_id })
end
scope :recent, -> { order(id: :desc).limit(100) }
scope :files_in_database, -> { where(stored_externally: [false, nil]) }
scope :files_in_database, -> do
where(stored_externally: [false, nil]).where(arel_table[:files_count].gt(0))
end
scope :not_latest_diffs, -> do
merge_requests = MergeRequest.arel_table
......@@ -115,29 +117,28 @@ class MergeRequestDiff < ApplicationRecord
end
def ids_for_external_storage_migration_strategy_always(limit:)
has_diff_files.files_in_database.limit(limit).pluck(:id)
files_in_database.limit(limit).pluck(:id)
end
def ids_for_external_storage_migration_strategy_outdated(limit:)
# Outdated is too complex to be a single SQL query, so split into three
before = EXTERNAL_DIFF_CUTOFF.ago
potentials = has_diff_files.files_in_database
ids = potentials
ids = files_in_database
.old_merged_diffs(before)
.limit(limit)
.pluck(:id)
return ids if ids.size >= limit
ids += potentials
ids += files_in_database
.old_closed_diffs(before)
.limit(limit - ids.size)
.pluck(:id)
return ids if ids.size >= limit
ids + potentials
ids + files_in_database
.not_latest_diffs
.limit(limit - ids.size)
.pluck(:id)
......
......@@ -81,7 +81,7 @@
- if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane
#js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline), empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
#js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
#js-tab-tests.tab-pane
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
......
......@@ -20,7 +20,8 @@
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
- if type != :boards_modal && type != :boards
= dropdown_tag(_('Recent searches'),
- text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline")
= dropdown_tag(text,
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "btn filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",
......
---
title: Use more-efficient indexing for the MergeRequestDiff storage migration
merge_request: 39470
author:
type: performance
---
title: Use history icon on recent search filter tab only on mobile
merge_request: 39557
author: Takuya Noguchi
type: fixed
---
title: Fix panel "more actions" button layout
merge_request: 39534
author:
type: fixed
# frozen_string_literal: true
class AddNewExternalDiffMigrationIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_merge_request_diffs_by_id_partial'
disable_ddl_transaction!
def up
add_concurrent_index(
:merge_request_diffs,
:id,
name: INDEX_NAME,
where: 'files_count > 0 AND ((NOT stored_externally) OR (stored_externally IS NULL))'
)
end
def down
remove_concurrent_index_by_name(:merge_request_diffs, INDEX_NAME)
end
end
# frozen_string_literal: true
class RemoveOldExternalDiffMigrationIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
remove_concurrent_index_by_name(
:merge_request_diffs,
'index_merge_request_diffs_on_merge_request_id_and_id_partial'
)
end
def down
add_concurrent_index(
:merge_request_diffs,
[:merge_request_id, :id],
where: { stored_externally: [nil, false] }
)
end
end
12bb243862adf930fc68f2f7641ee7cc6170bfbcc3aae2b98bfa262dacfeba49
\ No newline at end of file
7c4fec044a278fa51fdd23320a372528c420e8a650a26770fccf0cabe91163c5
\ No newline at end of file
......@@ -20003,14 +20003,14 @@ CREATE INDEX index_merge_request_diff_commits_on_sha ON public.merge_request_dif
CREATE UNIQUE INDEX index_merge_request_diff_files_on_mr_diff_id_and_order ON public.merge_request_diff_files USING btree (merge_request_diff_id, relative_order);
CREATE INDEX index_merge_request_diffs_by_id_partial ON public.merge_request_diffs USING btree (id) WHERE ((files_count > 0) AND ((NOT stored_externally) OR (stored_externally IS NULL)));
CREATE INDEX index_merge_request_diffs_external_diff_store_is_null ON public.merge_request_diffs USING btree (id) WHERE (external_diff_store IS NULL);
CREATE INDEX index_merge_request_diffs_on_external_diff_store ON public.merge_request_diffs USING btree (external_diff_store);
CREATE INDEX index_merge_request_diffs_on_merge_request_id_and_id ON public.merge_request_diffs USING btree (merge_request_id, id);
CREATE INDEX index_merge_request_diffs_on_merge_request_id_and_id_partial ON public.merge_request_diffs USING btree (merge_request_id, id) WHERE ((NOT stored_externally) OR (stored_externally IS NULL));
CREATE INDEX index_merge_request_metrics_on_first_deployed_to_production_at ON public.merge_request_metrics USING btree (first_deployed_to_production_at);
CREATE INDEX index_merge_request_metrics_on_latest_closed_at ON public.merge_request_metrics USING btree (latest_closed_at) WHERE (latest_closed_at IS NOT NULL);
......
......@@ -50,7 +50,7 @@ Have a look at some of our most popular topics:
## The entire DevOps Lifecycle
GitLab is the first single application for software development, security,
and operations that enables [Concurrent DevOps](https://about.gitlab.com/concurrent-devops/),
and operations that enables [Concurrent DevOps](https://about.gitlab.com/topics/concurrent-devops/),
making the software lifecycle faster and radically improving the speed of business.
GitLab provides solutions for [each of the stages of the DevOps lifecycle](https://about.gitlab.com/stages-devops-lifecycle/):
......
......@@ -235,5 +235,5 @@ Shortly after that, the client agents should rejoin as well.
If you have taken advantage of Consul to store other data and want to restore
the failed node, follow the
[Consul guide](https://learn.hashicorp.com/consul/day-2-operations/outage)
[Consul guide](https://learn.hashicorp.com/tutorials/consul/recovery-outage)
to recover a failed cluster.
......@@ -373,7 +373,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o
##
postgresql['sql_user_password'] = '<md5_hash_of_your_password>'
gitlab_rails['db_password'] = '<your_password_here>'
```
For external PostgreSQL instances, see [additional instructions](external_database.md).
If you bring a former **primary** node back online to serve as a **secondary** node, then you also need to remove `roles ['geo_primary_role']` or `geo_primary_role['enable'] = true`.
......
......@@ -233,7 +233,7 @@ The following values configure logging in Gitaly under the `[logging]` section.
| `format` | string | no | Log format: `text` or `json`. Default: `text`. |
| `level` | string | no | Log level: `debug`, `info`, `warn`, `error`, `fatal`, or `panic`. Default: `info`. |
| `sentry_dsn` | string | no | Sentry DSN for exception monitoring. |
| `sentry_environment` | string | no | [Sentry Environment](https://docs.sentry.io/enriching-error-data/environments/) for exception monitoring. |
| `sentry_environment` | string | no | [Sentry Environment](https://docs.sentry.io/product/sentry-basics/environments/) for exception monitoring. |
| `ruby_sentry_dsn` | string | no | Sentry DSN for `gitaly-ruby` exception monitoring. |
While the main Gitaly application logs go to `stdout`, there are some extra log
......
......@@ -62,7 +62,7 @@ Example of response
"id": 1,
"name": "review/fix-foo",
"slug": "review-fix-foo-dfjre3",
"external_url": "https://review-fix-foo-dfjre3.example.gitlab.com"
"external_url": "https://review-fix-foo-dfjre3.example.gitlab.com",
"state": "available",
"last_deployment": {
"id": 100,
......@@ -78,7 +78,7 @@ Example of response
"username": "root",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "http://localhost:3000/root"
}
},
"deployable": {
"id": 710,
"status": "success",
......@@ -107,7 +107,7 @@ Example of response
"twitter": "",
"website_url": "",
"organization": null
}
},
"commit": {
"id": "416d8ea11849050d3d1f5104cf8cf51053e790ab",
"short_id": "416d8ea1",
......
......@@ -208,7 +208,7 @@ The variable names begin with the `CI_MERGE_REQUEST_` prefix.
### Two pipelines created when pushing to a merge request
If you are experiencing duplicated pipelines when using `rules`, take a look at
the [important differences between `rules` and `only`/`except`](../yaml/README.md#differences-between-rules-and-onlyexcept),
the [important differences between `rules` and `only`/`except`](../yaml/README.md#prevent-duplicate-pipelines),
which will help you get your starting configuration correct.
If you are seeing two pipelines when using `only/except`, please see the caveats
......
......@@ -7,6 +7,24 @@ type: reference
# Troubleshooting CI/CD
## Pipeline warnings
Pipeline configuration warnings are shown when you:
- [View pipeline details](pipelines/index.md#view-pipelines).
- [Validate configuration with the CI Lint tool](yaml/README.md#validate-the-gitlab-ciyml).
- [Manually run a pipeline](pipelines/index.md#run-a-pipeline-manually).
### "Job may allow multiple pipelines to run for a single action"
When you use [`rules`](yaml/README.md#rules) with a `when:` clause without
an `if:` clause, multiple pipelines may run. Usually
this occurs when you push a commit to a branch that has an open merge request associated with it.
To [prevent duplicate pipelines](yaml/README.md#prevent-duplicate-pipelines), use
[`workflow: rules`](yaml/README.md#workflowrules) or rewrite your rules
to control which pipelines can run.
## Merge request pipeline widget
The merge request pipeline widget shows information about the pipeline status in a Merge Request. It's displayed above the [merge request ability to merge widget](#merge-request-ability-to-merge-widget).
......
......@@ -348,7 +348,7 @@ but does allow pipelines in **all** other cases, *including* merge request pipel
As with `rules` defined in jobs, be careful not to use a configuration that allows
merge request pipelines and branch pipelines to run at the same time, or you could
have [duplicate pipelines](#differences-between-rules-and-onlyexcept).
have [duplicate pipelines](#prevent-duplicate-pipelines).
#### `workflow:rules` templates
......@@ -1218,19 +1218,22 @@ job:
- In **all other cases**, the job is added to the pipeline, with `when: on_success`.
CAUTION: **Caution:**
If you use `when: on_success`, `always`, or `delayed` as the final rule, two
If you use a `when:` clause as the final rule (not including `when: never`), two
simultaneous pipelines may start. Both push pipelines and merge request pipelines can
be triggered by the same event (a push to the source branch for an open merge request).
See the [important differences between `rules` and `only`/`except`](#differences-between-rules-and-onlyexcept)
See how to [prevent duplicate pipelines](#prevent-duplicate-pipelines)
for more details.
#### Differences between `rules` and `only`/`except`
#### Prevent duplicate pipelines
Jobs defined with `only/except` do not trigger merge request pipelines by default.
You must explicitly add `only: merge_requests`.
Jobs defined with `rules` can trigger multiple pipelines with the same action. You
don't have to explicitly configure rules for each type of pipeline to trigger them
accidentally. Rules that are too loose (allowing too many types of pipelines) could
cause a second pipeline to run unexpectedly.
Jobs defined with `rules` can trigger all types of pipelines.
You do not have to explicitly configure each type.
Some configurations that have the potential to cause duplicate pipelines cause a
[pipeline warning](../troubleshooting.md#pipeline-warnings) to be displayed.
[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/219431) in GitLab 13.3.
For example:
......@@ -1246,21 +1249,77 @@ job:
This job does not run when `$CUSTOM_VARIABLE` is false, but it *does* run in **all**
other pipelines, including **both** push (branch) and merge request pipelines. With
this configuration, every push to an open merge request's source branch
causes duplicated pipelines. Explicitly allowing both push and merge request pipelines
in the same job could have the same effect.
causes duplicated pipelines.
We recommend using [`workflow: rules`](#workflowrules) to limit which types of pipelines
are permitted. Allowing only merge request pipelines, or only branch pipelines,
eliminates duplicated pipelines. Alternatively, you can rewrite the rules to be
stricter, or avoid using a final `when` (`always`, `on_success` or `delayed`).
There are multiple ways to avoid this:
It is not possible to run a job for branch pipelines first, then only for merge request
pipelines after the merge request is created (skipping the duplicate branch pipeline). See
the [related issue](https://gitlab.com/gitlab-org/gitlab/-/issues/201845) for more details.
- Use [`workflow: rules`](#workflowrules) to specify which types of pipelines
can run. To eliminate duplicate pipelines, allow only merge request pipelines
or push (branch) pipelines.
Also, we don't recommend mixing `only/except` jobs with `rules` jobs in the same pipeline.
It may not cause YAML errors, but debugging the exact execution behavior can be complex
due to the different default behaviors of `only/except` and `rules`.
- Rewrite the rules to run the job only in very specific cases,
and avoid using a final `when:` rule:
```yaml
job:
script: "echo This does NOT create double pipelines!"
rules:
- if: '$CUSTOM_VARIABLE == "true" && $CI_PIPELINE_SOURCE == "merge_request_event"'
```
You can prevent duplicate pipelines by changing the job rules to avoid either push (branch)
pipelines or merge request pipelines. However, if you use a `- when: always` rule without
`workflow: rules`, GitLab still displays a [pipeline warning](../troubleshooting.md#pipeline-warnings).
For example, the following does not trigger double pipelines, but is not recommended
without `workflow: rules`:
```yaml
job:
script: "echo This does NOT create double pipelines!"
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
when: never
- when: always
```
Do not include both push and merge request pipelines in the same job:
```yaml
job:
script: "echo This creates double pipelines!"
rules:
- if: '$CI_PIPELINE_SOURCE == "push"'
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
```
Also, do not mix `only/except` jobs with `rules` jobs in the same pipeline.
It may not cause YAML errors, but the different default behaviors of `only/except`
and `rules` can cause issues that are difficult to troubleshoot:
```yaml
job-with-no-rules:
script: "echo This job runs in branch pipelines."
job-with-rules:
script: "echo This job runs in merge request pipelines."
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
```
For every change pushed to the branch, duplicate pipelines run. One
branch pipeline runs a single job (`job-with-no-rules`), and one merge request pipeline
runs the other job (`job-with-rules`). Jobs with no rules default
to [`except: merge_requests`](#onlyexcept-basic), so `job-with-no-rules`
runs in all cases except merge requests.
It is not possible to define rules based on whether or not a branch has an open
merge request associated with it. You can't configure a job to be included in:
- Only branch pipelines when the branch doesn't have a merge request associated with it.
- Only merge request pipelines when the branch has a merge request associated with it.
See the [related issue](https://gitlab.com/gitlab-org/gitlab/-/issues/201845) for more details.
#### `rules:if`
......
......@@ -233,4 +233,4 @@ In the same code block, precede each with comments: `# Better` and `# Best`.
NOTE: **Note:**
While the bad-then-good approach is acceptable for the GitLab development guidelines, do not use it
for user documentation. For user documentation, use "Do" and "Don't." For example, see the
[Pajamas Design System](https://design.gitlab.com/content/punctuation).
[Pajamas Design System](https://design.gitlab.com/content/punctuation/).
......@@ -53,8 +53,8 @@ Examples of component classes that were created using "utility-first" include:
Inspiration:
- <https://tailwindcss.com/docs/utility-first/>
- <https://tailwindcss.com/docs/extracting-components/>
- <https://tailwindcss.com/docs/utility-first>
- <https://tailwindcss.com/docs/extracting-components>
### Naming
......
......@@ -285,7 +285,7 @@ describe('~/todos/app.vue', () => {
### Test the component's output
The main return value of a Vue component is the rendered output. In order to test the component we
need to test the rendered output. [Vue](https://vuejs.org/v2/guide/unit-testing.html) guide's to unit test show us exactly that:
need to test the rendered output. Visit the [Vue testing guide](https://vuejs.org/v2/guide/testing.html#Unit-Testing).
### Events
......
......@@ -10,7 +10,7 @@ Any frontend engineer can contribute to this dashboard. They can contribute by a
There are 3 recommended high impact metrics to review on each page:
- [First visual change](https://web.dev/first-meaningful-paint/)
- [Speed Index](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index)
- [Visual Complete 95%](https://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index)
- [Speed Index](https://github.com/WPO-Foundation/webpagetest-docs/blob/master/user/Metrics/SpeedIndex.md)
- [Visual Complete 95%](https://github.com/WPO-Foundation/webpagetest-docs/blob/master/user/Metrics/SpeedIndex.md)
For these metrics, lower numbers are better as it means that the website is more performant.
......@@ -26,7 +26,7 @@ You will need at least Maintainer [permissions](../user/permissions.md) to enabl
GitLab provides an easy way to connect Sentry to your project:
1. Sign up to Sentry.io or [deploy your own](#deploying-sentry) Sentry instance.
1. [Create](https://docs.sentry.io/guides/tutorials/integrate-frontend/create-new-project/) a new Sentry project. For each GitLab project that you want to integrate, we recommend that you create a new Sentry project.
1. [Create](https://docs.sentry.io/product/sentry-basics/guides/integrate-frontend/create-new-project/) a new Sentry project. For each GitLab project that you want to integrate, we recommend that you create a new Sentry project.
1. [Find or generate](https://docs.sentry.io/api/auth/) a Sentry auth token for your Sentry project.
Make sure to give the token at least the following scopes: `event:read` and `project:read`.
1. Navigate to your project’s **Settings > Operations**.
......@@ -40,7 +40,7 @@ GitLab provides an easy way to connect Sentry to your project:
### Enabling GitLab issues links
You may also want to enable Sentry's GitLab integration by following the steps in the [Sentry documentation](https://docs.sentry.io/workflow/integrations/gitlab/)
You may also want to enable Sentry's GitLab integration by following the steps in the [Sentry documentation](https://docs.sentry.io/product/integrations/gitlab/)
## Error Tracking List
......@@ -59,7 +59,7 @@ From error list, users can navigate to the error details page by clicking the ti
This page has:
- A link to the Sentry issue.
- A link to the GitLab commit if the Sentry [release ID/version](https://docs.sentry.io/workflow/releases/?platform=javascript#configure-sdk) on the Sentry Issue's first release matches a commit SHA in your GitLab hosted project.
- A link to the GitLab commit if the Sentry [release ID/version](https://docs.sentry.io/product/releases/?platform=javascript#configure-sdk) on the Sentry Issue's first release matches a commit SHA in your GitLab hosted project.
- Other details about the issue, including a full stack trace.
- In [GitLab 12.7 and newer](https://gitlab.com/gitlab-org/gitlab/-/issues/36246), language and urgency are displayed.
......
......@@ -1189,7 +1189,7 @@ server:
}
```
Once you have successfully installed Vault, you will need to [initialize the Vault](https://learn.hashicorp.com/vault/getting-started/deploy#initializing-the-vault)
Once you have successfully installed Vault, you will need to [initialize the Vault](https://learn.hashicorp.com/tutorials/vault/getting-started-deploy#initializing-the-vault)
and obtain the initial root token. You will need access to your Kubernetes cluster that Vault has been deployed into in order to do this.
To initialize the Vault, get a shell to one of the Vault pods running inside Kubernetes (typically this is done by using the `kubectl` command line tool).
Once you have a shell into the pod, run the `vault operator init` command:
......
......@@ -75,15 +75,15 @@ which means that the reported licenses might be incomplete or inaccurate.
| Language | Package managers | Scan Tool |
|------------|-------------------------------------------------------------------|----------------------------------------------------------|
| JavaScript | [yarn](https://yarnpkg.com/)|[License Finder](https://github.com/pivotal/LicenseFinder)|
| JavaScript | [Yarn](https://yarnpkg.com/)|[License Finder](https://github.com/pivotal/LicenseFinder)|
| Go | go get, gvt, glide, dep, trash, govendor |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Erlang | [rebar](https://www.rebar3.org/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Erlang | [Rebar](https://www.rebar3.org/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Objective-C, Swift | [CocoaPods](https://cocoapods.org/) v0.39 and below |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Elixir | [mix](https://elixir-lang.org/getting-started/mix-otp/introduction-to-mix.html) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| C++/C | [conan](https://conan.io/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Elixir | [Mix](https://elixir-lang.org/getting-started/mix-otp/introduction-to-mix.html) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| C++/C | [Conan](https://conan.io/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Scala | [sbt](https://www.scala-sbt.org/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Rust | [cargo](https://crates.io) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| PHP | [composer](https://getcomposer.org/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| Rust | [Cargo](https://crates.io) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| PHP | [Composer](https://getcomposer.org/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
## Requirements
......@@ -330,13 +330,13 @@ strict-ssl = false
### Configuring Yarn projects
You can configure Yarn projects by using a [`.yarnrc.yml`](https://yarnpkg.com/configuration/yarnrc)
You can configure Yarn projects by using a [`.yarnrc.yml`](https://yarnpkg.com/configuration/yarnrc/)
file.
#### Using private Yarn registries
If you have a private Yarn registry you can use the
[`npmRegistryServer`](https://yarnpkg.com/configuration/yarnrc#npmRegistryServer)
[`npmRegistryServer`](https://yarnpkg.com/configuration/yarnrc/#npmRegistryServer)
setting to specify its location.
For example:
......
......@@ -162,7 +162,7 @@ For more information, see our [discussion on providers](#providers).
Your identity provider may have relevant documentation. It may be generic SAML documentation, or specifically targeted for GitLab. Examples:
- [Auth0](https://auth0.com/docs/protocols/saml/saml-idp-generic)
- [Auth0](https://auth0.com/docs/protocols/saml-configuration-options/configure-auth0-as-saml-identity-provider)
- [G Suite](https://support.google.com/a/answer/6087519?hl=en)
- [JumpCloud](https://support.jumpcloud.com/support/s/article/single-sign-on-sso-with-gitlab-2019-08-21-10-36-47)
- [PingOne by Ping Identity](https://docs.pingidentity.com/bundle/pingone/page/xsh1564020480660-1.html)
......
......@@ -427,7 +427,7 @@ apply:
### Multiple Terraform Plan reports
Starting with 13.2, you can display mutiple reports on the Merge Request page. The reports will also display the `artifact: name:`. See example below for a suggested setup.
Starting with 13.2, you can display mutiple reports on the Merge Request page. The reports will also display the `artifacts: name:`. See example below for a suggested setup.
```yaml
image:
......
......@@ -7,7 +7,7 @@ NOTE: **Note:**
Your GitLab instance's [usage ping](../admin_area/settings/usage_statistics.md#usage-ping-core-only) must be activated in order to use this feature.
The DevOps Score gives you an overview of your entire instance's adoption of
[Concurrent DevOps](https://about.gitlab.com/concurrent-devops/)
[Concurrent DevOps](https://about.gitlab.com/topics/concurrent-devops/)
from planning to monitoring.
This displays the usage of these GitLab features over
......
......@@ -18,11 +18,11 @@ The Package Registry supports the following formats:
<tr style="background:#dfdfdf"><th>Package type</th><th>GitLab version</th></tr>
<tr><td><a href="https://docs.gitlab.com/ee/user/packages/composer_repository/index.html">Composer</a></td><td>13.2+</td></tr>
<tr><td><a href="https://docs.gitlab.com/ee/user/packages/conan_repository/index.html">Conan</a></td><td>12.6+</td></tr>
<tr><td><a href="http://docs.gitlab.com/ee/user/packages/go_proxy/index.html">Go</a></td><td>13.1+</td></tr>
<tr><td><a href="http://docs.gitlab.com/ee/user/packages/maven_repository/index.html">Maven</a></td><td>11.3+</td></tr>
<tr><td><a href="https://docs.gitlab.com/ee/user/packages/go_proxy/index.html">Go</a></td><td>13.1+</td></tr>
<tr><td><a href="https://docs.gitlab.com/ee/user/packages/maven_repository/index.html">Maven</a></td><td>11.3+</td></tr>
<tr><td><a href="https://docs.gitlab.com/ee/user/packages/npm_registry/index.html">NPM</a></td><td>11.7+</td></tr>
<tr><td><a href="http://docs.gitlab.com/ee/user/packages/nuget_repository/index.html">NuGet</a></td><td>12.8+</td></tr>
<tr><td><a href="http://docs.gitlab.com/ee/user/packages/pypi_repository/index.html">PyPI</a></td><td>12.10+</td></tr>
<tr><td><a href="https://docs.gitlab.com/ee/user/packages/nuget_repository/index.html">NuGet</a></td><td>12.8+</td></tr>
<tr><td><a href="https://docs.gitlab.com/ee/user/packages/pypi_repository/index.html">PyPI</a></td><td>12.10+</td></tr>
</table>
</div>
</div>
......
......@@ -84,7 +84,7 @@ merge-request-pipeline-job:
```
You should avoid configuration like this, and only use branch (`push`) pipelines
or merge request pipelines, when possible. See [`rules` documentation](../../../ci/yaml/README.md#differences-between-rules-and-onlyexcept)
or merge request pipelines, when possible. See [`rules` documentation](../../../ci/yaml/README.md#prevent-duplicate-pipelines)
for details on avoiding two pipelines for a single merge request.
### Skipped pipelines
......
......@@ -56,7 +56,7 @@ reiterating the importance of HTTPS.
## Issuing Certificates
GitLab Pages accepts certificates provided in the [PEM](https://support.quovadisglobal.com/kb/a37/what-is-pem-format.aspx) format, issued by
GitLab Pages accepts certificates provided in the [PEM](https://knowledge.digicert.com/quovadis) format, issued by
[Certificate Authorities](https://en.wikipedia.org/wiki/Certificate_authority) or as
[self-signed certificates](https://en.wikipedia.org/wiki/Self-signed_certificate). Note that [self-signed certificates are typically not used](https://www.mcafee.com/blogs/other-blogs/mcafee-labs/self-signed-certificates-secure-so-why-ban/)
for public websites for security reasons and to ensure that browsers trust your site's certificate.
......
......@@ -13,6 +13,7 @@ description: "The static site editor enables users to edit content on static web
> - Support for adding images through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216640) in GitLab 13.1.
> - Markdown front matter hidden on the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216834) in GitLab 13.1.
> - Support for `*.md.erb` files [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/223171) in GitLab 13.2.
> - Non-Markdown content blocks uneditable on the WYSIWYG mode [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216836) in GitLab 13.3.
DANGER: **Danger:**
In GitLab 13.0, we [introduced breaking changes](https://gitlab.com/gitlab-org/gitlab/-/issues/213282)
......@@ -57,7 +58,7 @@ When you click it, GitLab opens up an editor window from which the content
can be directly edited. When you're ready, you can submit your changes in a
click of a button:
![Static Site Editor](img/wysiwyg_editor_v13_0.png)
![Static Site Editor](img/wysiwyg_editor_v13_3.png)
When an editor submits their changes, in the background, GitLab automatically
creates a new branch, commits their changes, and opens a merge request. The
......
......@@ -25,7 +25,12 @@ export default {
rule: 'data',
}),
title() {
return this.rule ? __('Update approval rule') : __('Add approval rule');
return !this.rule || this.defaultRuleName
? __('Add approval rule')
: __('Update approval rule');
},
defaultRuleName() {
return this.rule?.defaultRuleName;
},
},
methods: {
......@@ -47,6 +52,11 @@ export default {
size="sm"
@ok.prevent="submit"
>
<rule-form ref="form" :init-rule="rule" :is-mr-edit="isMrEdit" />
<rule-form
ref="form"
:init-rule="rule"
:is-mr-edit="isMrEdit"
:default-rule-name="defaultRuleName"
/>
</gl-modal-vuex>
</template>
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapState, mapActions } from 'vuex';
import { n__, sprintf } from '~/locale';
import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import Rules from '../rules.vue';
import RuleControls from '../rule_controls.vue';
import EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue';
import UnconfiguredSecurityRules from '../security_configuration/unconfigured_security_rules.vue';
export default {
components: {
......@@ -17,7 +20,10 @@ export default {
EmptyRule,
RuleInput,
RuleBranches,
UnconfiguredSecurityRules,
},
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
mixins: [glFeatureFlagsMixin()],
computed: {
...mapState(['settings']),
...mapState({
......@@ -92,6 +98,7 @@ export default {
</script>
<template>
<div>
<rules :rules="rules">
<template #thead="{ name, members, approvalsRequired, branches }">
<tr class="d-none d-sm-table-row">
......@@ -117,7 +124,10 @@ export default {
/>
<tr v-else :key="index">
<td class="js-name">{{ rule.name }}</td>
<td class="js-members" :class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null">
<td
class="js-members"
:class="settings.allowMultiRule ? 'd-none d-sm-table-cell' : null"
>
<user-avatar-list :items="rule.approvers" :img-size="24" empty-text="" />
</td>
<td v-if="settings.allowMultiRule" class="js-branches">
......@@ -133,4 +143,7 @@ export default {
</template>
</template>
</rules>
<!-- TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114 -->
<unconfigured-security-rules v-if="glFeatures.approvalSuggestions" />
</div>
</template>
<script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapState, mapActions } from 'vuex';
import { groupBy, isNumber } from 'lodash';
import { sprintf, __ } from '~/locale';
......@@ -9,7 +10,8 @@ import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
const DEFAULT_NAME = 'Default';
const DEFAULT_NAME_FOR_LICENSE_REPORT = 'License-Check';
const READONLY_NAMES = [DEFAULT_NAME_FOR_LICENSE_REPORT];
const DEFAULT_NAME_FOR_VULNERABILITY_CHECK = 'Vulnerability-Check';
const READONLY_NAMES = [DEFAULT_NAME_FOR_LICENSE_REPORT, DEFAULT_NAME_FOR_VULNERABILITY_CHECK];
export default {
components: {
......@@ -17,6 +19,8 @@ export default {
ApproversSelect,
BranchesSelect,
},
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
mixins: [glFeatureFlagsMixin()],
props: {
initRule: {
type: Object,
......@@ -28,9 +32,14 @@ export default {
default: true,
required: false,
},
defaultRuleName: {
type: String,
required: false,
default: '',
},
},
data() {
return {
const defaults = {
name: '',
approvalsRequired: 1,
minApprovalsRequired: 0,
......@@ -43,9 +52,19 @@ export default {
containsHiddenGroups: false,
...this.getInitialData(),
};
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
if (this.glFeatures.approvalSuggestions) {
return { ...defaults, name: this.defaultRuleName || defaults.name };
}
return defaults;
},
computed: {
...mapState(['settings']),
rule() {
// If we are creating a new rule with a suggested approval name
return this.defaultRuleName ? null : this.initRule;
},
approversByType() {
return groupBy(this.approvers, x => x.type);
},
......@@ -132,6 +151,12 @@ export default {
return !this.settings.lockedApprovalsRuleName;
},
isNameDisabled() {
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
if (this.glFeatures.approvalSuggestions) {
return (
Boolean(this.isPersisted || this.defaultRuleName) && READONLY_NAMES.includes(this.name)
);
}
return this.isPersisted && READONLY_NAMES.includes(this.name);
},
removeHiddenGroups() {
......@@ -232,7 +257,7 @@ export default {
return this.isValid;
},
getInitialData() {
if (!this.initRule) {
if (!this.initRule || this.defaultRuleName) {
return {};
}
......@@ -309,7 +334,7 @@ export default {
v-model="branchesToAdd"
:project-id="settings.projectId"
:is-invalid="!!validation.branches"
:init-rule="initRule"
:init-rule="rule"
/>
<div class="invalid-feedback">{{ validation.branches }}</div>
</div>
......
<script>
import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
export default {
components: {
GlButton,
GlLink,
GlSprintf,
},
props: {
rule: {
type: Object,
required: true,
},
},
};
</script>
<template>
<tr>
<!-- Suggested approval rule creation row -->
<template v-if="rule.hasConfiguredJob">
<td class="js-name" colspan="4">
<div>{{ rule.name }}</div>
<div class="gl-text-gray-500">
<gl-sprintf :message="rule.enableDescription">
<template #link="{ content }">
<gl-link :href="rule.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</td>
<td class="gl-px-2! gl-text-right">
<gl-button @click="$emit('enable')">
{{ __('Enable') }}
</gl-button>
</td>
</template>
<!-- Approval rule suggestion when lacking appropriate CI job for the rule -->
<td v-else class="js-name" colspan="5">
<div>{{ rule.name }}</div>
<div class="gl-text-gray-500">
<gl-sprintf :message="rule.description">
<template #link="{ content }">
<gl-link :href="rule.docsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</td>
</tr>
</template>
<script>
import { camelCase } from 'lodash';
import { mapState, mapActions } from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
import { LICENSE_CHECK_NAME, VULNERABILITY_CHECK_NAME, JOB_TYPES } from 'ee/approvals/constants';
import { s__ } from '~/locale';
import UnconfiguredSecurityRule from './unconfigured_security_rule.vue';
export default {
components: {
UnconfiguredSecurityRule,
GlSkeletonLoading,
},
inject: {
vulnerabilityCheckHelpPagePath: {
from: 'vulnerabilityCheckHelpPagePath',
default: '',
},
licenseCheckHelpPagePath: {
from: 'licenseCheckHelpPagePath',
default: '',
},
},
featureTypes: {
vulnerabilityCheck: [
JOB_TYPES.SAST,
JOB_TYPES.DAST,
JOB_TYPES.DEPENDENCY_SCANNING,
JOB_TYPES.SECRET_DETECTION,
JOB_TYPES.COVERAGE_FUZZING,
],
licenseCheck: [JOB_TYPES.LICENSE_SCANNING],
},
computed: {
...mapState('securityConfiguration', ['configuration']),
...mapState({
rules: state => state.approvals.rules,
isApprovalsLoading: state => state.approvals.isLoading,
isSecurityConfigurationLoading: state => state.securityConfiguration.isLoading,
}),
isRulesLoading() {
return this.isApprovalsLoading || this.isSecurityConfigurationLoading;
},
securityRules() {
return [
{
name: VULNERABILITY_CHECK_NAME,
description: s__(
'SecurityApprovals|One or more of the security scanners must be enabled. %{linkStart}More information%{linkEnd}',
),
enableDescription: s__(
'SecurityApprovals|Requires approval for vulnerabilties of Critical, High, or Unknown severity. %{linkStart}More information%{linkEnd}',
),
docsPath: this.vulnerabilityCheckHelpPagePath,
},
{
name: LICENSE_CHECK_NAME,
description: s__(
'SecurityApprovals|License Scanning must be enabled. %{linkStart}More information%{linkEnd}',
),
enableDescription: s__(
'SecurityApprovals|Requires license policy rules for licenses of Allowed, or Denied. %{linkStart}More information%{linkEnd}',
),
docsPath: this.licenseCheckHelpPagePath,
},
];
},
unconfiguredRules() {
return this.securityRules.reduce((filtered, securityRule) => {
const hasApprovalRuleDefined = this.hasApprovalRuleDefined(securityRule);
const hasConfiguredJob = this.hasConfiguredJob(securityRule);
if (!hasApprovalRuleDefined || !hasConfiguredJob) {
filtered.push({ ...securityRule, hasConfiguredJob });
}
return filtered;
}, []);
},
},
created() {
this.fetchSecurityConfiguration();
},
methods: {
...mapActions('securityConfiguration', ['fetchSecurityConfiguration']),
...mapActions({ openCreateModal: 'createModal/open' }),
hasApprovalRuleDefined(matchRule) {
return this.rules.some(rule => {
return matchRule.name === rule.name;
});
},
hasConfiguredJob(matchRule) {
const { features = [] } = this.configuration;
return this.$options.featureTypes[camelCase(matchRule.name)].some(featureType => {
return features.some(feature => {
return feature.type === featureType && feature.configured;
});
});
},
},
};
</script>
<template>
<table class="table m-0">
<tbody>
<tr v-if="isRulesLoading">
<td colspan="3">
<gl-skeleton-loading :lines="3" />
</td>
</tr>
<unconfigured-security-rule
v-for="rule in unconfiguredRules"
v-else
:key="rule.name"
:rule="rule"
@enable="openCreateModal({ defaultRuleName: rule.name })"
/>
</tbody>
</table>
</template>
......@@ -14,6 +14,15 @@ export const RULE_NAME_ANY_APPROVER = 'All Members';
export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check';
export const LICENSE_CHECK_NAME = 'License-Check';
export const JOB_TYPES = {
SAST: 'sast',
DAST: 'dast',
DEPENDENCY_SCANNING: 'dependency_scanning',
SECRET_DETECTION: 'secret_detection',
COVERAGE_FUZZING: 'coverage_fuzzing',
LICENSE_SCANNING: 'license_scanning',
};
export const APPROVAL_RULE_CONFIGS = {
[VULNERABILITY_CHECK_NAME]: {
title: __('Vulnerability-Check'),
......
......@@ -12,6 +12,8 @@ export default function mountProjectSettingsApprovals(el) {
return null;
}
const { vulnerabilityCheckHelpPagePath, licenseCheckHelpPagePath } = el.dataset;
const store = createStore(projectSettingsModule(), {
...el.dataset,
prefix: 'project-settings',
......@@ -22,6 +24,10 @@ export default function mountProjectSettingsApprovals(el) {
return new Vue({
el,
store,
provide: {
vulnerabilityCheckHelpPagePath,
licenseCheckHelpPagePath,
},
render(h) {
return h(ProjectSettingsApp);
},
......
import Vuex from 'vuex';
import modalModule from '~/vuex_shared/modules/modal';
import securityConfigurationModule from 'ee/security_configuration/modules/configuration';
import state from './state';
export const createStoreOptions = (approvalsModule, settings) => ({
......@@ -8,6 +9,9 @@ export const createStoreOptions = (approvalsModule, settings) => ({
...(approvalsModule ? { approvals: approvalsModule } : {}),
createModal: modalModule(),
deleteModal: modalModule(),
securityConfiguration: securityConfigurationModule({
securityConfigurationPath: settings?.securityConfigurationPath || '',
}),
},
});
......
import ipaddr from 'ipaddr.js';
import validateIpAddress from 'ee/validators/ip_address';
import { s__ } from '~/locale';
const validateAddress = address => {
try {
// Checks if Valid IPv4/IPv6 (CIDR) - Throws if not
return Boolean(ipaddr.parseCIDR(address));
} catch (e) {
// Checks if Valid IPv4/IPv6 (Non-CIDR) - Does not Throw
return ipaddr.isValid(address);
}
};
const validateIP = data => {
let addresses = data.replace(/\s/g, '').split(',');
addresses = addresses.map(address => validateAddress(address));
addresses = addresses.map(address => validateIpAddress(address));
return !addresses.some(a => !a);
};
......
......@@ -2,7 +2,7 @@ import Vue from 'vue';
import { __, sprintf } from '~/locale';
import CommaSeparatedListTokenSelector from '../components/comma_separated_list_token_selector.vue';
export default (el, props = {}, qaSelector) => {
export default (el, props = {}, qaSelector, customValidator) => {
// eslint-disable-next-line no-new
new Vue({
el,
......@@ -20,8 +20,14 @@ export default (el, props = {}, qaSelector) => {
regexValidator,
...(regexValidator ? { regexValidator: new RegExp(regexValidator) } : {}),
...(disallowedValues ? { disallowedValues: JSON.parse(disallowedValues) } : {}),
customErrorMessage: '',
};
},
methods: {
handleTextInput(value) {
this.customErrorMessage = customValidator(value);
},
},
render(createElement) {
return createElement('comma-separated-list-token-selector', {
attrs: {
......@@ -32,8 +38,10 @@ export default (el, props = {}, qaSelector) => {
ariaLabelledby: this.labelId,
regexValidator: this.regexValidator,
disallowedValues: this.disallowedValues,
customErrorMessage: this.customErrorMessage,
...props,
},
on: customValidator ? { 'text-input': this.handleTextInput } : {},
scopedSlots: {
'user-defined-token-content': ({ inputText: value }) => {
return sprintf(__('Add "%{value}"'), { value });
......
import validateIpAddress from 'ee/validators/ip_address';
import { __, sprintf } from '~/locale';
export default address => {
if (!validateIpAddress(address)) {
return sprintf(__('%{address} is an invalid IP address range'), { address });
}
return '';
};
......@@ -30,7 +30,7 @@ export default {
required: false,
default: () => [],
},
errorMessage: {
regexErrorMessage: {
type: String,
required: false,
default: '',
......@@ -40,6 +40,11 @@ export default {
required: false,
default: '',
},
customErrorMessage: {
type: String,
required: false,
default: '',
},
},
data() {
return {
......@@ -54,14 +59,14 @@ export default {
},
computedErrorMessage() {
if (this.regexValidator !== null && this.textInputValue.match(this.regexValidator) === null) {
return this.errorMessage;
return this.regexErrorMessage;
}
if (!isEmpty(this.disallowedValues) && this.disallowedValues.includes(this.textInputValue)) {
return this.disallowedValueErrorMessage;
}
return '';
return this.customErrorMessage;
},
},
watch: {
......@@ -111,6 +116,7 @@ export default {
handleTextInput(value) {
this.hideErrorMessage = true;
this.textInputValue = value;
this.$emit('text-input', value);
},
handleBlur() {
this.hideErrorMessage = true;
......
import '~/pages/groups/edit';
import initAccessRestrictionField from 'ee/groups/settings/access_restriction_field';
import validateRestrictedIpAddress from 'ee/groups/settings/access_restriction_field/validate_ip_address';
import { __ } from '~/locale';
document.addEventListener('DOMContentLoaded', () => {
initAccessRestrictionField('.js-allowed-email-domains', {
placeholder: __('Enter domain'),
errorMessage: __('The domain you entered is misformatted.'),
regexErrorMessage: __('The domain you entered is misformatted.'),
disallowedValueErrorMessage: __('The domain you entered is not allowed.'),
});
initAccessRestrictionField(
'.js-ip-restriction',
{ placeholder: __('Enter IP address range') },
'ip_restriction_field',
validateRestrictedIpAddress,
);
});
......@@ -2,9 +2,7 @@ import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types';
export const setSecurityConfigurationEndpoint = ({ commit }, endpoint) =>
commit(types.SET_SECURITY_CONFIGURATION_ENDPOINT, endpoint);
// eslint-disable-next-line import/prefer-default-export
export const fetchSecurityConfiguration = ({ commit, state }) => {
if (!state.securityConfigurationPath) {
return commit(types.RECEIVE_SECURITY_CONFIGURATION_ERROR);
......
import state from './state';
import createState from './state';
import mutations from './mutations';
import * as actions from './actions';
export default {
export default ({ securityConfigurationPath = '' }) => ({
namespaced: true,
state,
state: createState({ securityConfigurationPath }),
mutations,
actions,
};
});
export const SET_SECURITY_CONFIGURATION_ENDPOINT = 'SET_SECURITY_CONFIGURATION_ENDPOINT';
export const REQUEST_SECURITY_CONFIGURATION = 'REQUEST_SECURITY_CONFIGURATION';
export const RECEIVE_SECURITY_CONFIGURATION_SUCCESS = 'RECEIVE_SECURITY_CONFIGURATION_SUCCESS';
export const RECEIVE_SECURITY_CONFIGURATION_ERROR = 'RECEIVE_SECURITY_CONFIGURATION_ERROR';
import * as types from './mutation_types';
export default {
[types.SET_SECURITY_CONFIGURATION_ENDPOINT](state, payload) {
state.securityConfigurationPath = payload;
},
[types.REQUEST_SECURITY_CONFIGURATION](state) {
state.isLoading = true;
state.errorLoading = false;
......
export default () => ({
securityConfigurationPath: '',
export default ({ securityConfigurationPath }) => ({
securityConfigurationPath,
isLoading: false,
errorLoading: false,
configuration: {},
......
import ipaddr from 'ipaddr.js';
export default address => {
// Reject IP addresses that are only integers to match Ruby IPAddr
// https://github.com/whitequark/ipaddr.js/issues/7#issuecomment-158545695
if (/^\d+$/.exec(address)) {
return false;
}
try {
// Checks if Valid IPv4/IPv6 (CIDR) - Throws if not
return Boolean(ipaddr.parseCIDR(address));
} catch (e) {
// Checks if Valid IPv4/IPv6 (Non-CIDR) - Does not Throw
return ipaddr.isValid(address);
}
};
import CEGetStateKey from '~/vue_merge_request_widget/stores/get_state_key';
import { stateKey } from './state_maps';
export default function(data) {
export default function() {
if (this.isGeoSecondaryNode) {
return 'geoSecondaryNode';
}
if (data.policy_violation) {
if (this.policyViolation) {
return stateKey.policyViolation;
}
return CEGetStateKey.call(this, data);
return CEGetStateKey.call(this);
}
......@@ -44,6 +44,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled);
this.mergeTrainsCount = data.merge_trains_count || 0;
this.mergeTrainIndex = data.merge_train_index;
this.policyViolation = data.policy_violation;
super.setData(data, isRebased);
}
......
......@@ -94,6 +94,24 @@ module EE
{ date: date }
end
def approvals_app_data(project = @project)
{ data: { 'project_id': project.id,
'can_edit': can_modify_approvers.to_s,
'project_path': expose_path(api_v4_projects_path(id: project.id)),
'settings_path': expose_path(api_v4_projects_approval_settings_path(id: project.id)),
'rules_path': expose_path(api_v4_projects_approval_settings_rules_path(id: project.id)),
'allow_multi_rule': project.multiple_approval_rules_available?.to_s,
'eligible_approvers_docs_path': help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers'),
'security_approvals_help_page_path': help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate'),
'security_configuration_path': project_security_configuration_path(project),
'vulnerability_check_help_page_path': help_page_path('user/application_security/index', anchor: 'enabling-security-approvals-within-a-project'),
'license_check_help_page_path': help_page_path('user/application_security/index', anchor: 'enabling-license-approvals-within-a-project') } }
end
def can_modify_approvers(project = @project)
can?(current_user, :modify_approvers_rules, project)
end
def permanent_delete_message(project)
message = _('This action will %{strongOpen}permanently delete%{strongClose} %{codeOpen}%{project}%{codeClose} %{strongOpen}immediately%{strongClose}, including its repositories and all content: issues, merge requests, etc.')
html_escape(message) % remove_message_data(project)
......
- can_override_approvers = project.can_override_approvers?
- can_modify_approvers = can?(current_user, :modify_approvers_rules, @project)
- can_modify_merge_request_author_settings = can?(current_user, :modify_merge_request_author_setting, @project)
- can_modify_merge_request_committer_settings = can?(current_user, :modify_merge_request_committer_setting, @project)
.form-group
= form.label :approver_ids, class: 'label-bold' do
= _("Approval rules")
#js-mr-approvals-settings{ data: { 'project_id': @project.id,
'can_edit': can_modify_approvers.to_s,
'project_path': expose_path(api_v4_projects_path(id: @project.id)),
'settings_path': expose_path(api_v4_projects_approval_settings_path(id: @project.id)),
'rules_path': expose_path(api_v4_projects_approval_settings_rules_path(id: @project.id)),
'allow_multi_rule': @project.multiple_approval_rules_available?.to_s,
'eligible_approvers_docs_path': help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers'),
'security_approvals_help_page_path': help_page_path('user/application_security/index.md', anchor: 'security-approvals-in-merge-requests-ultimate')} }
#js-mr-approvals-settings{ approvals_app_data }
.text-center.gl-mt-3
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner')
......
- page_title _('Security Dashboard - Settings')
- @hide_breadcrumbs = true
- page_title _('Settings')
#js-security
- page_title _('Security Dashboard')
- @hide_breadcrumbs = true
#js-security{ data: instance_security_dashboard_data }
---
title: Add frontend validation to "Restrict access by IP address" field
merge_request: 39061
author:
type: changed
---
title: Enable breadcrumbs in security dashboard
merge_request: 39635
author:
type: added
---
title: Ensure secondary is enabled on failover
merge_request: 39615
author:
type: fixed
......@@ -17,6 +17,21 @@ module Gitlab
end
end
def set_secondary_as_primary
ActiveRecord::Base.transaction do
primary_node = GeoNode.primary_node
current_node = GeoNode.current_node
abort 'The primary is not set' unless primary_node
abort 'This is not a secondary node' unless current_node.secondary?
primary_node.destroy
current_node.update!(primary: true, enabled: true)
$stdout.puts "#{current_node.url} is now the primary Geo node".color(:green)
end
end
def update_primary_geo_node_url
node = Gitlab::Geo.primary_node
......
......@@ -190,23 +190,7 @@ namespace :geo do
task set_secondary_as_primary: :environment do
abort GEO_LICENSE_ERROR_TEXT unless Gitlab::Geo.license_allows?
ActiveRecord::Base.transaction do
primary_node = GeoNode.primary_node
unless primary_node
abort 'The primary is not set'
end
primary_node.destroy
current_node = GeoNode.current_node
unless current_node.secondary?
abort 'This is not a secondary node'
end
current_node.update!(primary: true)
end
Gitlab::Geo::GeoTasks.set_secondary_as_primary
end
desc 'GitLab | Geo | Update Geo primary node URL'
......
......@@ -14,6 +14,11 @@ localVue.use(Vuex);
describe('Approvals ModalRuleCreate', () => {
let createModalState;
let wrapper;
let modal;
let form;
const findModal = () => wrapper.find(GlModalVuex);
const findForm = () => wrapper.find(RuleForm);
const factory = (options = {}) => {
const store = new Vuex.Store({
......@@ -49,15 +54,13 @@ describe('Approvals ModalRuleCreate', () => {
describe('without data', () => {
beforeEach(() => {
createModalState.data = null;
factory();
modal = findModal();
form = findForm();
});
it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true);
expect(modal.props('modalModule')).toEqual(MODAL_MODULE);
expect(modal.props('modalId')).toEqual(TEST_MODAL_ID);
expect(modal.attributes('title')).toEqual('Add approval rule');
......@@ -65,22 +68,12 @@ describe('Approvals ModalRuleCreate', () => {
});
it('renders form', () => {
factory();
const modal = wrapper.find(GlModalVuex);
const form = modal.find(RuleForm);
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(null);
});
it('when modal emits ok, submits form', () => {
factory();
const form = wrapper.find(RuleForm);
form.vm.submit = jest.fn();
const modal = wrapper.find(GlModalVuex);
modal.vm.$emit('ok', new Event('ok'));
expect(form.vm.submit).toHaveBeenCalled();
......@@ -90,27 +83,50 @@ describe('Approvals ModalRuleCreate', () => {
describe('with data', () => {
beforeEach(() => {
createModalState.data = TEST_RULE;
factory();
modal = findModal();
form = findForm();
});
it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true);
expect(modal.attributes('title')).toEqual('Update approval rule');
expect(modal.attributes('ok-title')).toEqual('Update approval rule');
});
it('renders form', () => {
factory();
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(TEST_RULE);
});
});
describe('with approvalSuggestions feature flag', () => {
beforeEach(() => {
createModalState.data = { ...TEST_RULE, defaultRuleName: 'Vulnerability-Check' };
factory({
provide: {
glFeatures: { approvalSuggestions: true },
},
});
modal = findModal();
form = findForm();
});
const modal = wrapper.find(GlModalVuex);
const form = modal.find(RuleForm);
it('renders add rule modal', () => {
expect(modal.exists()).toBe(true);
expect(modal.attributes('title')).toEqual('Add approval rule');
expect(modal.attributes('ok-title')).toEqual('Add approval rule');
});
it('renders form with defaultRuleName', () => {
expect(form.props().defaultRuleName).toBe('Vulnerability-Check');
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(TEST_RULE);
});
it('renders the form when passing in an existing rule', () => {
expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(createModalState.data);
});
});
});
......@@ -5,6 +5,7 @@ import projectSettingsModule from 'ee/approvals/stores/modules/project_settings'
import ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleInput from 'ee/approvals/components/mr_edit/rule_input.vue';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import UnconfiguredSecurityRules from 'ee/approvals/components/security_configuration/unconfigured_security_rules.vue';
import { createProjectRules } from '../../mocks';
const TEST_RULES = createProjectRules();
......@@ -29,11 +30,12 @@ describe('Approvals ProjectRules', () => {
let wrapper;
let store;
const factory = (props = {}) => {
const factory = (props = {}, options = {}) => {
wrapper = mount(localVue.extend(ProjectRules), {
propsData: props,
store: new Vuex.Store(store),
localVue,
...options,
});
};
......@@ -121,5 +123,38 @@ describe('Approvals ProjectRules', () => {
expect(nameCell.find('.js-help').exists()).toBeFalsy();
});
it('should not render the unconfigured-security-rules component', () => {
expect(wrapper.contains(UnconfiguredSecurityRules)).toBe(false);
});
});
describe.each([true, false])(
'when the approvalSuggestions feature flag is %p',
approvalSuggestions => {
beforeEach(() => {
const rules = createProjectRules();
rules[0].name = 'Vulnerability-Check';
store.modules.approvals.state.rules = rules;
store.state.settings.allowMultiRule = true;
});
beforeEach(() => {
factory(
{},
{
provide: {
glFeatures: { approvalSuggestions },
},
},
);
});
it(`should ${
approvalSuggestions ? '' : 'not'
} render the unconfigured-security-rules component`, () => {
expect(wrapper.contains(UnconfiguredSecurityRules)).toBe(approvalSuggestions);
});
},
);
});
......@@ -39,13 +39,13 @@ describe('EE Approvals RuleForm', () => {
let store;
let actions;
const createComponent = (props = {}) => {
const createComponent = (props = {}, options = {}) => {
wrapper = shallowMount(localVue.extend(RuleForm), {
propsData: props,
store: new Vuex.Store(store),
localVue,
provide: {
glFeatures: { scopedApprovalRules: true },
glFeatures: { scopedApprovalRules: true, ...options.provide?.glFeatures },
},
});
};
......@@ -482,6 +482,38 @@ describe('EE Approvals RuleForm', () => {
});
});
describe('with approvalSuggestions enabled', () => {
describe.each`
defaultRuleName | expectedDisabledAttribute
${'Vulnerability-Check'} | ${'disabled'}
${'License-Check'} | ${'disabled'}
${'Foo Bar Baz'} | ${undefined}
`(
'with defaultRuleName set to $defaultRuleName',
({ defaultRuleName, expectedDisabledAttribute }) => {
beforeEach(() => {
createComponent(
{
initRule: null,
defaultRuleName,
},
{
provide: {
glFeatures: { approvalSuggestions: true },
},
},
);
});
it(`it ${
expectedDisabledAttribute ? 'disables' : 'does not disable'
} the name text field`, () => {
expect(findNameInput().attributes('disabled')).toBe(expectedDisabledAttribute);
});
},
);
});
describe('with new License-Check rule', () => {
beforeEach(() => {
createComponent({
......@@ -494,6 +526,18 @@ describe('EE Approvals RuleForm', () => {
});
});
describe('with new Vulnerability-Check rule', () => {
beforeEach(() => {
createComponent({
initRule: { ...TEST_RULE, id: null, name: 'Vulnerability-Check' },
});
});
it('does not disable the name text field', () => {
expect(findNameInput().attributes('disabled')).toBe(undefined);
});
});
describe('with editing the License-Check rule', () => {
beforeEach(() => {
createComponent({
......@@ -505,6 +549,18 @@ describe('EE Approvals RuleForm', () => {
expect(findNameInput().attributes('disabled')).toBe('disabled');
});
});
describe('with editing the Vulnerability-Check rule', () => {
beforeEach(() => {
createComponent({
initRule: { ...TEST_RULE, name: 'Vulnerability-Check' },
});
});
it('disables the name text field', () => {
expect(findNameInput().attributes('disabled')).toBe('disabled');
});
});
});
describe('when allow only single rule', () => {
......
import Vuex from 'vuex';
import { LICENSE_CHECK_NAME, VULNERABILITY_CHECK_NAME } from 'ee/approvals/constants';
import UnconfiguredSecurityRule from 'ee/approvals/components/security_configuration/unconfigured_security_rule.vue';
import { mount, createLocalVue } from '@vue/test-utils';
import { GlSprintf, GlButton } from '@gitlab/ui';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('UnconfiguredSecurityRule component', () => {
let wrapper;
let description;
const findDescription = () => wrapper.find(GlSprintf);
const findButton = () => wrapper.find(GlButton);
const vulnCheckRule = {
name: VULNERABILITY_CHECK_NAME,
description: 'vuln-check description without enable button',
enableDescription: 'vuln-check description with enable button',
docsPath: 'docs/vuln-check',
};
const licenseCheckRule = {
name: LICENSE_CHECK_NAME,
description: 'license-check description without enable button',
enableDescription: 'license-check description with enable button',
docsPath: 'docs/license-check',
};
const createWrapper = (props = {}, options = {}) => {
wrapper = mount(UnconfiguredSecurityRule, {
localVue,
propsData: {
...props,
},
...options,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
rule | ruleName | descriptionText
${licenseCheckRule} | ${licenseCheckRule.name} | ${licenseCheckRule.enableDescription}
${vulnCheckRule} | ${vulnCheckRule.name} | ${vulnCheckRule.enableDescription}
`('with a configured job that is eligible for $ruleName', ({ rule, descriptionText }) => {
beforeEach(() => {
createWrapper({
rule: { ...rule, hasConfiguredJob: true },
});
description = findDescription();
});
it('should render the row with the enable decription and enable button', () => {
expect(description.exists()).toBe(true);
expect(description.text()).toBe(descriptionText);
expect(findButton().exists()).toBe(true);
});
it('should emit the "enable" event when the button is clicked', () => {
findButton().trigger('click');
expect(wrapper.emitted('enable')).toEqual([[]]);
});
});
describe.each`
rule | ruleName | descriptionText
${licenseCheckRule} | ${licenseCheckRule.name} | ${licenseCheckRule.description}
${vulnCheckRule} | ${vulnCheckRule.name} | ${vulnCheckRule.description}
`('with a unconfigured job that is eligible for $ruleName', ({ rule, descriptionText }) => {
beforeEach(() => {
createWrapper({
rule: { ...rule, hasConfiguredJob: false },
});
description = findDescription();
});
it('should render the row with the decription and no button', () => {
expect(description.exists()).toBe(true);
expect(description.text()).toBe(descriptionText);
expect(findButton().exists()).toBe(false);
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlSkeletonLoading } from '@gitlab/ui';
import UnconfiguredSecurityRules from 'ee/approvals/components/security_configuration/unconfigured_security_rules.vue';
import UnconfiguredSecurityRule from 'ee/approvals/components/security_configuration/unconfigured_security_rule.vue';
import { createStoreOptions } from 'ee/approvals/stores';
import projectSettingsModule from 'ee/approvals/stores/modules/project_settings';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('UnconfiguredSecurityRules component', () => {
let wrapper;
let store;
const TEST_PROJECT_ID = '7';
const createWrapper = (props = {}) => {
wrapper = shallowMount(UnconfiguredSecurityRules, {
localVue,
store,
propsData: {
...props,
},
provide: {
vulnerabilityCheckHelpPagePath: '',
licenseCheckHelpPagePath: '',
},
});
};
beforeEach(() => {
store = new Vuex.Store(
createStoreOptions(projectSettingsModule(), { projectId: TEST_PROJECT_ID }),
);
jest.spyOn(store, 'dispatch');
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when created ', () => {
beforeEach(() => {
createWrapper();
});
it('should fetch the security configuration', () => {
expect(store.dispatch).toHaveBeenCalledWith(
'securityConfiguration/fetchSecurityConfiguration',
undefined,
);
});
it('should render a unconfigured-security-rule component for every security rule ', () => {
expect(wrapper.findAll(UnconfiguredSecurityRule).length).toBe(2);
});
});
describe.each`
approvalsLoading | securityConfigurationLoading | shouldRender
${false} | ${false} | ${false}
${true} | ${false} | ${true}
${false} | ${true} | ${true}
${true} | ${true} | ${true}
`(
'while approvalsLoading is $approvalsLoading and securityConfigurationLoading is $securityConfigurationLoading',
({ approvalsLoading, securityConfigurationLoading, shouldRender }) => {
beforeEach(() => {
createWrapper();
store.state.approvals.isLoading = approvalsLoading;
store.state.securityConfiguration.isLoading = securityConfigurationLoading;
});
it(`should ${shouldRender ? '' : 'not'} render the loading skeleton`, () => {
expect(wrapper.contains(GlSkeletonLoading)).toBe(shouldRender);
});
},
);
});
import ipaddr from 'ipaddr.js';
import validateRestrictedIpAddress from 'ee/groups/settings/access_restriction_field/validate_ip_address';
describe('validateRestrictedIpAddress', () => {
describe('when IP address is not valid', () => {
it('returns an error message', () => {
ipaddr.isValid = jest.fn(() => false);
const result = validateRestrictedIpAddress('foo bar');
expect(ipaddr.isValid).toHaveBeenCalledWith('foo bar');
expect(result).toBe(`foo bar is an invalid IP address range`);
});
});
describe('when IP address is valid', () => {
it('returns an empty string', () => {
ipaddr.isValid = jest.fn(() => true);
const result = validateRestrictedIpAddress('192.168.0.0');
expect(ipaddr.isValid).toHaveBeenCalledWith('192.168.0.0');
expect(result).toBe('');
});
});
});
......@@ -12,13 +12,16 @@ describe('CommaSeparatedListTokenSelector', () => {
const defaultProps = {
hiddenInputId: 'comma-separated-list',
ariaLabelledby: 'comma-separated-list-label',
errorMessage: 'The value entered is invalid',
regexErrorMessage: 'The value entered is invalid',
disallowedValueErrorMessage: 'The value entered is not allowed',
};
const createComponent = options => {
wrapper = mount(CommaSeparatedListTokenSelector, {
attachTo: div,
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
...options,
propsData: {
...defaultProps,
......@@ -132,6 +135,16 @@ describe('CommaSeparatedListTokenSelector', () => {
});
});
describe('when text input is typed in', () => {
it('emits `text-input` event', async () => {
createComponent();
await setTokenSelectorInputValue('foo bar');
expect(wrapper.emitted('text-input')[0]).toEqual(['foo bar']);
});
});
describe('when enter key is pressed', () => {
it('does not submit the form if token selector text input has a value', async () => {
createComponent();
......@@ -145,7 +158,7 @@ describe('CommaSeparatedListTokenSelector', () => {
});
describe('when `regexValidator` prop is set', () => {
it('displays `errorMessage` if regex fails', async () => {
it('displays `regexErrorMessage` if regex fails', async () => {
createComponent({
propsData: {
regexValidator: /baz/,
......@@ -176,8 +189,22 @@ describe('CommaSeparatedListTokenSelector', () => {
});
});
describe('when `regexValidator` and `disallowedValues` props are set', () => {
it('displays `errorMessage` if regex fails', async () => {
describe('when `customErrorMessage` prop is set', () => {
it('displays `customErrorMessage`', () => {
createComponent({
propsData: {
customErrorMessage: 'Value is invalid',
},
});
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('Value is invalid');
});
});
describe('when `regexValidator`, `disallowedValues` and `customErrorMessage` props are set', () => {
it('displays `regexErrorMessage` if regex fails', async () => {
createComponent({
propsData: {
regexValidator: /baz/,
......@@ -206,6 +233,22 @@ describe('CommaSeparatedListTokenSelector', () => {
expect(findErrorMessageText()).toBe('The value entered is not allowed');
});
it('displays `customErrorMessage` if regex passes and value is not in the disallowed list', async () => {
createComponent({
propsData: {
regexValidator: /foo bar/,
disallowedValues: ['foo', 'bar', 'baz'],
customErrorMessage: 'Value is invalid',
},
});
await setTokenSelectorInputValue('foo bar');
tokenSelectorTriggerEnter();
expect(findErrorMessageText()).toBe('Value is invalid');
});
});
});
......@@ -216,38 +259,21 @@ describe('CommaSeparatedListTokenSelector', () => {
regexValidator: /foo/,
disallowedValues: ['bar', 'baz'],
},
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
});
await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter();
expect(
findTokenSelector()
.find('[role="menuitem"]')
.text(),
).toBe('Add "foo"');
expect(findTokenSelectorDropdown().text()).toBe('Add "foo"');
});
});
describe('when `regexValidator` and `disallowedValues` props are not set', () => {
describe('when `regexValidator`, `disallowedValues` and `customErrorMessage` props are not set', () => {
it('allows any value to be added', async () => {
createComponent({
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
});
createComponent();
await setTokenSelectorInputValue('foo');
expect(
findTokenSelector()
.find('[role="menuitem"]')
.text(),
).toBe('Add "foo"');
expect(findTokenSelectorDropdown().text()).toBe('Add "foo"');
});
});
......
......@@ -11,25 +11,8 @@ describe('security configuration module actions', () => {
let state;
beforeEach(() => {
state = createState();
});
describe('setSecurityConfigurationEndpoint', () => {
const securityConfigurationPath = 123;
it('should commit the SET_SECURITY_CONFIGURATION_ENDPOINT mutation', async () => {
await testAction(
actions.setSecurityConfigurationEndpoint,
securityConfigurationPath,
state,
[
{
type: types.SET_SECURITY_CONFIGURATION_ENDPOINT,
payload: securityConfigurationPath,
},
],
[],
);
state = createState({
securityConfigurationPath: `${TEST_HOST}/-/security/configuration.json`,
});
});
......@@ -38,7 +21,6 @@ describe('security configuration module actions', () => {
const configuration = {};
beforeEach(() => {
state.securityConfigurationPath = `${TEST_HOST}/-/security/configuration.json`;
mock = new MockAdapter(axios);
});
......
......@@ -8,15 +8,6 @@ describe('security configuration module mutations', () => {
state = {};
});
describe('SET_SECURITY_CONFIGURATION_ENDPOINT', () => {
const securityConfigurationPath = 123;
it(`should set the securityConfigurationPath to ${securityConfigurationPath}`, () => {
mutations[types.SET_SECURITY_CONFIGURATION_ENDPOINT](state, securityConfigurationPath);
expect(state.securityConfigurationPath).toBe(securityConfigurationPath);
});
});
describe('REQUEST_SECURITY_CONFIGURATION', () => {
it('should set the isLoading to true', () => {
mutations[types.REQUEST_SECURITY_CONFIGURATION](state);
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment