Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-08-18 21:10:10 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-08-18 21:10:10 +0300
commit85f7fa54f404f28b0f351c2be0f7a6e9d74fe65f (patch)
treeb0f4a7578f374185fb649be904641cd79baa2ca0 /app
parenta8a9c520128bffc1157db4dc1beaa215fc731c80 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/blob/components/blob_edit_header.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue5
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue12
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue94
-rw-r--r--app/assets/javascripts/pipelines/components/dag/parsing_utils.js50
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql27
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js28
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_dag.js39
-rw-r--r--app/assets/javascripts/snippets/components/edit.vue67
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue145
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_edit.vue83
-rw-r--r--app/assets/javascripts/snippets/constants.js2
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue116
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/merge_request_query_variables.js10
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue23
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql8
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js12
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js24
-rw-r--r--app/assets/javascripts/vue_shared/components/gl_mentions.vue16
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field.vue2
-rw-r--r--app/assets/stylesheets/framework/filters.scss17
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/controllers/projects/snippets_controller.rb4
-rw-r--r--app/controllers/projects_controller.rb4
-rw-r--r--app/controllers/snippets_controller.rb4
-rw-r--r--app/models/merge_request_diff.rb15
-rw-r--r--app/policies/group_policy.rb7
-rw-r--r--app/policies/project_policy.rb7
-rw-r--r--app/views/projects/pipelines/_with_tabs.html.haml2
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml3
32 files changed, 587 insertions, 260 deletions
diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue
index 5d3a1f0ccdb..2cbbbddceeb 100644
--- a/app/assets/javascripts/blob/components/blob_edit_header.vue
+++ b/app/assets/javascripts/blob/components/blob_edit_header.vue
@@ -15,7 +15,7 @@ export default {
canDelete: {
type: Boolean,
required: false,
- default: false,
+ default: true,
},
showDelete: {
type: Boolean,
diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
index 943cee9b504..68afa2ace01 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue
@@ -138,6 +138,11 @@ export default {
</script>
<template>
+ <!--
+ This component should be replaced with a variant developed
+ as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
+ The variant will create a dropdown with an icon, no text and no caret
+ -->
<gl-new-dropdown
v-gl-tooltip
data-testid="actions-menu"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 9a0ac0f48ef..278858d3a94 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -394,15 +394,21 @@ export default {
data-qa-selector="prometheus_graph_widgets"
>
<div data-testid="dropdown-wrapper" class="d-flex align-items-center">
+ <!--
+ This component should be replaced with a variant developed
+ as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936
+ The variant will create a dropdown with an icon, no text and no caret
+ -->
<gl-dropdown
v-gl-tooltip
- toggle-class="shadow-none border-0"
+ toggle-class="gl-px-3!"
+ no-caret
data-qa-selector="prometheus_widgets_dropdown"
right
:title="__('More actions')"
>
- <template slot="button-content">
- <gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" />
+ <template #button-content>
+ <gl-icon class="gl-mr-0!" name="ellipsis_v" />
</template>
<gl-dropdown-item
v-if="expandBtnAvailable"
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 10a9703a4c7..8487da3d621 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -1,8 +1,9 @@
<script>
import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui';
import { isEmpty } from 'lodash';
-import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
+import { fetchPolicies } from '~/lib/graphql';
+import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql';
import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue';
import {
@@ -27,23 +28,58 @@ export default {
GlEmptyState,
GlButton,
},
- props: {
- graphUrl: {
- type: String,
- required: false,
- default: '',
+ inject: {
+ dagDocPath: {
+ default: null,
},
emptySvgPath: {
- type: String,
- required: true,
default: '',
},
- dagDocPath: {
- type: String,
- required: true,
+ pipelineIid: {
+ default: '',
+ },
+ pipelineProjectPath: {
default: '',
},
},
+ apollo: {
+ graphData: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query: getDagVisData,
+ variables() {
+ return {
+ projectPath: this.pipelineProjectPath,
+ iid: this.pipelineIid,
+ };
+ },
+ update(data) {
+ const {
+ stages: { nodes: stages },
+ } = data.project.pipeline;
+
+ const unwrappedGroups = stages
+ .map(({ name, groups: { nodes: groups } }) => {
+ return groups.map(group => {
+ return { category: name, ...group };
+ });
+ })
+ .flat(2);
+
+ const nodes = unwrappedGroups.map(group => {
+ const jobs = group.jobs.nodes.map(({ name, needs }) => {
+ return { name, needs: needs.nodes.map(need => need.name) };
+ });
+
+ return { ...group, jobs };
+ });
+
+ return nodes;
+ },
+ error() {
+ this.reportFailure(LOAD_FAILURE);
+ },
+ },
+ },
data() {
return {
annotationsMap: {},
@@ -90,32 +126,20 @@ export default {
default:
return {
text: this.$options.errorTexts[DEFAULT],
- vatiant: 'danger',
+ variant: 'danger',
};
}
},
+ processedData() {
+ return this.processGraphData(this.graphData);
+ },
shouldDisplayAnnotations() {
return !isEmpty(this.annotationsMap);
},
shouldDisplayGraph() {
- return Boolean(!this.showFailureAlert && this.graphData);
+ return Boolean(!this.showFailureAlert && !this.hasNoDependentJobs && this.graphData);
},
},
- mounted() {
- const { processGraphData, reportFailure } = this;
-
- if (!this.graphUrl) {
- reportFailure();
- return;
- }
-
- axios
- .get(this.graphUrl)
- .then(response => {
- processGraphData(response.data);
- })
- .catch(() => reportFailure(LOAD_FAILURE));
- },
methods: {
addAnnotationToMap({ uid, source, target }) {
this.$set(this.annotationsMap, uid, { source, target });
@@ -124,25 +148,25 @@ export default {
let parsed;
try {
- parsed = parseData(data.stages);
+ parsed = parseData(data);
} catch {
this.reportFailure(PARSE_FAILURE);
- return;
+ return {};
}
if (parsed.links.length === 1) {
this.reportFailure(UNSUPPORTED_DATA);
- return;
+ return {};
}
// If there are no links, we don't report failure
// as it simply means the user does not use job dependencies
if (parsed.links.length === 0) {
this.hasNoDependentJobs = true;
- return;
+ return {};
}
- this.graphData = parsed;
+ return parsed;
},
hideAlert() {
this.showFailureAlert = false;
@@ -182,7 +206,7 @@ export default {
<dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" />
<dag-graph
v-if="shouldDisplayGraph"
- :graph-data="graphData"
+ :graph-data="processedData"
@onFailure="reportFailure"
@update-annotation="updateAnnotation"
/>
@@ -209,7 +233,7 @@ export default {
</p>
</div>
</template>
- <template #actions>
+ <template v-if="dagDocPath" #actions>
<gl-button :href="dagDocPath" target="__blank" variant="success">
{{ $options.emptyStateTexts.button }}
</gl-button>
diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
index 3234f80ee91..1ed415688f2 100644
--- a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
@@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash';
received from the endpoint into the format the d3 graph expects.
Input is of the form:
- [stages]
- stages: {name, groups}
- groups: [{ name, size, jobs }]
- name is a group name; in the case that the group has one job, it is
- also the job name
- size is the number of parallel jobs
- jobs: [{ name, needs}]
- job name is either the same as the group name or group x/y
+ [nodes]
+ nodes: [{category, name, jobs, size}]
+ category is the stage name
+ name is a group name; in the case that the group has one job, it is
+ also the job name
+ size is the number of parallel jobs
+ jobs: [{ name, needs}]
+ job name is either the same as the group name or group x/y
+ needs: [job-names]
+ needs is an array of job-name strings
Output is of the form:
{ nodes: [node], links: [link] }
@@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash';
link: { source, target, value }, with source & target being node names
and value being a constant
- We create nodes, create links, and then dedupe the links, so that in the case where
+ We create nodes in the GraphQL update function, and then here we create the node dictionary,
+ then create links, and then dedupe the links, so that in the case where
job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link
from job 1 to job 2 then another from job 2 to job 4.
- CREATE NODES
- stage.name -> node.category
- stage.group.name -> node.name (this is the group name if there are parallel jobs)
- stage.group.jobs -> node.jobs
- stage.group.size -> node.size
-
CREATE LINKS
- stages.groups.name -> target
- stages.groups.needs.each -> source (source is the name of the group, not the parallel job)
+ nodes.name -> target
+ nodes.name.needs.each -> source (source is the name of the group, not the parallel job)
10 -> value (constant)
*/
-export const createNodes = data => {
- return data.flatMap(({ groups, name }) => {
- return groups.map(group => {
- return { ...group, category: name };
- });
- });
-};
-
export const createNodeDict = nodes => {
return nodes.reduce((acc, node) => {
const newNode = {
@@ -62,13 +51,6 @@ export const createNodeDict = nodes => {
}, {});
};
-export const createNodesStructure = data => {
- const nodes = createNodes(data);
- const nodeDict = createNodeDict(nodes);
-
- return { nodes, nodeDict };
-};
-
export const makeLinksFromNodes = (nodes, nodeDict) => {
const constantLinkValue = 10; // all links are the same weight
return nodes
@@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) =>
return !allAncestors.includes(source);
});
-export const parseData = data => {
- const { nodes, nodeDict } = createNodesStructure(data);
+export const parseData = nodes => {
+ const nodeDict = createNodeDict(nodes);
const allLinks = makeLinksFromNodes(nodes, nodeDict);
const filteredLinks = filterByAncestors(allLinks, nodeDict);
const links = uniqWith(filteredLinks, isEqual);
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
new file mode 100644
index 00000000000..c73b186739e
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
@@ -0,0 +1,27 @@
+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
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 127d24c5473..e154c3f2422 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -4,7 +4,7 @@ import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue';
-import Dag from './components/dag/dag.vue';
+import createDagApp from './pipeline_details_dag';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
@@ -114,32 +114,6 @@ const createTestDetails = () => {
});
};
-const createDagApp = () => {
- if (!window.gon?.features?.dagPipelineTab) {
- return;
- }
-
- const el = document.querySelector('#js-pipeline-dag-vue');
- const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset;
-
- // eslint-disable-next-line no-new
- new Vue({
- el,
- components: {
- Dag,
- },
- render(createElement) {
- return createElement('dag', {
- props: {
- graphUrl: pipelineDataPath,
- emptySvgPath,
- dagDocPath,
- },
- });
- },
- });
-};
-
export default () => {
const { dataset } = document.querySelector('.js-pipeline-details-vue');
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js
new file mode 100644
index 00000000000..dc03b457265
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js
@@ -0,0 +1,39 @@
+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;
diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue
index 81be39bda04..e1b57f35134 100644
--- a/app/assets/javascripts/snippets/components/edit.vue
+++ b/app/assets/javascripts/snippets/components/edit.vue
@@ -14,9 +14,6 @@ import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR,
- SNIPPET_BLOB_ACTION_CREATE,
- SNIPPET_BLOB_ACTION_UPDATE,
- SNIPPET_BLOB_ACTION_MOVE,
} from '../constants';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
import SnippetVisibilityEdit from './snippet_visibility_edit.vue';
@@ -56,25 +53,20 @@ export default {
},
data() {
return {
- blobsActions: {},
isUpdating: false,
newSnippet: false,
+ actions: [],
};
},
computed: {
- getActionsEntries() {
- return Object.values(this.blobsActions);
+ hasBlobChanges() {
+ return this.actions.length > 0;
},
- allBlobsHaveContent() {
- const entries = this.getActionsEntries;
- return entries.length > 0 && !entries.find(action => !action.content);
- },
- allBlobChangesRegistered() {
- const entries = this.getActionsEntries;
- return entries.length > 0 && !entries.find(action => action.action === '');
+ hasValidBlobs() {
+ return this.actions.every(x => x.filePath && x.content);
},
updatePrevented() {
- return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating;
+ return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating;
},
isProjectSnippet() {
return Boolean(this.projectPath);
@@ -85,7 +77,7 @@ export default {
title: this.snippet.title,
description: this.snippet.description,
visibilityLevel: this.snippet.visibilityLevel,
- blobActions: this.getActionsEntries.filter(entry => entry.action !== ''),
+ blobActions: this.actions,
};
},
saveButtonLabel() {
@@ -120,48 +112,11 @@ export default {
onBeforeUnload(e = {}) {
const returnValue = __('Are you sure you want to lose unsaved changes?');
- if (!this.allBlobChangesRegistered || this.isUpdating) return undefined;
+ if (!this.hasBlobChanges || this.isUpdating) return undefined;
Object.assign(e, { returnValue });
return returnValue;
},
- updateBlobActions(args = {}) {
- // `_constants` is the internal prop that
- // should not be sent to the mutation. Hence we filter it out from
- // the argsToUpdateAction that is the data-basis for the mutation.
- const { _constants: blobConstants, ...argsToUpdateAction } = args;
- const { previousPath, filePath, content } = argsToUpdateAction;
- let actionEntry = this.blobsActions[blobConstants.id] || {};
- let tunedActions = {
- action: '',
- previousPath,
- };
-
- if (this.newSnippet) {
- // new snippet, hence new blob
- tunedActions = {
- action: SNIPPET_BLOB_ACTION_CREATE,
- previousPath: '',
- };
- } else if (previousPath && filePath) {
- // renaming of a blob + renaming & content update
- const renamedToOriginal = filePath === blobConstants.originalPath;
- tunedActions = {
- action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE,
- previousPath: !renamedToOriginal ? blobConstants.originalPath : '',
- };
- } else if (content !== blobConstants.originalContent) {
- // content update only
- tunedActions = {
- action: SNIPPET_BLOB_ACTION_UPDATE,
- previousPath: '',
- };
- }
-
- actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions };
-
- this.$set(this.blobsActions, blobConstants.id, actionEntry);
- },
flashAPIFailure(err) {
const defaultErrorMsg = this.newSnippet
? SNIPPET_CREATE_MUTATION_ERROR
@@ -218,7 +173,6 @@ export default {
if (errors.length) {
this.flashAPIFailure(errors[0]);
} else {
- this.originalContent = this.content;
redirectTo(baseObj.snippet.webUrl);
}
})
@@ -226,6 +180,9 @@ export default {
this.flashAPIFailure(e);
});
},
+ updateActions(actions) {
+ this.actions = actions;
+ },
},
newSnippetSchema: {
title: '',
@@ -261,7 +218,7 @@ export default {
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
/>
- <snippet-blob-actions-edit :blobs="blobs" @blob-updated="updateBlobActions" />
+ <snippet-blob-actions-edit :init-blobs="blobs" @actions="updateActions" />
<snippet-visibility-edit
v-model="snippet.visibilityLevel"
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
index fd81a5fa69c..55cd13a6930 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue
@@ -1,25 +1,156 @@
<script>
+import { GlButton } from '@gitlab/ui';
+import { cloneDeep } from 'lodash';
+import { s__, sprintf } from '~/locale';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import SnippetBlobEdit from './snippet_blob_edit.vue';
+import { SNIPPET_MAX_BLOBS } from '../constants';
+import { createBlob, decorateBlob, diffAll } from '../utils/blob';
export default {
components: {
SnippetBlobEdit,
+ GlButton,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
- blobs: {
+ initBlobs: {
type: Array,
required: true,
},
},
+ data() {
+ return {
+ // This is a dictionary (by .id) of the original blobs and
+ // is used as the baseline for calculating diffs
+ // (e.g., what has been deleted, changed, renamed, etc.)
+ blobsOrig: {},
+ // This is a dictionary (by .id) of the current blobs and
+ // is updated as the user makes changes.
+ blobs: {},
+ // This is a list of blob ID's in order how they should be
+ // presented.
+ blobIds: [],
+ };
+ },
+ computed: {
+ actions() {
+ return diffAll(this.blobs, this.blobsOrig);
+ },
+ count() {
+ return this.blobIds.length;
+ },
+ addLabel() {
+ return sprintf(s__('Snippets|Add another file %{num}/%{total}'), {
+ num: this.count,
+ total: SNIPPET_MAX_BLOBS,
+ });
+ },
+ canDelete() {
+ return this.count > 1;
+ },
+ canAdd() {
+ return this.count < SNIPPET_MAX_BLOBS;
+ },
+ hasMultiFilesEnabled() {
+ return this.glFeatures.snippetMultipleFiles;
+ },
+ filesLabel() {
+ return this.hasMultiFilesEnabled ? s__('Snippets|Files') : s__('Snippets|File');
+ },
+ firstInputId() {
+ const blobId = this.blobIds[0];
+
+ if (!blobId) {
+ return '';
+ }
+
+ return `${blobId}_file_path`;
+ },
+ },
+ watch: {
+ actions: {
+ immediate: true,
+ handler(val) {
+ this.$emit('actions', val);
+ },
+ },
+ },
+ created() {
+ const blobs = this.initBlobs.map(decorateBlob);
+ const blobsById = blobs.reduce((acc, x) => Object.assign(acc, { [x.id]: x }), {});
+
+ this.blobsOrig = blobsById;
+ this.blobs = cloneDeep(blobsById);
+ this.blobIds = blobs.map(x => x.id);
+
+ // Show 1 empty blob if none exist
+ if (!this.blobIds.length) {
+ this.addBlob();
+ }
+ },
+ methods: {
+ updateBlobContent(id, content) {
+ const origBlob = this.blobsOrig[id];
+ const blob = this.blobs[id];
+
+ blob.content = content;
+
+ // If we've received content, but we haven't loaded the content before
+ // then this is also the original content.
+ if (origBlob && !origBlob.isLoaded) {
+ blob.isLoaded = true;
+ origBlob.isLoaded = true;
+ origBlob.content = content;
+ }
+ },
+ updateBlobFilePath(id, path) {
+ const blob = this.blobs[id];
+
+ blob.path = path;
+ },
+ addBlob() {
+ const blob = createBlob();
+
+ this.$set(this.blobs, blob.id, blob);
+ this.blobIds.push(blob.id);
+ },
+ deleteBlob(id) {
+ this.blobIds = this.blobIds.filter(x => x !== id);
+ this.$delete(this.blobs, id);
+ },
+ updateBlob(id, args) {
+ if ('content' in args) {
+ this.updateBlobContent(id, args.content);
+ }
+ if ('path' in args) {
+ this.updateBlobFilePath(id, args.path);
+ }
+ },
+ },
};
</script>
-
<template>
<div class="form-group file-editor">
- <label for="snippet_file_path">{{ s__('Snippets|File') }}</label>
- <template v-if="blobs.length">
- <snippet-blob-edit v-for="blob in blobs" :key="blob.name" :blob="blob" v-on="$listeners" />
- </template>
- <snippet-blob-edit v-else v-on="$listeners" />
+ <label :for="firstInputId">{{ filesLabel }}</label>
+ <snippet-blob-edit
+ v-for="(blobId, index) in blobIds"
+ :key="blobId"
+ :class="{ 'gl-mt-3': index > 0 }"
+ :blob="blobs[blobId]"
+ :can-delete="canDelete"
+ :show-delete="hasMultiFilesEnabled"
+ @blob-updated="updateBlob(blobId, $event)"
+ @delete="deleteBlob(blobId)"
+ />
+ <gl-button
+ v-if="hasMultiFilesEnabled"
+ :disabled="!canAdd"
+ data-testid="add_button"
+ class="gl-my-3"
+ variant="dashed"
+ @click="addBlob"
+ >{{ addLabel }}</gl-button
+ >
</div>
</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
index b349069d73a..5066b7f3ed6 100644
--- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
+++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue
@@ -8,12 +8,6 @@ import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants';
import Flash from '~/flash';
import { sprintf } from '~/locale';
-function localId() {
- return Math.floor((1 + Math.random()) * 0x10000)
- .toString(16)
- .substring(1);
-}
-
export default {
components: {
BlobHeaderEdit,
@@ -24,49 +18,35 @@ export default {
props: {
blob: {
type: Object,
+ required: true,
+ },
+ canDelete: {
+ type: Boolean,
required: false,
- default: null,
- validator: ({ rawPath }) => Boolean(rawPath),
+ default: true,
},
- },
- data() {
- return {
- id: localId(),
- filePath: this.blob?.path || '',
- previousPath: '',
- originalPath: this.blob?.path || '',
- content: this.blob?.content || '',
- originalContent: '',
- isContentLoading: this.blob,
- };
- },
- watch: {
- filePath(filePath, previousPath) {
- this.previousPath = previousPath;
- this.notifyAboutUpdates({ previousPath });
+ showDelete: {
+ type: Boolean,
+ required: false,
+ default: false,
},
- content() {
- this.notifyAboutUpdates();
+ },
+ computed: {
+ inputId() {
+ return `${this.blob.id}_file_path`;
},
},
mounted() {
- if (this.blob) {
+ if (!this.blob.isLoaded) {
this.fetchBlobContent();
}
},
methods: {
+ onDelete() {
+ this.$emit('delete');
+ },
notifyAboutUpdates(args = {}) {
- const { filePath, previousPath } = args;
- this.$emit('blob-updated', {
- filePath: filePath || this.filePath,
- previousPath: previousPath || this.previousPath,
- content: this.content,
- _constants: {
- originalPath: this.originalPath,
- originalContent: this.originalContent,
- id: this.id,
- },
- });
+ this.$emit('blob-updated', args);
},
fetchBlobContent() {
const baseUrl = getBaseURL();
@@ -75,17 +55,12 @@ export default {
axios
.get(url)
.then(res => {
- this.originalContent = res.data;
- this.content = res.data;
+ this.notifyAboutUpdates({ content: res.data });
})
- .catch(e => this.flashAPIFailure(e))
- .finally(() => {
- this.isContentLoading = false;
- });
+ .catch(e => this.flashAPIFailure(e));
},
flashAPIFailure(err) {
Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err }));
- this.isContentLoading = false;
},
},
};
@@ -93,16 +68,26 @@ export default {
<template>
<div class="file-holder snippet">
<blob-header-edit
- id="snippet_file_path"
- v-model="filePath"
+ :id="inputId"
+ :value="blob.path"
data-qa-selector="file_name_field"
+ :can-delete="canDelete"
+ :show-delete="showDelete"
+ @input="notifyAboutUpdates({ path: $event })"
+ @delete="onDelete"
/>
<gl-loading-icon
- v-if="isContentLoading"
+ v-if="!blob.isLoaded"
:label="__('Loading snippet')"
size="lg"
class="loading-animation prepend-top-20 append-bottom-20"
/>
- <blob-content-edit v-else v-model="content" :file-global-id="id" :file-name="filePath" />
+ <blob-content-edit
+ v-else
+ :value="blob.content"
+ :file-global-id="blob.id"
+ :file-name="blob.path"
+ @input="notifyAboutUpdates({ content: $event })"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js
index a59d7aa84eb..12b83525bf7 100644
--- a/app/assets/javascripts/snippets/constants.js
+++ b/app/assets/javascripts/snippets/constants.js
@@ -31,3 +31,5 @@ export const SNIPPET_BLOB_ACTION_CREATE = 'create';
export const SNIPPET_BLOB_ACTION_UPDATE = 'update';
export const SNIPPET_BLOB_ACTION_MOVE = 'move';
export const SNIPPET_BLOB_ACTION_DELETE = 'delete';
+
+export const SNIPPET_MAX_BLOBS = 10;
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
index 6837545c681..0db90f281cd 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue
@@ -3,6 +3,11 @@ import $ from 'jquery';
import { GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
+import getStateQuery from '../../queries/get_state.query.graphql';
+import workInProgressQuery from '../../queries/states/work_in_progress.query.graphql';
+import removeWipMutation from '../../queries/toggle_wip.mutation.graphql';
import StatusIcon from '../mr_widget_status_icon.vue';
import tooltip from '../../../vue_shared/directives/tooltip';
import eventHub from '../../event_hub';
@@ -16,38 +21,127 @@ export default {
directives: {
tooltip,
},
+ mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
+ apollo: {
+ userPermissions: {
+ query: workInProgressQuery,
+ skip() {
+ return !this.glFeatures.mergeRequestWidgetGraphql;
+ },
+ variables() {
+ return this.mergeRequestQueryVariables;
+ },
+ update: data => data.project.mergeRequest.userPermissions,
+ },
+ },
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
},
data() {
return {
+ userPermissions: {},
isMakingRequest: false,
};
},
+ computed: {
+ canUpdate() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ return this.userPermissions.updateMergeRequest;
+ }
+
+ return Boolean(this.mr.removeWIPPath);
+ },
+ },
methods: {
- handleRemoveWIP() {
+ removeWipMutation() {
this.isMakingRequest = true;
- this.service
- .removeWIP()
- .then(res => res.data)
- .then(data => {
- eventHub.$emit('UpdateWidgetData', data);
+
+ 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(this.mr.title);
+ $('.merge-request .detail-page-description .title').text(title);
})
- .catch(() => {
+ .catch(() => createFlash(__('Something went wrong. Please try again.')))
+ .finally(() => {
this.isMakingRequest = false;
- createFlash(__('Something went wrong. Please try again.'));
});
},
+ handleRemoveWIP() {
+ if (this.glFeatures.mergeRequestWidgetGraphql) {
+ this.removeWipMutation();
+ } else {
+ this.isMakingRequest = true;
+ this.service
+ .removeWIP()
+ .then(res => res.data)
+ .then(data => {
+ eventHub.$emit('UpdateWidgetData', data);
+ createFlash(__('The merge request can now be merged.'), 'notice');
+ $('.merge-request .detail-page-description .title').text(this.mr.title);
+ })
+ .catch(() => {
+ this.isMakingRequest = false;
+ createFlash(__('Something went wrong. Please try again.'));
+ });
+ }
+ },
},
};
</script>
<template>
<div class="mr-widget-body media">
- <status-icon :show-disabled-button="Boolean(mr.removeWIPPath)" status="warning" />
+ <status-icon :show-disabled-button="canUpdate" status="warning" />
<div class="media-body">
<div class="gl-ml-3 float-left">
<span class="gl-font-weight-bold">
@@ -58,7 +152,7 @@ export default {
}}</span>
</div>
<gl-button
- v-if="mr.removeWIPPath"
+ v-if="canUpdate"
size="small"
:disabled="isMakingRequest"
:loading="isMakingRequest"
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/merge_request_query_variables.js b/app/assets/javascripts/vue_merge_request_widget/mixins/merge_request_query_variables.js
new file mode 100644
index 00000000000..3a121908f36
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/merge_request_query_variables.js
@@ -0,0 +1,10 @@
+export default {
+ computed: {
+ mergeRequestQueryVariables() {
+ return {
+ projectPath: this.mr.targetProjectFullPath,
+ iid: `${this.mr.iid}`,
+ };
+ },
+ },
+};
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 34c7e1568db..cb8d9bd7791 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -8,6 +8,7 @@ import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
import createFlash from '../flash';
+import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables';
import Loading from './components/loading.vue';
import WidgetHeader from './components/mr_widget_header.vue';
import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue';
@@ -42,6 +43,7 @@ import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_
import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue';
import { setFaviconOverlay } from '../lib/utils/common_utils';
import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue';
+import getStateQuery from './queries/get_state.query.graphql';
export default {
el: '#js-vue-mr-widget',
@@ -83,6 +85,27 @@ export default {
GroupedAccessibilityReportsApp,
MrWidgetApprovals,
},
+ apollo: {
+ state: {
+ query: getStateQuery,
+ manual: true,
+ pollInterval: 10 * 1000,
+ skip() {
+ return !this.mr || !window.gon?.features?.mergeRequestWidgetGraphql;
+ },
+ variables() {
+ return this.mergeRequestQueryVariables;
+ },
+ result({
+ data: {
+ project: { mergeRequest },
+ },
+ }) {
+ this.mr.setGraphqlData(mergeRequest);
+ },
+ },
+ },
+ mixins: [mergeRequestQueryVariablesMixin],
props: {
mrData: {
type: Object,
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
new file mode 100644
index 00000000000..488397e7735
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql
@@ -0,0 +1,8 @@
+query getState($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ title
+ workInProgress
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql
new file mode 100644
index 00000000000..73e205ebf2b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql
@@ -0,0 +1,9 @@
+query workInProgressQuery($projectPath: ID!, $iid: String!) {
+ project(fullPath: $projectPath) {
+ mergeRequest(iid: $iid) {
+ userPermissions {
+ updateMergeRequest
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
new file mode 100644
index 00000000000..37abe5ddf3c
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql
@@ -0,0 +1,9 @@
+mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) {
+ mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) {
+ mergeRequest {
+ title
+ workInProgress
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
index 44e8167d6a3..3bd512c89bf 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js
@@ -1,21 +1,21 @@
import { stateKey } from './state_maps';
-export default function deviseState(data) {
- if (data.project_archived) {
+export default function deviseState() {
+ if (this.projectArchived) {
return stateKey.archived;
- } else if (data.branch_missing) {
+ } else if (this.branchMissing) {
return stateKey.missingBranch;
- } else if (!data.commits_count) {
+ } else if (!this.commitsCount) {
return stateKey.nothingToMerge;
} else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') {
return stateKey.checking;
- } else if (data.has_conflicts) {
+ } else if (this.hasConflicts) {
return stateKey.conflicts;
} else if (this.shouldBeRebased) {
return stateKey.rebase;
} else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) {
return stateKey.pipelineFailed;
- } else if (data.work_in_progress) {
+ } else if (this.workInProgress) {
return stateKey.workInProgress;
} else if (this.hasMergeableDiscussionsState) {
return stateKey.unresolvedDiscussions;
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 28b41b64ff6..8c98ba1b023 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -60,6 +60,9 @@ export default class MergeRequestStore {
this.rebaseInProgress = data.rebase_in_progress;
this.mergeRequestDiffsPath = data.diffs_path;
this.approvalsWidgetType = data.approvals_widget_type;
+ this.projectArchived = data.project_archived;
+ this.branchMissing = data.branch_missing;
+ this.hasConflicts = data.has_conflicts;
if (data.issues_links) {
const links = data.issues_links;
@@ -90,7 +93,8 @@ export default class MergeRequestStore {
this.ffOnlyEnabled = data.ff_only_enabled;
this.shouldBeRebased = Boolean(data.should_be_rebased);
this.isRemovingSourceBranch = this.isRemovingSourceBranch || false;
- this.isOpen = data.state === 'opened';
+ this.mergeRequestState = data.state;
+ this.isOpen = this.mergeRequestState === 'opened';
this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false;
this.isSHAMismatch = this.sha !== data.diff_head_sha;
this.latestSHA = data.diff_head_sha;
@@ -133,6 +137,10 @@ export default class MergeRequestStore {
this.mergeCommitPath = data.merge_commit_path;
this.canPushToSourceBranch = data.can_push_to_source_branch;
+ if (data.work_in_progress !== undefined) {
+ this.workInProgress = data.work_in_progress;
+ }
+
const currentUser = data.current_user;
this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path;
@@ -143,19 +151,25 @@ export default class MergeRequestStore {
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false;
- this.setState(data);
+ this.setState();
+ }
+
+ setGraphqlData(data) {
+ this.workInProgress = data.workInProgress;
+
+ this.setState();
}
- setState(data) {
+ setState() {
if (this.mergeOngoing) {
this.state = 'merging';
return;
}
if (this.isOpen) {
- this.state = getStateKey.call(this, data);
+ this.state = getStateKey.call(this);
} else {
- switch (data.state) {
+ switch (this.mergeRequestState) {
case 'merged':
this.state = 'merged';
break;
diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
index 5d97d364e94..00bc46257ed 100644
--- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue
+++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue
@@ -6,6 +6,7 @@ import { spriteIcon } from '~/lib/utils/common_utils';
import SidebarMediator from '~/sidebar/sidebar_mediator';
const AutoComplete = {
+ Issues: 'issues',
Labels: 'labels',
Members: 'members',
};
@@ -17,6 +18,14 @@ function doesCurrentLineStartWith(searchString, fullText, selectionStart) {
}
const autoCompleteMap = {
+ [AutoComplete.Issues]: {
+ filterValues() {
+ return this[AutoComplete.Issues];
+ },
+ menuItemTemplate({ original }) {
+ return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`;
+ },
+ },
[AutoComplete.Labels]: {
filterValues() {
const fullText = this.$slots.default?.[0]?.elm?.value;
@@ -107,6 +116,13 @@ export default {
this.tribute = new Tribute({
collection: [
{
+ trigger: '#',
+ lookup: value => value.iid + value.title,
+ menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate,
+ selectTemplate: ({ original }) => original.reference || `#${original.iid}`,
+ values: this.getValues(AutoComplete.Issues),
+ },
+ {
trigger: '@',
fillAttr: 'username',
lookup: value => value.name + value.username,
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index c354228ce74..8a32de0a2e4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -167,7 +167,7 @@ export default {
return new GLForm($(this.$refs['gl-form']), {
emojis: this.enableAutocomplete,
members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
- issues: this.enableAutocomplete,
+ issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
mergeRequests: this.enableAutocomplete,
epics: this.enableAutocomplete,
milestones: this.enableAutocomplete,
diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss
index 2e87724a9e7..ed4281123cd 100644
--- a/app/assets/stylesheets/framework/filters.scss
+++ b/app/assets/stylesheets/framework/filters.scss
@@ -307,23 +307,6 @@
color: $gl-text-color;
border-color: $border-color;
}
-
- svg {
- height: 14px;
- width: 14px;
- vertical-align: middle;
- margin-bottom: 4px;
- }
-
- .dropdown-toggle-text {
- display: inline-block;
- color: inherit;
-
- .fa {
- vertical-align: middle;
- color: inherit;
- }
- }
}
.filtered-search-history-dropdown {
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index b217b366ee5..e77d2f0f5ee 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -39,6 +39,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:auto_expand_collapsed_diffs, @project, default_enabled: true)
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true)
+ push_frontend_feature_flag(:merge_request_widget_graphql, @project)
end
before_action do
diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb
index 49840e847f2..632e8db9796 100644
--- a/app/controllers/projects/snippets_controller.rb
+++ b/app/controllers/projects/snippets_controller.rb
@@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController
before_action :authorize_update_snippet!, only: [:edit, :update]
before_action :authorize_admin_snippet!, only: [:destroy]
+ before_action do
+ push_frontend_feature_flag(:snippet_multiple_files, current_user)
+ end
+
def index
@snippet_counts = ::Snippets::CountService
.new(current_user, project: @project)
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 10a23c0539e..ba21fbddde1 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -44,6 +44,10 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:service_desk_custom_address, @project)
end
+ before_action only: [:edit] do
+ push_frontend_feature_flag(:approval_suggestions, @project)
+ end
+
layout :determine_layout
def index
diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb
index e68b821459d..486c7f1d028 100644
--- a/app/controllers/snippets_controller.rb
+++ b/app/controllers/snippets_controller.rb
@@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController
layout 'snippets'
+ before_action do
+ push_frontend_feature_flag(:snippet_multiple_files, current_user)
+ end
+
def index
if params[:username].present?
@user = UserFinder.new(params[:username]).find_by_username!
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index fdc55063433..b70340a98cd 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -51,14 +51,16 @@ class MergeRequestDiff < ApplicationRecord
scope :by_commit_sha, ->(sha) do
joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil)
end
- scope :has_diff_files, -> { where(id: MergeRequestDiffFile.select(:merge_request_diff_id)) }
scope :by_project_id, -> (project_id) do
joins(:merge_request).where(merge_requests: { target_project_id: project_id })
end
scope :recent, -> { order(id: :desc).limit(100) }
- scope :files_in_database, -> { where(stored_externally: [false, nil]) }
+
+ scope :files_in_database, -> do
+ where(stored_externally: [false, nil]).where(arel_table[:files_count].gt(0))
+ end
scope :not_latest_diffs, -> do
merge_requests = MergeRequest.arel_table
@@ -115,29 +117,28 @@ class MergeRequestDiff < ApplicationRecord
end
def ids_for_external_storage_migration_strategy_always(limit:)
- has_diff_files.files_in_database.limit(limit).pluck(:id)
+ files_in_database.limit(limit).pluck(:id)
end
def ids_for_external_storage_migration_strategy_outdated(limit:)
# Outdated is too complex to be a single SQL query, so split into three
before = EXTERNAL_DIFF_CUTOFF.ago
- potentials = has_diff_files.files_in_database
- ids = potentials
+ ids = files_in_database
.old_merged_diffs(before)
.limit(limit)
.pluck(:id)
return ids if ids.size >= limit
- ids += potentials
+ ids += files_in_database
.old_closed_diffs(before)
.limit(limit - ids.size)
.pluck(:id)
return ids if ids.size >= limit
- ids + potentials
+ ids + files_in_database
.not_latest_diffs
.limit(limit - ids.size)
.pluck(:id)
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index 42545cffc61..3cc1be9dfb7 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -167,6 +167,7 @@ class GroupPolicy < BasePolicy
def access_level
return GroupMember::NO_ACCESS if @user.nil?
+ return GroupMember::NO_ACCESS unless user_is_user?
@access_level ||= lookup_access_level!
end
@@ -174,6 +175,12 @@ class GroupPolicy < BasePolicy
def lookup_access_level!
@subject.max_member_access_for_user(@user)
end
+
+ private
+
+ def user_is_user?
+ user.is_a?(User)
+ end
end
GroupPolicy.prepend_if_ee('EE::GroupPolicy')
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 21a73185df4..081cc018b5b 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -589,8 +589,13 @@ class ProjectPolicy < BasePolicy
private
+ def user_is_user?
+ user.is_a?(User)
+ end
+
def team_member?
return false if @user.nil?
+ return false unless user_is_user?
greedy_load_subject = false
@@ -618,6 +623,7 @@ class ProjectPolicy < BasePolicy
# rubocop: disable CodeReuse/ActiveRecord
def project_group_member?
return false if @user.nil?
+ return false unless user_is_user?
project.group &&
(
@@ -629,6 +635,7 @@ class ProjectPolicy < BasePolicy
def team_access_level
return -1 if @user.nil?
+ return -1 unless user_is_user?
lookup_access_level!
end
diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml
index 9199fdb99d6..4ae06e1e16f 100644
--- a/app/views/projects/pipelines/_with_tabs.html.haml
+++ b/app/views/projects/pipelines/_with_tabs.html.haml
@@ -81,7 +81,7 @@
- if dag_pipeline_tab_enabled
#js-tab-dag.tab-pane
- #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline), empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
+ #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} }
#js-tab-tests.tab-pane
#js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json),
diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml
index ea3ca278426..3f3b9146e71 100644
--- a/app/views/shared/issuable/_search_bar.html.haml
+++ b/app/views/shared/issuable/_search_bar.html.haml
@@ -20,7 +20,8 @@
.issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row
.filtered-search-box
- if type != :boards_modal && type != :boards
- = dropdown_tag(_('Recent searches'),
+ - text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline")
+ = dropdown_tag(text,
options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
toggle_class: "btn filtered-search-history-dropdown-toggle-button",
dropdown_class: "filtered-search-history-dropdown",