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 { ...@@ -15,7 +15,7 @@ export default {
canDelete: { canDelete: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false, default: true,
}, },
showDelete: { showDelete: {
type: Boolean, type: Boolean,
......
...@@ -138,6 +138,11 @@ export default { ...@@ -138,6 +138,11 @@ export default {
</script> </script>
<template> <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 <gl-new-dropdown
v-gl-tooltip v-gl-tooltip
data-testid="actions-menu" data-testid="actions-menu"
......
...@@ -394,15 +394,21 @@ export default { ...@@ -394,15 +394,21 @@ export default {
data-qa-selector="prometheus_graph_widgets" data-qa-selector="prometheus_graph_widgets"
> >
<div data-testid="dropdown-wrapper" class="d-flex align-items-center"> <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 <gl-dropdown
v-gl-tooltip v-gl-tooltip
toggle-class="shadow-none border-0" toggle-class="gl-px-3!"
no-caret
data-qa-selector="prometheus_widgets_dropdown" data-qa-selector="prometheus_widgets_dropdown"
right right
:title="__('More actions')" :title="__('More actions')"
> >
<template slot="button-content"> <template #button-content>
<gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" /> <gl-icon class="gl-mr-0!" name="ellipsis_v" />
</template> </template>
<gl-dropdown-item <gl-dropdown-item
v-if="expandBtnAvailable" v-if="expandBtnAvailable"
......
<script> <script>
import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui'; import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; 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 DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue'; import DagAnnotations from './dag_annotations.vue';
import { import {
...@@ -27,22 +28,57 @@ export default { ...@@ -27,22 +28,57 @@ export default {
GlEmptyState, GlEmptyState,
GlButton, GlButton,
}, },
props: { inject: {
graphUrl: { dagDocPath: {
type: String, default: null,
required: false,
default: '',
}, },
emptySvgPath: { emptySvgPath: {
type: String,
required: true,
default: '', default: '',
}, },
dagDocPath: { pipelineIid: {
type: String,
required: true,
default: '', 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() { data() {
return { return {
...@@ -90,31 +126,19 @@ export default { ...@@ -90,31 +126,19 @@ export default {
default: default:
return { return {
text: this.$options.errorTexts[DEFAULT], text: this.$options.errorTexts[DEFAULT],
vatiant: 'danger', variant: 'danger',
}; };
} }
}, },
processedData() {
return this.processGraphData(this.graphData);
},
shouldDisplayAnnotations() { shouldDisplayAnnotations() {
return !isEmpty(this.annotationsMap); return !isEmpty(this.annotationsMap);
}, },
shouldDisplayGraph() { 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: { methods: {
addAnnotationToMap({ uid, source, target }) { addAnnotationToMap({ uid, source, target }) {
...@@ -124,25 +148,25 @@ export default { ...@@ -124,25 +148,25 @@ export default {
let parsed; let parsed;
try { try {
parsed = parseData(data.stages); parsed = parseData(data);
} catch { } catch {
this.reportFailure(PARSE_FAILURE); this.reportFailure(PARSE_FAILURE);
return; return {};
} }
if (parsed.links.length === 1) { if (parsed.links.length === 1) {
this.reportFailure(UNSUPPORTED_DATA); this.reportFailure(UNSUPPORTED_DATA);
return; return {};
} }
// If there are no links, we don't report failure // If there are no links, we don't report failure
// as it simply means the user does not use job dependencies // as it simply means the user does not use job dependencies
if (parsed.links.length === 0) { if (parsed.links.length === 0) {
this.hasNoDependentJobs = true; this.hasNoDependentJobs = true;
return; return {};
} }
this.graphData = parsed; return parsed;
}, },
hideAlert() { hideAlert() {
this.showFailureAlert = false; this.showFailureAlert = false;
...@@ -182,7 +206,7 @@ export default { ...@@ -182,7 +206,7 @@ export default {
<dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" />
<dag-graph <dag-graph
v-if="shouldDisplayGraph" v-if="shouldDisplayGraph"
:graph-data="graphData" :graph-data="processedData"
@onFailure="reportFailure" @onFailure="reportFailure"
@update-annotation="updateAnnotation" @update-annotation="updateAnnotation"
/> />
...@@ -209,7 +233,7 @@ export default { ...@@ -209,7 +233,7 @@ export default {
</p> </p>
</div> </div>
</template> </template>
<template #actions> <template v-if="dagDocPath" #actions>
<gl-button :href="dagDocPath" target="__blank" variant="success"> <gl-button :href="dagDocPath" target="__blank" variant="success">
{{ $options.emptyStateTexts.button }} {{ $options.emptyStateTexts.button }}
</gl-button> </gl-button>
......
...@@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash'; ...@@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash';
received from the endpoint into the format the d3 graph expects. received from the endpoint into the format the d3 graph expects.
Input is of the form: Input is of the form:
[stages] [nodes]
stages: {name, groups} nodes: [{category, name, jobs, size}]
groups: [{ name, size, jobs }] category is the stage name
name is a group name; in the case that the group has one job, it is name is a group name; in the case that the group has one job, it is
also the job name also the job name
size is the number of parallel jobs size is the number of parallel jobs
jobs: [{ name, needs}] jobs: [{ name, needs}]
job name is either the same as the group name or group x/y 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: Output is of the form:
{ nodes: [node], links: [link] } { nodes: [node], links: [link] }
...@@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash'; ...@@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash';
link: { source, target, value }, with source & target being node names link: { source, target, value }, with source & target being node names
and value being a constant 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 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. 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 CREATE LINKS
stages.groups.name -> target nodes.name -> target
stages.groups.needs.each -> source (source is the name of the group, not the parallel job) nodes.name.needs.each -> source (source is the name of the group, not the parallel job)
10 -> value (constant) 10 -> value (constant)
*/ */
export const createNodes = data => {
return data.flatMap(({ groups, name }) => {
return groups.map(group => {
return { ...group, category: name };
});
});
};
export const createNodeDict = nodes => { export const createNodeDict = nodes => {
return nodes.reduce((acc, node) => { return nodes.reduce((acc, node) => {
const newNode = { const newNode = {
...@@ -62,13 +51,6 @@ export const createNodeDict = nodes => { ...@@ -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) => { export const makeLinksFromNodes = (nodes, nodeDict) => {
const constantLinkValue = 10; // all links are the same weight const constantLinkValue = 10; // all links are the same weight
return nodes return nodes
...@@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) => ...@@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) =>
return !allAncestors.includes(source); return !allAncestors.includes(source);
}); });
export const parseData = data => { export const parseData = nodes => {
const { nodes, nodeDict } = createNodesStructure(data); const nodeDict = createNodeDict(nodes);
const allLinks = makeLinksFromNodes(nodes, nodeDict); const allLinks = makeLinksFromNodes(nodes, nodeDict);
const filteredLinks = filterByAncestors(allLinks, nodeDict); const filteredLinks = filterByAncestors(allLinks, nodeDict);
const links = uniqWith(filteredLinks, isEqual); 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'; ...@@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue'; 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 GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator'; import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue'; import pipelineHeader from './components/header_component.vue';
...@@ -114,32 +114,6 @@ const createTestDetails = () => { ...@@ -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 () => { export default () => {
const { dataset } = document.querySelector('.js-pipeline-details-vue'); const { dataset } = document.querySelector('.js-pipeline-details-vue');
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); 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 { ...@@ -14,9 +14,6 @@ import {
SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR,
SNIPPET_BLOB_ACTION_CREATE,
SNIPPET_BLOB_ACTION_UPDATE,
SNIPPET_BLOB_ACTION_MOVE,
} from '../constants'; } from '../constants';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue'; import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
...@@ -56,25 +53,20 @@ export default { ...@@ -56,25 +53,20 @@ export default {
}, },
data() { data() {
return { return {
blobsActions: {},
isUpdating: false, isUpdating: false,
newSnippet: false, newSnippet: false,
actions: [],
}; };
}, },
computed: { computed: {
getActionsEntries() { hasBlobChanges() {
return Object.values(this.blobsActions); return this.actions.length > 0;
}, },
allBlobsHaveContent() { hasValidBlobs() {
const entries = this.getActionsEntries; return this.actions.every(x => x.filePath && x.content);
return entries.length > 0 && !entries.find(action => !action.content);
},
allBlobChangesRegistered() {
const entries = this.getActionsEntries;
return entries.length > 0 && !entries.find(action => action.action === '');
}, },
updatePrevented() { updatePrevented() {
return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating; return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating;
}, },
isProjectSnippet() { isProjectSnippet() {
return Boolean(this.projectPath); return Boolean(this.projectPath);
...@@ -85,7 +77,7 @@ export default { ...@@ -85,7 +77,7 @@ export default {
title: this.snippet.title, title: this.snippet.title,
description: this.snippet.description, description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel, visibilityLevel: this.snippet.visibilityLevel,
blobActions: this.getActionsEntries.filter(entry => entry.action !== ''), blobActions: this.actions,
}; };
}, },
saveButtonLabel() { saveButtonLabel() {
...@@ -120,48 +112,11 @@ export default { ...@@ -120,48 +112,11 @@ export default {
onBeforeUnload(e = {}) { onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?'); 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 }); Object.assign(e, { returnValue });
return 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) { flashAPIFailure(err) {
const defaultErrorMsg = this.newSnippet const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR ? SNIPPET_CREATE_MUTATION_ERROR
...@@ -218,7 +173,6 @@ export default { ...@@ -218,7 +173,6 @@ export default {
if (errors.length) { if (errors.length) {
this.flashAPIFailure(errors[0]); this.flashAPIFailure(errors[0]);
} else { } else {
this.originalContent = this.content;
redirectTo(baseObj.snippet.webUrl); redirectTo(baseObj.snippet.webUrl);
} }
}) })
...@@ -226,6 +180,9 @@ export default { ...@@ -226,6 +180,9 @@ export default {
this.flashAPIFailure(e); this.flashAPIFailure(e);
}); });
}, },
updateActions(actions) {
this.actions = actions;
},
}, },
newSnippetSchema: { newSnippetSchema: {
title: '', title: '',
...@@ -261,7 +218,7 @@ export default { ...@@ -261,7 +218,7 @@ export default {
:markdown-preview-path="markdownPreviewPath" :markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath" :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 <snippet-visibility-edit
v-model="snippet.visibilityLevel" v-model="snippet.visibilityLevel"
......
<script> <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 SnippetBlobEdit from './snippet_blob_edit.vue';
import { SNIPPET_MAX_BLOBS } from '../constants';
import { createBlob, decorateBlob, diffAll } from '../utils/blob';
export default { export default {
components: { components: {
SnippetBlobEdit, SnippetBlobEdit,
GlButton,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
blobs: { initBlobs: {
type: Array, type: Array,
required: true, 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> </script>
<template> <template>
<div class="form-group file-editor"> <div class="form-group file-editor">
<label for="snippet_file_path">{{ s__('Snippets|File') }}</label> <label :for="firstInputId">{{ filesLabel }}</label>
<template v-if="blobs.length"> <snippet-blob-edit
<snippet-blob-edit v-for="blob in blobs" :key="blob.name" :blob="blob" v-on="$listeners" /> v-for="(blobId, index) in blobIds"
</template> :key="blobId"
<snippet-blob-edit v-else v-on="$listeners" /> :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> </div>
</template> </template>
...@@ -8,12 +8,6 @@ import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; ...@@ -8,12 +8,6 @@ import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
import Flash from '~/flash'; import Flash from '~/flash';
import { sprintf } from '~/locale'; import { sprintf } from '~/locale';
function localId() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
export default { export default {
components: { components: {
BlobHeaderEdit, BlobHeaderEdit,
...@@ -24,49 +18,35 @@ export default { ...@@ -24,49 +18,35 @@ export default {
props: { props: {
blob: { blob: {
type: Object, type: Object,
required: false, required: true,
default: null,
validator: ({ rawPath }) => Boolean(rawPath),
}, },
canDelete: {
type: Boolean,
required: false,
default: true,
}, },
data() { showDelete: {
return { type: Boolean,
id: localId(), required: false,
filePath: this.blob?.path || '', default: false,
previousPath: '',
originalPath: this.blob?.path || '',
content: this.blob?.content || '',
originalContent: '',
isContentLoading: this.blob,
};
}, },
watch: {
filePath(filePath, previousPath) {
this.previousPath = previousPath;
this.notifyAboutUpdates({ previousPath });
}, },
content() { computed: {
this.notifyAboutUpdates(); inputId() {
return `${this.blob.id}_file_path`;
}, },
}, },
mounted() { mounted() {
if (this.blob) { if (!this.blob.isLoaded) {
this.fetchBlobContent(); this.fetchBlobContent();
} }
}, },
methods: { methods: {
notifyAboutUpdates(args = {}) { onDelete() {
const { filePath, previousPath } = args; this.$emit('delete');
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,
}, },
}); notifyAboutUpdates(args = {}) {
this.$emit('blob-updated', args);
}, },
fetchBlobContent() { fetchBlobContent() {
const baseUrl = getBaseURL(); const baseUrl = getBaseURL();
...@@ -75,17 +55,12 @@ export default { ...@@ -75,17 +55,12 @@ export default {
axios axios
.get(url) .get(url)
.then(res => { .then(res => {
this.originalContent = res.data; this.notifyAboutUpdates({ content: res.data });
this.content = res.data;
}) })
.catch(e => this.flashAPIFailure(e)) .catch(e => this.flashAPIFailure(e));
.finally(() => {
this.isContentLoading = false;
});
}, },
flashAPIFailure(err) { flashAPIFailure(err) {
Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err })); Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
this.isContentLoading = false;
}, },
}, },
}; };
...@@ -93,16 +68,26 @@ export default { ...@@ -93,16 +68,26 @@ export default {
<template> <template>
<div class="file-holder snippet"> <div class="file-holder snippet">
<blob-header-edit <blob-header-edit
id="snippet_file_path" :id="inputId"
v-model="filePath" :value="blob.path"
data-qa-selector="file_name_field" data-qa-selector="file_name_field"
:can-delete="canDelete"
:show-delete="showDelete"
@input="notifyAboutUpdates({ path: $event })"
@delete="onDelete"
/> />
<gl-loading-icon <gl-loading-icon
v-if="isContentLoading" v-if="!blob.isLoaded"
:label="__('Loading snippet')" :label="__('Loading snippet')"
size="lg" size="lg"
class="loading-animation prepend-top-20 append-bottom-20" 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> </div>
</template> </template>
...@@ -31,3 +31,5 @@ export const SNIPPET_BLOB_ACTION_CREATE = 'create'; ...@@ -31,3 +31,5 @@ export const SNIPPET_BLOB_ACTION_CREATE = 'create';
export const SNIPPET_BLOB_ACTION_UPDATE = 'update'; export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
export const SNIPPET_BLOB_ACTION_MOVE = 'move'; export const SNIPPET_BLOB_ACTION_MOVE = 'move';
export const SNIPPET_BLOB_ACTION_DELETE = 'delete'; export const SNIPPET_BLOB_ACTION_DELETE = 'delete';
export const SNIPPET_MAX_BLOBS = 10;
...@@ -3,6 +3,11 @@ import $ from 'jquery'; ...@@ -3,6 +3,11 @@ import $ from 'jquery';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import createFlash from '~/flash'; 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 StatusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip'; import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
...@@ -16,17 +21,105 @@ export default { ...@@ -16,17 +21,105 @@ export default {
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
apollo: {
userPermissions: {
query: workInProgressQuery,
skip() {
return !this.glFeatures.mergeRequestWidgetGraphql;
},
variables() {
return this.mergeRequestQueryVariables;
},
update: data => data.project.mergeRequest.userPermissions,
},
},
props: { props: {
mr: { type: Object, required: true }, mr: { type: Object, required: true },
service: { type: Object, required: true }, service: { type: Object, required: true },
}, },
data() { data() {
return { return {
userPermissions: {},
isMakingRequest: false, isMakingRequest: false,
}; };
}, },
computed: {
canUpdate() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
return this.userPermissions.updateMergeRequest;
}
return Boolean(this.mr.removeWIPPath);
},
},
methods: { 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() { handleRemoveWIP() {
if (this.glFeatures.mergeRequestWidgetGraphql) {
this.removeWipMutation();
} else {
this.isMakingRequest = true; this.isMakingRequest = true;
this.service this.service
.removeWIP() .removeWIP()
...@@ -40,6 +133,7 @@ export default { ...@@ -40,6 +133,7 @@ export default {
this.isMakingRequest = false; this.isMakingRequest = false;
createFlash(__('Something went wrong. Please try again.')); createFlash(__('Something went wrong. Please try again.'));
}); });
}
}, },
}, },
}; };
...@@ -47,7 +141,7 @@ export default { ...@@ -47,7 +141,7 @@ export default {
<template> <template>
<div class="mr-widget-body media"> <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="media-body">
<div class="gl-ml-3 float-left"> <div class="gl-ml-3 float-left">
<span class="gl-font-weight-bold"> <span class="gl-font-weight-bold">
...@@ -58,7 +152,7 @@ export default { ...@@ -58,7 +152,7 @@ export default {
}}</span> }}</span>
</div> </div>
<gl-button <gl-button
v-if="mr.removeWIPPath" v-if="canUpdate"
size="small" size="small"
:disabled="isMakingRequest" :disabled="isMakingRequest"
:loading="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'; ...@@ -8,6 +8,7 @@ import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project'; import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval'; import SmartInterval from '~/smart_interval';
import createFlash from '../flash'; import createFlash from '../flash';
import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import Loading from './components/loading.vue'; import Loading from './components/loading.vue';
import WidgetHeader from './components/mr_widget_header.vue'; import WidgetHeader from './components/mr_widget_header.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue'; import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
...@@ -42,6 +43,7 @@ import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_ ...@@ -42,6 +43,7 @@ import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils'; import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
import getStateQuery from './queries/get_state.query.graphql';
export default { export default {
el: '#js-vue-mr-widget', el: '#js-vue-mr-widget',
...@@ -83,6 +85,27 @@ export default { ...@@ -83,6 +85,27 @@ export default {
GroupedAccessibilityReportsApp, GroupedAccessibilityReportsApp,
MrWidgetApprovals, 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: { props: {
mrData: { mrData: {
type: Object, 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'; import { stateKey } from './state_maps';
export default function deviseState(data) { export default function deviseState() {
if (data.project_archived) { if (this.projectArchived) {
return stateKey.archived; return stateKey.archived;
} else if (data.branch_missing) { } else if (this.branchMissing) {
return stateKey.missingBranch; return stateKey.missingBranch;
} else if (!data.commits_count) { } else if (!this.commitsCount) {
return stateKey.nothingToMerge; return stateKey.nothingToMerge;
} else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') { } else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') {
return stateKey.checking; return stateKey.checking;
} else if (data.has_conflicts) { } else if (this.hasConflicts) {
return stateKey.conflicts; return stateKey.conflicts;
} else if (this.shouldBeRebased) { } else if (this.shouldBeRebased) {
return stateKey.rebase; return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed; return stateKey.pipelineFailed;
} else if (data.work_in_progress) { } else if (this.workInProgress) {
return stateKey.workInProgress; return stateKey.workInProgress;
} else if (this.hasMergeableDiscussionsState) { } else if (this.hasMergeableDiscussionsState) {
return stateKey.unresolvedDiscussions; return stateKey.unresolvedDiscussions;
......
...@@ -60,6 +60,9 @@ export default class MergeRequestStore { ...@@ -60,6 +60,9 @@ export default class MergeRequestStore {
this.rebaseInProgress = data.rebase_in_progress; this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path; this.mergeRequestDiffsPath = data.diffs_path;
this.approvalsWidgetType = data.approvals_widget_type; 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) { if (data.issues_links) {
const links = data.issues_links; const links = data.issues_links;
...@@ -90,7 +93,8 @@ export default class MergeRequestStore { ...@@ -90,7 +93,8 @@ export default class MergeRequestStore {
this.ffOnlyEnabled = data.ff_only_enabled; this.ffOnlyEnabled = data.ff_only_enabled;
this.shouldBeRebased = Boolean(data.should_be_rebased); this.shouldBeRebased = Boolean(data.should_be_rebased);
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; 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.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
this.isSHAMismatch = this.sha !== data.diff_head_sha; this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.latestSHA = data.diff_head_sha; this.latestSHA = data.diff_head_sha;
...@@ -133,6 +137,10 @@ export default class MergeRequestStore { ...@@ -133,6 +137,10 @@ export default class MergeRequestStore {
this.mergeCommitPath = data.merge_commit_path; this.mergeCommitPath = data.merge_commit_path;
this.canPushToSourceBranch = data.can_push_to_source_branch; 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; const currentUser = data.current_user;
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
...@@ -143,19 +151,25 @@ export default class MergeRequestStore { ...@@ -143,19 +151,25 @@ export default class MergeRequestStore {
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
this.canRevertInCurrentMR = currentUser.can_revert_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) { if (this.mergeOngoing) {
this.state = 'merging'; this.state = 'merging';
return; return;
} }
if (this.isOpen) { if (this.isOpen) {
this.state = getStateKey.call(this, data); this.state = getStateKey.call(this);
} else { } else {
switch (data.state) { switch (this.mergeRequestState) {
case 'merged': case 'merged':
this.state = 'merged'; this.state = 'merged';
break; break;
......
...@@ -307,23 +307,6 @@ ...@@ -307,23 +307,6 @@
color: $gl-text-color; color: $gl-text-color;
border-color: $border-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 { .filtered-search-history-dropdown {
......
...@@ -39,6 +39,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo ...@@ -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(:auto_expand_collapsed_diffs, @project, default_enabled: true)
push_frontend_feature_flag(:approvals_commented_by, @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(:hide_jump_to_next_unresolved_in_threads, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project)
end end
before_action do before_action do
......
...@@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController ...@@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :authorize_update_snippet!, only: [:edit, :update] before_action :authorize_update_snippet!, only: [:edit, :update]
before_action :authorize_admin_snippet!, only: [:destroy] before_action :authorize_admin_snippet!, only: [:destroy]
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index def index
@snippet_counts = ::Snippets::CountService @snippet_counts = ::Snippets::CountService
.new(current_user, project: @project) .new(current_user, project: @project)
......
...@@ -44,6 +44,10 @@ class ProjectsController < Projects::ApplicationController ...@@ -44,6 +44,10 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:service_desk_custom_address, @project) push_frontend_feature_flag(:service_desk_custom_address, @project)
end end
before_action only: [:edit] do
push_frontend_feature_flag(:approval_suggestions, @project)
end
layout :determine_layout layout :determine_layout
def index def index
......
...@@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController ...@@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController
layout 'snippets' layout 'snippets'
before_action do
push_frontend_feature_flag(:snippet_multiple_files, current_user)
end
def index def index
if params[:username].present? if params[:username].present?
@user = UserFinder.new(params[:username]).find_by_username! @user = UserFinder.new(params[:username]).find_by_username!
......
...@@ -51,14 +51,16 @@ class MergeRequestDiff < ApplicationRecord ...@@ -51,14 +51,16 @@ class MergeRequestDiff < ApplicationRecord
scope :by_commit_sha, ->(sha) do scope :by_commit_sha, ->(sha) do
joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil) joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
end end
scope :has_diff_files, -> { where(id: MergeRequestDiffFile.select(:merge_request_diff_id)) }
scope :by_project_id, -> (project_id) do scope :by_project_id, -> (project_id) do
joins(:merge_request).where(merge_requests: { target_project_id: project_id }) joins(:merge_request).where(merge_requests: { target_project_id: project_id })
end end
scope :recent, -> { order(id: :desc).limit(100) } 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 scope :not_latest_diffs, -> do
merge_requests = MergeRequest.arel_table merge_requests = MergeRequest.arel_table
...@@ -115,29 +117,28 @@ class MergeRequestDiff < ApplicationRecord ...@@ -115,29 +117,28 @@ class MergeRequestDiff < ApplicationRecord
end end
def ids_for_external_storage_migration_strategy_always(limit:) 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 end
def ids_for_external_storage_migration_strategy_outdated(limit:) def ids_for_external_storage_migration_strategy_outdated(limit:)
# Outdated is too complex to be a single SQL query, so split into three # Outdated is too complex to be a single SQL query, so split into three
before = EXTERNAL_DIFF_CUTOFF.ago before = EXTERNAL_DIFF_CUTOFF.ago
potentials = has_diff_files.files_in_database
ids = potentials ids = files_in_database
.old_merged_diffs(before) .old_merged_diffs(before)
.limit(limit) .limit(limit)
.pluck(:id) .pluck(:id)
return ids if ids.size >= limit return ids if ids.size >= limit
ids += potentials ids += files_in_database
.old_closed_diffs(before) .old_closed_diffs(before)
.limit(limit - ids.size) .limit(limit - ids.size)
.pluck(:id) .pluck(:id)
return ids if ids.size >= limit return ids if ids.size >= limit
ids + potentials ids + files_in_database
.not_latest_diffs .not_latest_diffs
.limit(limit - ids.size) .limit(limit - ids.size)
.pluck(:id) .pluck(:id)
......
...@@ -81,7 +81,7 @@ ...@@ -81,7 +81,7 @@
- if dag_pipeline_tab_enabled - if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane #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-tab-tests.tab-pane
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
......
...@@ -20,7 +20,8 @@ ...@@ -20,7 +20,8 @@
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box .filtered-search-box
- if type != :boards_modal && type != :boards - 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", options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "btn filtered-search-history-dropdown-toggle-button", toggle_class: "btn filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown", 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 ...@@ -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 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_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_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 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_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); 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: ...@@ -50,7 +50,7 @@ Have a look at some of our most popular topics:
## The entire DevOps Lifecycle ## The entire DevOps Lifecycle
GitLab is the first single application for software development, security, 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. 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/): 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. ...@@ -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 If you have taken advantage of Consul to store other data and want to restore
the failed node, follow the 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. to recover a failed cluster.
...@@ -373,7 +373,7 @@ There is an [issue where support is being discussed](https://gitlab.com/gitlab-o ...@@ -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>' postgresql['sql_user_password'] = '<md5_hash_of_your_password>'
gitlab_rails['db_password'] = '<your_password_here>' gitlab_rails['db_password'] = '<your_password_here>'
```
For external PostgreSQL instances, see [additional instructions](external_database.md). 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`. 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. ...@@ -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`. | | `format` | string | no | Log format: `text` or `json`. Default: `text`. |
| `level` | string | no | Log level: `debug`, `info`, `warn`, `error`, `fatal`, or `panic`. Default: `info`. | | `level` | string | no | Log level: `debug`, `info`, `warn`, `error`, `fatal`, or `panic`. Default: `info`. |
| `sentry_dsn` | string | no | Sentry DSN for exception monitoring. | | `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. | | `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 While the main Gitaly application logs go to `stdout`, there are some extra log
......
...@@ -62,7 +62,7 @@ Example of response ...@@ -62,7 +62,7 @@ Example of response
"id": 1, "id": 1,
"name": "review/fix-foo", "name": "review/fix-foo",
"slug": "review-fix-foo-dfjre3", "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", "state": "available",
"last_deployment": { "last_deployment": {
"id": 100, "id": 100,
...@@ -78,7 +78,7 @@ Example of response ...@@ -78,7 +78,7 @@ Example of response
"username": "root", "username": "root",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon",
"web_url": "http://localhost:3000/root" "web_url": "http://localhost:3000/root"
} },
"deployable": { "deployable": {
"id": 710, "id": 710,
"status": "success", "status": "success",
...@@ -107,7 +107,7 @@ Example of response ...@@ -107,7 +107,7 @@ Example of response
"twitter": "", "twitter": "",
"website_url": "", "website_url": "",
"organization": null "organization": null
} },
"commit": { "commit": {
"id": "416d8ea11849050d3d1f5104cf8cf51053e790ab", "id": "416d8ea11849050d3d1f5104cf8cf51053e790ab",
"short_id": "416d8ea1", "short_id": "416d8ea1",
......
...@@ -208,7 +208,7 @@ The variable names begin with the `CI_MERGE_REQUEST_` prefix. ...@@ -208,7 +208,7 @@ The variable names begin with the `CI_MERGE_REQUEST_` prefix.
### Two pipelines created when pushing to a merge request ### Two pipelines created when pushing to a merge request
If you are experiencing duplicated pipelines when using `rules`, take a look at 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. which will help you get your starting configuration correct.
If you are seeing two pipelines when using `only/except`, please see the caveats If you are seeing two pipelines when using `only/except`, please see the caveats
......
...@@ -7,6 +7,24 @@ type: reference ...@@ -7,6 +7,24 @@ type: reference
# Troubleshooting CI/CD # 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 ## 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). 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 ...@@ -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 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 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 #### `workflow:rules` templates
...@@ -1218,19 +1218,22 @@ job: ...@@ -1218,19 +1218,22 @@ job:
- In **all other cases**, the job is added to the pipeline, with `when: on_success`. - In **all other cases**, the job is added to the pipeline, with `when: on_success`.
CAUTION: **Caution:** 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 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). 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. 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. Jobs defined with `rules` can trigger multiple pipelines with the same action. You
You must explicitly add `only: merge_requests`. 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. Some configurations that have the potential to cause duplicate pipelines cause a
You do not have to explicitly configure each type. [pipeline warning](../troubleshooting.md#pipeline-warnings) to be displayed.
[Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/219431) in GitLab 13.3.
For example: For example:
...@@ -1246,21 +1249,77 @@ job: ...@@ -1246,21 +1249,77 @@ job:
This job does not run when `$CUSTOM_VARIABLE` is false, but it *does* run in **all** 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 other pipelines, including **both** push (branch) and merge request pipelines. With
this configuration, every push to an open merge request's source branch this configuration, every push to an open merge request's source branch
causes duplicated pipelines. Explicitly allowing both push and merge request pipelines causes duplicated pipelines.
in the same job could have the same effect.
We recommend using [`workflow: rules`](#workflowrules) to limit which types of pipelines There are multiple ways to avoid this:
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`).
It is not possible to run a job for branch pipelines first, then only for merge request - Use [`workflow: rules`](#workflowrules) to specify which types of pipelines
pipelines after the merge request is created (skipping the duplicate branch pipeline). See can run. To eliminate duplicate pipelines, allow only merge request pipelines
the [related issue](https://gitlab.com/gitlab-org/gitlab/-/issues/201845) for more details. or push (branch) pipelines.
Also, we don't recommend mixing `only/except` jobs with `rules` jobs in the same pipeline. - Rewrite the rules to run the job only in very specific cases,
It may not cause YAML errors, but debugging the exact execution behavior can be complex and avoid using a final `when:` rule:
due to the different default behaviors of `only/except` and `rules`.
```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` #### `rules:if`
......
...@@ -233,4 +233,4 @@ In the same code block, precede each with comments: `# Better` and `# Best`. ...@@ -233,4 +233,4 @@ In the same code block, precede each with comments: `# Better` and `# Best`.
NOTE: **Note:** NOTE: **Note:**
While the bad-then-good approach is acceptable for the GitLab development guidelines, do not use it 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 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: ...@@ -53,8 +53,8 @@ Examples of component classes that were created using "utility-first" include:
Inspiration: Inspiration:
- <https://tailwindcss.com/docs/utility-first/> - <https://tailwindcss.com/docs/utility-first>
- <https://tailwindcss.com/docs/extracting-components/> - <https://tailwindcss.com/docs/extracting-components>
### Naming ### Naming
......
...@@ -285,7 +285,7 @@ describe('~/todos/app.vue', () => { ...@@ -285,7 +285,7 @@ describe('~/todos/app.vue', () => {
### Test the component's output ### Test the component's output
The main return value of a Vue component is the rendered output. In order to test the component we 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 ### Events
......
...@@ -10,7 +10,7 @@ Any frontend engineer can contribute to this dashboard. They can contribute by a ...@@ -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: There are 3 recommended high impact metrics to review on each page:
- [First visual change](https://web.dev/first-meaningful-paint/) - [First visual change](https://web.dev/first-meaningful-paint/)
- [Speed Index](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://sites.google.com/a/webpagetest.org/docs/using-webpagetest/metrics/speed-index) - [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. 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 ...@@ -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: 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. 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. 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`. Make sure to give the token at least the following scopes: `event:read` and `project:read`.
1. Navigate to your project’s **Settings > Operations**. 1. Navigate to your project’s **Settings > Operations**.
...@@ -40,7 +40,7 @@ GitLab provides an easy way to connect Sentry to your project: ...@@ -40,7 +40,7 @@ GitLab provides an easy way to connect Sentry to your project:
### Enabling GitLab issues links ### 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 ## Error Tracking List
...@@ -59,7 +59,7 @@ From error list, users can navigate to the error details page by clicking the ti ...@@ -59,7 +59,7 @@ From error list, users can navigate to the error details page by clicking the ti
This page has: This page has:
- A link to the Sentry issue. - 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. - 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. - In [GitLab 12.7 and newer](https://gitlab.com/gitlab-org/gitlab/-/issues/36246), language and urgency are displayed.
......
...@@ -1189,7 +1189,7 @@ server: ...@@ -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. 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). 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: 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. ...@@ -75,15 +75,15 @@ which means that the reported licenses might be incomplete or inaccurate.
| Language | Package managers | Scan Tool | | 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)| | 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)| | 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)| | 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)| | 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)| | 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)| | Rust | [Cargo](https://crates.io) |[License Finder](https://github.com/pivotal/LicenseFinder)|
| PHP | [composer](https://getcomposer.org/) |[License Finder](https://github.com/pivotal/LicenseFinder)| | PHP | [Composer](https://getcomposer.org/) |[License Finder](https://github.com/pivotal/LicenseFinder)|
## Requirements ## Requirements
...@@ -330,13 +330,13 @@ strict-ssl = false ...@@ -330,13 +330,13 @@ strict-ssl = false
### Configuring Yarn projects ### 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. file.
#### Using private Yarn registries #### Using private Yarn registries
If you have a private Yarn registry you can use the 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. setting to specify its location.
For example: For example:
......
...@@ -162,7 +162,7 @@ For more information, see our [discussion on providers](#providers). ...@@ -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: 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) - [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) - [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) - [PingOne by Ping Identity](https://docs.pingidentity.com/bundle/pingone/page/xsh1564020480660-1.html)
......
...@@ -427,7 +427,7 @@ apply: ...@@ -427,7 +427,7 @@ apply:
### Multiple Terraform Plan reports ### 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 ```yaml
image: image:
......
...@@ -7,7 +7,7 @@ NOTE: **Note:** ...@@ -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. 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 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. from planning to monitoring.
This displays the usage of these GitLab features over This displays the usage of these GitLab features over
......
...@@ -18,11 +18,11 @@ The Package Registry supports the following formats: ...@@ -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 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/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="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="https://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/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="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="https://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/pypi_repository/index.html">PyPI</a></td><td>12.10+</td></tr>
</table> </table>
</div> </div>
</div> </div>
......
...@@ -84,7 +84,7 @@ merge-request-pipeline-job: ...@@ -84,7 +84,7 @@ merge-request-pipeline-job:
``` ```
You should avoid configuration like this, and only use branch (`push`) pipelines 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. for details on avoiding two pipelines for a single merge request.
### Skipped pipelines ### Skipped pipelines
......
...@@ -56,7 +56,7 @@ reiterating the importance of HTTPS. ...@@ -56,7 +56,7 @@ reiterating the importance of HTTPS.
## Issuing Certificates ## 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 [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/) [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. 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 ...@@ -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. > - 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. > - 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. > - 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:** DANGER: **Danger:**
In GitLab 13.0, we [introduced breaking changes](https://gitlab.com/gitlab-org/gitlab/-/issues/213282) 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 ...@@ -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 can be directly edited. When you're ready, you can submit your changes in a
click of a button: 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 When an editor submits their changes, in the background, GitLab automatically
creates a new branch, commits their changes, and opens a merge request. The creates a new branch, commits their changes, and opens a merge request. The
......
...@@ -25,7 +25,12 @@ export default { ...@@ -25,7 +25,12 @@ export default {
rule: 'data', rule: 'data',
}), }),
title() { 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: { methods: {
...@@ -47,6 +52,11 @@ export default { ...@@ -47,6 +52,11 @@ export default {
size="sm" size="sm"
@ok.prevent="submit" @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> </gl-modal-vuex>
</template> </template>
<script> <script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { n__, sprintf } from '~/locale'; import { n__, sprintf } from '~/locale';
import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants'; import { RULE_TYPE_ANY_APPROVER, RULE_TYPE_REGULAR } from '../../constants';
import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue';
import Rules from '../rules.vue'; import Rules from '../rules.vue';
import RuleControls from '../rule_controls.vue'; import RuleControls from '../rule_controls.vue';
import EmptyRule from '../empty_rule.vue'; import EmptyRule from '../empty_rule.vue';
import RuleInput from '../mr_edit/rule_input.vue'; import RuleInput from '../mr_edit/rule_input.vue';
import RuleBranches from '../rule_branches.vue'; import RuleBranches from '../rule_branches.vue';
import UnconfiguredSecurityRules from '../security_configuration/unconfigured_security_rules.vue';
export default { export default {
components: { components: {
...@@ -17,7 +20,10 @@ export default { ...@@ -17,7 +20,10 @@ export default {
EmptyRule, EmptyRule,
RuleInput, RuleInput,
RuleBranches, RuleBranches,
UnconfiguredSecurityRules,
}, },
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
mixins: [glFeatureFlagsMixin()],
computed: { computed: {
...mapState(['settings']), ...mapState(['settings']),
...mapState({ ...mapState({
...@@ -92,6 +98,7 @@ export default { ...@@ -92,6 +98,7 @@ export default {
</script> </script>
<template> <template>
<div>
<rules :rules="rules"> <rules :rules="rules">
<template #thead="{ name, members, approvalsRequired, branches }"> <template #thead="{ name, members, approvalsRequired, branches }">
<tr class="d-none d-sm-table-row"> <tr class="d-none d-sm-table-row">
...@@ -117,7 +124,10 @@ export default { ...@@ -117,7 +124,10 @@ export default {
/> />
<tr v-else :key="index"> <tr v-else :key="index">
<td class="js-name">{{ rule.name }}</td> <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="" /> <user-avatar-list :items="rule.approvers" :img-size="24" empty-text="" />
</td> </td>
<td v-if="settings.allowMultiRule" class="js-branches"> <td v-if="settings.allowMultiRule" class="js-branches">
...@@ -133,4 +143,7 @@ export default { ...@@ -133,4 +143,7 @@ export default {
</template> </template>
</template> </template>
</rules> </rules>
<!-- TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114 -->
<unconfigured-security-rules v-if="glFeatures.approvalSuggestions" />
</div>
</template> </template>
<script> <script>
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { groupBy, isNumber } from 'lodash'; import { groupBy, isNumber } from 'lodash';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
...@@ -9,7 +10,8 @@ import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants'; ...@@ -9,7 +10,8 @@ import { TYPE_USER, TYPE_GROUP, TYPE_HIDDEN_GROUPS } from '../constants';
const DEFAULT_NAME = 'Default'; const DEFAULT_NAME = 'Default';
const DEFAULT_NAME_FOR_LICENSE_REPORT = 'License-Check'; 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 { export default {
components: { components: {
...@@ -17,6 +19,8 @@ export default { ...@@ -17,6 +19,8 @@ export default {
ApproversSelect, ApproversSelect,
BranchesSelect, BranchesSelect,
}, },
// TODO: Remove feature flag in https://gitlab.com/gitlab-org/gitlab/-/issues/235114
mixins: [glFeatureFlagsMixin()],
props: { props: {
initRule: { initRule: {
type: Object, type: Object,
...@@ -28,9 +32,14 @@ export default { ...@@ -28,9 +32,14 @@ export default {
default: true, default: true,
required: false, required: false,
}, },
defaultRuleName: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { const defaults = {
name: '', name: '',
approvalsRequired: 1, approvalsRequired: 1,
minApprovalsRequired: 0, minApprovalsRequired: 0,
...@@ -43,9 +52,19 @@ export default { ...@@ -43,9 +52,19 @@ export default {
containsHiddenGroups: false, containsHiddenGroups: false,
...this.getInitialData(), ...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: { computed: {
...mapState(['settings']), ...mapState(['settings']),
rule() {
// If we are creating a new rule with a suggested approval name
return this.defaultRuleName ? null : this.initRule;
},
approversByType() { approversByType() {
return groupBy(this.approvers, x => x.type); return groupBy(this.approvers, x => x.type);
}, },
...@@ -132,6 +151,12 @@ export default { ...@@ -132,6 +151,12 @@ export default {
return !this.settings.lockedApprovalsRuleName; return !this.settings.lockedApprovalsRuleName;
}, },
isNameDisabled() { 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); return this.isPersisted && READONLY_NAMES.includes(this.name);
}, },
removeHiddenGroups() { removeHiddenGroups() {
...@@ -232,7 +257,7 @@ export default { ...@@ -232,7 +257,7 @@ export default {
return this.isValid; return this.isValid;
}, },
getInitialData() { getInitialData() {
if (!this.initRule) { if (!this.initRule || this.defaultRuleName) {
return {}; return {};
} }
...@@ -309,7 +334,7 @@ export default { ...@@ -309,7 +334,7 @@ export default {
v-model="branchesToAdd" v-model="branchesToAdd"
:project-id="settings.projectId" :project-id="settings.projectId"
:is-invalid="!!validation.branches" :is-invalid="!!validation.branches"
:init-rule="initRule" :init-rule="rule"
/> />
<div class="invalid-feedback">{{ validation.branches }}</div> <div class="invalid-feedback">{{ validation.branches }}</div>
</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'; ...@@ -14,6 +14,15 @@ export const RULE_NAME_ANY_APPROVER = 'All Members';
export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check'; export const VULNERABILITY_CHECK_NAME = 'Vulnerability-Check';
export const LICENSE_CHECK_NAME = 'License-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 = { export const APPROVAL_RULE_CONFIGS = {
[VULNERABILITY_CHECK_NAME]: { [VULNERABILITY_CHECK_NAME]: {
title: __('Vulnerability-Check'), title: __('Vulnerability-Check'),
......
...@@ -12,6 +12,8 @@ export default function mountProjectSettingsApprovals(el) { ...@@ -12,6 +12,8 @@ export default function mountProjectSettingsApprovals(el) {
return null; return null;
} }
const { vulnerabilityCheckHelpPagePath, licenseCheckHelpPagePath } = el.dataset;
const store = createStore(projectSettingsModule(), { const store = createStore(projectSettingsModule(), {
...el.dataset, ...el.dataset,
prefix: 'project-settings', prefix: 'project-settings',
...@@ -22,6 +24,10 @@ export default function mountProjectSettingsApprovals(el) { ...@@ -22,6 +24,10 @@ export default function mountProjectSettingsApprovals(el) {
return new Vue({ return new Vue({
el, el,
store, store,
provide: {
vulnerabilityCheckHelpPagePath,
licenseCheckHelpPagePath,
},
render(h) { render(h) {
return h(ProjectSettingsApp); return h(ProjectSettingsApp);
}, },
......
import Vuex from 'vuex'; import Vuex from 'vuex';
import modalModule from '~/vuex_shared/modules/modal'; import modalModule from '~/vuex_shared/modules/modal';
import securityConfigurationModule from 'ee/security_configuration/modules/configuration';
import state from './state'; import state from './state';
export const createStoreOptions = (approvalsModule, settings) => ({ export const createStoreOptions = (approvalsModule, settings) => ({
...@@ -8,6 +9,9 @@ export const createStoreOptions = (approvalsModule, settings) => ({ ...@@ -8,6 +9,9 @@ export const createStoreOptions = (approvalsModule, settings) => ({
...(approvalsModule ? { approvals: approvalsModule } : {}), ...(approvalsModule ? { approvals: approvalsModule } : {}),
createModal: modalModule(), createModal: modalModule(),
deleteModal: modalModule(), deleteModal: modalModule(),
securityConfiguration: securityConfigurationModule({
securityConfigurationPath: settings?.securityConfigurationPath || '',
}),
}, },
}); });
......
import ipaddr from 'ipaddr.js'; import validateIpAddress from 'ee/validators/ip_address';
import { s__ } from '~/locale'; 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 => { const validateIP = data => {
let addresses = data.replace(/\s/g, '').split(','); let addresses = data.replace(/\s/g, '').split(',');
addresses = addresses.map(address => validateAddress(address)); addresses = addresses.map(address => validateIpAddress(address));
return !addresses.some(a => !a); return !addresses.some(a => !a);
}; };
......
...@@ -2,7 +2,7 @@ import Vue from 'vue'; ...@@ -2,7 +2,7 @@ import Vue from 'vue';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import CommaSeparatedListTokenSelector from '../components/comma_separated_list_token_selector.vue'; 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 // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
...@@ -20,8 +20,14 @@ export default (el, props = {}, qaSelector) => { ...@@ -20,8 +20,14 @@ export default (el, props = {}, qaSelector) => {
regexValidator, regexValidator,
...(regexValidator ? { regexValidator: new RegExp(regexValidator) } : {}), ...(regexValidator ? { regexValidator: new RegExp(regexValidator) } : {}),
...(disallowedValues ? { disallowedValues: JSON.parse(disallowedValues) } : {}), ...(disallowedValues ? { disallowedValues: JSON.parse(disallowedValues) } : {}),
customErrorMessage: '',
}; };
}, },
methods: {
handleTextInput(value) {
this.customErrorMessage = customValidator(value);
},
},
render(createElement) { render(createElement) {
return createElement('comma-separated-list-token-selector', { return createElement('comma-separated-list-token-selector', {
attrs: { attrs: {
...@@ -32,8 +38,10 @@ export default (el, props = {}, qaSelector) => { ...@@ -32,8 +38,10 @@ export default (el, props = {}, qaSelector) => {
ariaLabelledby: this.labelId, ariaLabelledby: this.labelId,
regexValidator: this.regexValidator, regexValidator: this.regexValidator,
disallowedValues: this.disallowedValues, disallowedValues: this.disallowedValues,
customErrorMessage: this.customErrorMessage,
...props, ...props,
}, },
on: customValidator ? { 'text-input': this.handleTextInput } : {},
scopedSlots: { scopedSlots: {
'user-defined-token-content': ({ inputText: value }) => { 'user-defined-token-content': ({ inputText: value }) => {
return sprintf(__('Add "%{value}"'), { 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 { ...@@ -30,7 +30,7 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
errorMessage: { regexErrorMessage: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
...@@ -40,6 +40,11 @@ export default { ...@@ -40,6 +40,11 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
customErrorMessage: {
type: String,
required: false,
default: '',
},
}, },
data() { data() {
return { return {
...@@ -54,14 +59,14 @@ export default { ...@@ -54,14 +59,14 @@ export default {
}, },
computedErrorMessage() { computedErrorMessage() {
if (this.regexValidator !== null && this.textInputValue.match(this.regexValidator) === null) { 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)) { if (!isEmpty(this.disallowedValues) && this.disallowedValues.includes(this.textInputValue)) {
return this.disallowedValueErrorMessage; return this.disallowedValueErrorMessage;
} }
return ''; return this.customErrorMessage;
}, },
}, },
watch: { watch: {
...@@ -111,6 +116,7 @@ export default { ...@@ -111,6 +116,7 @@ export default {
handleTextInput(value) { handleTextInput(value) {
this.hideErrorMessage = true; this.hideErrorMessage = true;
this.textInputValue = value; this.textInputValue = value;
this.$emit('text-input', value);
}, },
handleBlur() { handleBlur() {
this.hideErrorMessage = true; this.hideErrorMessage = true;
......
import '~/pages/groups/edit'; import '~/pages/groups/edit';
import initAccessRestrictionField from 'ee/groups/settings/access_restriction_field'; import initAccessRestrictionField from 'ee/groups/settings/access_restriction_field';
import validateRestrictedIpAddress from 'ee/groups/settings/access_restriction_field/validate_ip_address';
import { __ } from '~/locale'; import { __ } from '~/locale';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initAccessRestrictionField('.js-allowed-email-domains', { initAccessRestrictionField('.js-allowed-email-domains', {
placeholder: __('Enter domain'), 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.'), disallowedValueErrorMessage: __('The domain you entered is not allowed.'),
}); });
initAccessRestrictionField( initAccessRestrictionField(
'.js-ip-restriction', '.js-ip-restriction',
{ placeholder: __('Enter IP address range') }, { placeholder: __('Enter IP address range') },
'ip_restriction_field', 'ip_restriction_field',
validateRestrictedIpAddress,
); );
}); });
...@@ -2,9 +2,7 @@ import * as Sentry from '@sentry/browser'; ...@@ -2,9 +2,7 @@ import * as Sentry from '@sentry/browser';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import * as types from './mutation_types'; import * as types from './mutation_types';
export const setSecurityConfigurationEndpoint = ({ commit }, endpoint) => // eslint-disable-next-line import/prefer-default-export
commit(types.SET_SECURITY_CONFIGURATION_ENDPOINT, endpoint);
export const fetchSecurityConfiguration = ({ commit, state }) => { export const fetchSecurityConfiguration = ({ commit, state }) => {
if (!state.securityConfigurationPath) { if (!state.securityConfigurationPath) {
return commit(types.RECEIVE_SECURITY_CONFIGURATION_ERROR); return commit(types.RECEIVE_SECURITY_CONFIGURATION_ERROR);
......
import state from './state'; import createState from './state';
import mutations from './mutations'; import mutations from './mutations';
import * as actions from './actions'; import * as actions from './actions';
export default { export default ({ securityConfigurationPath = '' }) => ({
namespaced: true, namespaced: true,
state, state: createState({ securityConfigurationPath }),
mutations, mutations,
actions, actions,
}; });
export const SET_SECURITY_CONFIGURATION_ENDPOINT = 'SET_SECURITY_CONFIGURATION_ENDPOINT';
export const REQUEST_SECURITY_CONFIGURATION = 'REQUEST_SECURITY_CONFIGURATION'; export const REQUEST_SECURITY_CONFIGURATION = 'REQUEST_SECURITY_CONFIGURATION';
export const RECEIVE_SECURITY_CONFIGURATION_SUCCESS = 'RECEIVE_SECURITY_CONFIGURATION_SUCCESS'; export const RECEIVE_SECURITY_CONFIGURATION_SUCCESS = 'RECEIVE_SECURITY_CONFIGURATION_SUCCESS';
export const RECEIVE_SECURITY_CONFIGURATION_ERROR = 'RECEIVE_SECURITY_CONFIGURATION_ERROR'; export const RECEIVE_SECURITY_CONFIGURATION_ERROR = 'RECEIVE_SECURITY_CONFIGURATION_ERROR';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.SET_SECURITY_CONFIGURATION_ENDPOINT](state, payload) {
state.securityConfigurationPath = payload;
},
[types.REQUEST_SECURITY_CONFIGURATION](state) { [types.REQUEST_SECURITY_CONFIGURATION](state) {
state.isLoading = true; state.isLoading = true;
state.errorLoading = false; state.errorLoading = false;
......
export default () => ({ export default ({ securityConfigurationPath }) => ({
securityConfigurationPath: '', securityConfigurationPath,
isLoading: false, isLoading: false,
errorLoading: false, errorLoading: false,
configuration: {}, 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 CEGetStateKey from '~/vue_merge_request_widget/stores/get_state_key';
import { stateKey } from './state_maps'; import { stateKey } from './state_maps';
export default function(data) { export default function() {
if (this.isGeoSecondaryNode) { if (this.isGeoSecondaryNode) {
return 'geoSecondaryNode'; return 'geoSecondaryNode';
} }
if (data.policy_violation) { if (this.policyViolation) {
return stateKey.policyViolation; return stateKey.policyViolation;
} }
return CEGetStateKey.call(this, data); return CEGetStateKey.call(this);
} }
...@@ -44,6 +44,7 @@ export default class MergeRequestStore extends CEMergeRequestStore { ...@@ -44,6 +44,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled); this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled);
this.mergeTrainsCount = data.merge_trains_count || 0; this.mergeTrainsCount = data.merge_trains_count || 0;
this.mergeTrainIndex = data.merge_train_index; this.mergeTrainIndex = data.merge_train_index;
this.policyViolation = data.policy_violation;
super.setData(data, isRebased); super.setData(data, isRebased);
} }
......
...@@ -94,6 +94,24 @@ module EE ...@@ -94,6 +94,24 @@ module EE
{ date: date } { date: date }
end 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) 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.') 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) html_escape(message) % remove_message_data(project)
......
- can_override_approvers = project.can_override_approvers? - 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_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) - can_modify_merge_request_committer_settings = can?(current_user, :modify_merge_request_committer_setting, @project)
.form-group .form-group
= form.label :approver_ids, class: 'label-bold' do = form.label :approver_ids, class: 'label-bold' do
= _("Approval rules") = _("Approval rules")
#js-mr-approvals-settings{ data: { 'project_id': @project.id, #js-mr-approvals-settings{ approvals_app_data }
'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')} }
.text-center.gl-mt-3 .text-center.gl-mt-3
= sprite_icon('spinner', size: 24, css_class: 'gl-spinner') = sprite_icon('spinner', size: 24, css_class: 'gl-spinner')
......
- page_title _('Security Dashboard - Settings') - page_title _('Settings')
- @hide_breadcrumbs = true
#js-security #js-security
- page_title _('Security Dashboard') - page_title _('Security Dashboard')
- @hide_breadcrumbs = true
#js-security{ data: instance_security_dashboard_data } #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 ...@@ -17,6 +17,21 @@ module Gitlab
end end
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 def update_primary_geo_node_url
node = Gitlab::Geo.primary_node node = Gitlab::Geo.primary_node
......
...@@ -190,23 +190,7 @@ namespace :geo do ...@@ -190,23 +190,7 @@ namespace :geo do
task set_secondary_as_primary: :environment do task set_secondary_as_primary: :environment do
abort GEO_LICENSE_ERROR_TEXT unless Gitlab::Geo.license_allows? abort GEO_LICENSE_ERROR_TEXT unless Gitlab::Geo.license_allows?
ActiveRecord::Base.transaction do Gitlab::Geo::GeoTasks.set_secondary_as_primary
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
end end
desc 'GitLab | Geo | Update Geo primary node URL' desc 'GitLab | Geo | Update Geo primary node URL'
......
...@@ -14,6 +14,11 @@ localVue.use(Vuex); ...@@ -14,6 +14,11 @@ localVue.use(Vuex);
describe('Approvals ModalRuleCreate', () => { describe('Approvals ModalRuleCreate', () => {
let createModalState; let createModalState;
let wrapper; let wrapper;
let modal;
let form;
const findModal = () => wrapper.find(GlModalVuex);
const findForm = () => wrapper.find(RuleForm);
const factory = (options = {}) => { const factory = (options = {}) => {
const store = new Vuex.Store({ const store = new Vuex.Store({
...@@ -49,15 +54,13 @@ describe('Approvals ModalRuleCreate', () => { ...@@ -49,15 +54,13 @@ describe('Approvals ModalRuleCreate', () => {
describe('without data', () => { describe('without data', () => {
beforeEach(() => { beforeEach(() => {
createModalState.data = null; createModalState.data = null;
factory();
modal = findModal();
form = findForm();
}); });
it('renders modal', () => { it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true); expect(modal.exists()).toBe(true);
expect(modal.props('modalModule')).toEqual(MODAL_MODULE); expect(modal.props('modalModule')).toEqual(MODAL_MODULE);
expect(modal.props('modalId')).toEqual(TEST_MODAL_ID); expect(modal.props('modalId')).toEqual(TEST_MODAL_ID);
expect(modal.attributes('title')).toEqual('Add approval rule'); expect(modal.attributes('title')).toEqual('Add approval rule');
...@@ -65,22 +68,12 @@ describe('Approvals ModalRuleCreate', () => { ...@@ -65,22 +68,12 @@ describe('Approvals ModalRuleCreate', () => {
}); });
it('renders form', () => { it('renders form', () => {
factory();
const modal = wrapper.find(GlModalVuex);
const form = modal.find(RuleForm);
expect(form.exists()).toBe(true); expect(form.exists()).toBe(true);
expect(form.props('initRule')).toEqual(null); expect(form.props('initRule')).toEqual(null);
}); });
it('when modal emits ok, submits form', () => { it('when modal emits ok, submits form', () => {
factory();
const form = wrapper.find(RuleForm);
form.vm.submit = jest.fn(); form.vm.submit = jest.fn();
const modal = wrapper.find(GlModalVuex);
modal.vm.$emit('ok', new Event('ok')); modal.vm.$emit('ok', new Event('ok'));
expect(form.vm.submit).toHaveBeenCalled(); expect(form.vm.submit).toHaveBeenCalled();
...@@ -90,27 +83,50 @@ describe('Approvals ModalRuleCreate', () => { ...@@ -90,27 +83,50 @@ describe('Approvals ModalRuleCreate', () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
createModalState.data = TEST_RULE; createModalState.data = TEST_RULE;
factory();
modal = findModal();
form = findForm();
}); });
it('renders modal', () => { it('renders modal', () => {
factory();
const modal = wrapper.find(GlModalVuex);
expect(modal.exists()).toBe(true); expect(modal.exists()).toBe(true);
expect(modal.attributes('title')).toEqual('Update approval rule'); expect(modal.attributes('title')).toEqual('Update approval rule');
expect(modal.attributes('ok-title')).toEqual('Update approval rule'); expect(modal.attributes('ok-title')).toEqual('Update approval rule');
}); });
it('renders form', () => { 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); it('renders add rule modal', () => {
const form = modal.find(RuleForm); 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.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' ...@@ -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 ProjectRules from 'ee/approvals/components/project_settings/project_rules.vue';
import RuleInput from 'ee/approvals/components/mr_edit/rule_input.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 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'; import { createProjectRules } from '../../mocks';
const TEST_RULES = createProjectRules(); const TEST_RULES = createProjectRules();
...@@ -29,11 +30,12 @@ describe('Approvals ProjectRules', () => { ...@@ -29,11 +30,12 @@ describe('Approvals ProjectRules', () => {
let wrapper; let wrapper;
let store; let store;
const factory = (props = {}) => { const factory = (props = {}, options = {}) => {
wrapper = mount(localVue.extend(ProjectRules), { wrapper = mount(localVue.extend(ProjectRules), {
propsData: props, propsData: props,
store: new Vuex.Store(store), store: new Vuex.Store(store),
localVue, localVue,
...options,
}); });
}; };
...@@ -121,5 +123,38 @@ describe('Approvals ProjectRules', () => { ...@@ -121,5 +123,38 @@ describe('Approvals ProjectRules', () => {
expect(nameCell.find('.js-help').exists()).toBeFalsy(); 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', () => { ...@@ -39,13 +39,13 @@ describe('EE Approvals RuleForm', () => {
let store; let store;
let actions; let actions;
const createComponent = (props = {}) => { const createComponent = (props = {}, options = {}) => {
wrapper = shallowMount(localVue.extend(RuleForm), { wrapper = shallowMount(localVue.extend(RuleForm), {
propsData: props, propsData: props,
store: new Vuex.Store(store), store: new Vuex.Store(store),
localVue, localVue,
provide: { provide: {
glFeatures: { scopedApprovalRules: true }, glFeatures: { scopedApprovalRules: true, ...options.provide?.glFeatures },
}, },
}); });
}; };
...@@ -482,6 +482,38 @@ describe('EE Approvals RuleForm', () => { ...@@ -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', () => { describe('with new License-Check rule', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
...@@ -494,6 +526,18 @@ describe('EE Approvals RuleForm', () => { ...@@ -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', () => { describe('with editing the License-Check rule', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
...@@ -505,6 +549,18 @@ describe('EE Approvals RuleForm', () => { ...@@ -505,6 +549,18 @@ describe('EE Approvals RuleForm', () => {
expect(findNameInput().attributes('disabled')).toBe('disabled'); 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', () => { 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', () => { ...@@ -12,13 +12,16 @@ describe('CommaSeparatedListTokenSelector', () => {
const defaultProps = { const defaultProps = {
hiddenInputId: 'comma-separated-list', hiddenInputId: 'comma-separated-list',
ariaLabelledby: 'comma-separated-list-label', ariaLabelledby: 'comma-separated-list-label',
errorMessage: 'The value entered is invalid', regexErrorMessage: 'The value entered is invalid',
disallowedValueErrorMessage: 'The value entered is not allowed', disallowedValueErrorMessage: 'The value entered is not allowed',
}; };
const createComponent = options => { const createComponent = options => {
wrapper = mount(CommaSeparatedListTokenSelector, { wrapper = mount(CommaSeparatedListTokenSelector, {
attachTo: div, attachTo: div,
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
...options, ...options,
propsData: { propsData: {
...defaultProps, ...defaultProps,
...@@ -132,6 +135,16 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -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', () => { describe('when enter key is pressed', () => {
it('does not submit the form if token selector text input has a value', async () => { it('does not submit the form if token selector text input has a value', async () => {
createComponent(); createComponent();
...@@ -145,7 +158,7 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -145,7 +158,7 @@ describe('CommaSeparatedListTokenSelector', () => {
}); });
describe('when `regexValidator` prop is set', () => { describe('when `regexValidator` prop is set', () => {
it('displays `errorMessage` if regex fails', async () => { it('displays `regexErrorMessage` if regex fails', async () => {
createComponent({ createComponent({
propsData: { propsData: {
regexValidator: /baz/, regexValidator: /baz/,
...@@ -176,8 +189,22 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -176,8 +189,22 @@ describe('CommaSeparatedListTokenSelector', () => {
}); });
}); });
describe('when `regexValidator` and `disallowedValues` props are set', () => { describe('when `customErrorMessage` prop is set', () => {
it('displays `errorMessage` if regex fails', async () => { 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({ createComponent({
propsData: { propsData: {
regexValidator: /baz/, regexValidator: /baz/,
...@@ -206,6 +233,22 @@ describe('CommaSeparatedListTokenSelector', () => { ...@@ -206,6 +233,22 @@ describe('CommaSeparatedListTokenSelector', () => {
expect(findErrorMessageText()).toBe('The value entered is not allowed'); 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', () => { ...@@ -216,38 +259,21 @@ describe('CommaSeparatedListTokenSelector', () => {
regexValidator: /foo/, regexValidator: /foo/,
disallowedValues: ['bar', 'baz'], disallowedValues: ['bar', 'baz'],
}, },
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
}); });
await setTokenSelectorInputValue('foo'); await setTokenSelectorInputValue('foo');
tokenSelectorTriggerEnter(); expect(findTokenSelectorDropdown().text()).toBe('Add "foo"');
expect(
findTokenSelector()
.find('[role="menuitem"]')
.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 () => { it('allows any value to be added', async () => {
createComponent({ createComponent();
scopedSlots: {
'user-defined-token-content': '<span>Add "{{props.inputText}}"</span>',
},
});
await setTokenSelectorInputValue('foo'); await setTokenSelectorInputValue('foo');
expect( expect(findTokenSelectorDropdown().text()).toBe('Add "foo"');
findTokenSelector()
.find('[role="menuitem"]')
.text(),
).toBe('Add "foo"');
}); });
}); });
......
...@@ -11,25 +11,8 @@ describe('security configuration module actions', () => { ...@@ -11,25 +11,8 @@ describe('security configuration module actions', () => {
let state; let state;
beforeEach(() => { beforeEach(() => {
state = createState(); state = createState({
}); securityConfigurationPath: `${TEST_HOST}/-/security/configuration.json`,
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,
},
],
[],
);
}); });
}); });
...@@ -38,7 +21,6 @@ describe('security configuration module actions', () => { ...@@ -38,7 +21,6 @@ describe('security configuration module actions', () => {
const configuration = {}; const configuration = {};
beforeEach(() => { beforeEach(() => {
state.securityConfigurationPath = `${TEST_HOST}/-/security/configuration.json`;
mock = new MockAdapter(axios); mock = new MockAdapter(axios);
}); });
......
...@@ -8,15 +8,6 @@ describe('security configuration module mutations', () => { ...@@ -8,15 +8,6 @@ describe('security configuration module mutations', () => {
state = {}; 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', () => { describe('REQUEST_SECURITY_CONFIGURATION', () => {
it('should set the isLoading to true', () => { it('should set the isLoading to true', () => {
mutations[types.REQUEST_SECURITY_CONFIGURATION](state); 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