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>2021-01-14 21:10:59 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-01-14 21:10:59 +0300
commitea3306a15e945e694afba62dc93b17500ffaec7f (patch)
treeaa444f7fe503e5650bfdb6500ed428b4619ff37b /app
parent8106ac487c3b52471e2ca894c65c13162c2fb1a8 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/diffs/components/diff_line_note_form.vue11
-rw-r--r--app/assets/javascripts/diffs/components/diff_row.vue79
-rw-r--r--app/assets/javascripts/diffs/components/diff_row_utils.js6
-rw-r--r--app/assets/javascripts/diffs/components/diff_view.vue39
-rw-r--r--app/assets/javascripts/diffs/components/inline_diff_table_row.vue7
-rw-r--r--app/assets/javascripts/diffs/components/parallel_diff_table_row.vue24
-rw-r--r--app/assets/javascripts/diffs/store/utils.js9
-rw-r--r--app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue4
-rw-r--r--app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue2
-rw-r--r--app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue4
-rw-r--r--app/assets/javascripts/members/components/modals/leave_modal.vue4
-rw-r--r--app/assets/javascripts/members/components/table/member_source.vue2
-rw-r--r--app/assets/javascripts/notes/components/multiline_comment_form.vue7
-rw-r--r--app/assets/javascripts/notes/stores/modules/index.js2
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app.vue242
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue164
-rw-r--r--app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue176
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss2
-rw-r--r--app/assets/stylesheets/framework/variables.scss3
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb2
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/helpers/emails_helper.rb23
-rw-r--r--app/helpers/groups/group_members_helper.rb80
-rw-r--r--app/mailers/emails/members.rb17
-rw-r--r--app/models/commit_status.rb2
-rw-r--r--app/models/member.rb2
-rw-r--r--app/models/members/group_member.rb4
-rw-r--r--app/presenters/packages/nuget/service_index_presenter.rb4
-rw-r--r--app/serializers/diffs_metadata_entity.rb22
-rw-r--r--app/serializers/member_entity.rb54
-rw-r--r--app/serializers/member_serializer.rb5
-rw-r--r--app/serializers/member_user_entity.rb28
-rw-r--r--app/services/notification_service.rb6
-rw-r--r--app/views/admin/dev_ops_report/show.html.haml3
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml4
-rw-r--r--app/views/notify/member_expiration_date_updated_email.html.haml6
-rw-r--r--app/views/notify/member_expiration_date_updated_email.text.erb5
-rw-r--r--app/views/projects/issues/_design_management.html.haml2
38 files changed, 630 insertions, 427 deletions
diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 6e22e181c02..463b7f5cff4 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -56,10 +56,11 @@ export default {
},
computed: {
...mapState({
- noteableData: (state) => state.notes.noteableData,
- diffViewType: (state) => state.diffs.diffViewType,
+ diffViewType: ({ diffs }) => diffs.diffViewType,
+ showSuggestPopover: ({ diffs }) => diffs.showSuggestPopover,
+ noteableData: ({ notes }) => notes.noteableData,
+ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition,
}),
- ...mapState('diffs', ['showSuggestPopover']),
...mapGetters('diffs', ['getDiffFileByHash', 'diffLines']),
...mapGetters([
'isLoggedIn',
@@ -126,6 +127,10 @@ export default {
this.initAutoSave(this.noteableData, keys);
}
+
+ if (this.selectedCommentPosition) {
+ this.commentLineStart = this.selectedCommentPosition.start;
+ }
},
methods: {
...mapActions('diffs', [
diff --git a/app/assets/javascripts/diffs/components/diff_row.vue b/app/assets/javascripts/diffs/components/diff_row.vue
index 9e1385853b6..9054a8aec04 100644
--- a/app/assets/javascripts/diffs/components/diff_row.vue
+++ b/app/assets/javascripts/diffs/components/diff_row.vue
@@ -10,6 +10,7 @@ import {
CONFLICT_THEIR,
CONFLICT_MARKER,
} from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import DiffGutterAvatars from './diff_gutter_avatars.vue';
import * as utils from './diff_row_utils';
@@ -22,6 +23,7 @@ export default {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
fileHash: {
type: String,
@@ -45,6 +47,15 @@ export default {
required: false,
default: false,
},
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ dragging: false,
+ };
},
computed: {
...mapGetters('diffs', ['fileLineCoverage']),
@@ -52,26 +63,35 @@ export default {
...mapState({
isHighlighted(state) {
const line = this.line.left?.line_code ? this.line.left : this.line.right;
- return utils.isHighlighted(state, line, this.isCommented);
+ return utils.isHighlighted(state, line, false);
},
}),
classNameMap() {
return {
[CONTEXT_LINE_CLASS_NAME]: this.line.isContextLineLeft,
[PARALLEL_DIFF_VIEW_TYPE]: !this.inline,
+ commented: this.isCommented,
};
},
parallelViewLeftLineType() {
- return utils.parallelViewLeftLineType(this.line, this.isHighlighted);
+ return utils.parallelViewLeftLineType(this.line, this.isHighlighted || this.isCommented);
},
coverageState() {
return this.fileLineCoverage(this.filePath, this.line.right.new_line);
},
classNameMapCellLeft() {
- return utils.classNameMapCell(this.line.left, this.isHighlighted, this.isLoggedIn);
+ return utils.classNameMapCell({
+ line: this.line.left,
+ hll: this.isHighlighted || this.isCommented,
+ isLoggedIn: this.isLoggedIn,
+ });
},
classNameMapCellRight() {
- return utils.classNameMapCell(this.line.right, this.isHighlighted, this.isLoggedIn);
+ return utils.classNameMapCell({
+ line: this.line.right,
+ hll: this.isHighlighted || this.isCommented,
+ isLoggedIn: this.isLoggedIn,
+ });
},
addCommentTooltipLeft() {
return utils.addCommentTooltip(this.line.left);
@@ -131,6 +151,22 @@ export default {
? this.$options.THEIR_CHANGES
: this.$options.OUR_CHANGES;
},
+ onDragEnd() {
+ this.dragging = false;
+ if (!this.glFeatures.dragCommentSelection) return;
+
+ this.$emit('stopdragging');
+ },
+ onDragEnter(line, index) {
+ if (!this.glFeatures.dragCommentSelection) return;
+
+ this.$emit('enterdragging', { ...line, index });
+ },
+ onDragStart(line) {
+ this.$root.$emit('bv::hide::tooltip');
+ this.dragging = true;
+ this.$emit('startdragging', line);
+ },
},
OUR_CHANGES: 'HEAD//our changes',
THEIR_CHANGES: 'origin//their changes',
@@ -143,7 +179,13 @@ export default {
<template>
<div :class="classNameMap" class="diff-grid-row diff-tr line_holder">
- <div class="diff-grid-left left-side">
+ <div
+ data-testid="left-side"
+ class="diff-grid-left left-side"
+ @dragover.prevent
+ @dragenter="onDragEnter(line.left, index)"
+ @dragend="onDragEnd"
+ >
<template v-if="line.left && line.left.type !== $options.CONFLICT_MARKER">
<div
:class="classNameMapCellLeft"
@@ -159,10 +201,13 @@ export default {
:title="addCommentTooltipLeft"
>
<button
+ :draggable="glFeatures.dragCommentSelection"
type="button"
class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ :class="{ 'gl-cursor-grab': dragging }"
:disabled="line.left.commentsDisabled"
@click="handleCommentButton(line.left)"
+ @dragstart="onDragStart({ ...line.left, index })"
>
<gl-icon :size="12" name="comment" />
</button>
@@ -234,7 +279,14 @@ export default {
></div>
</template>
</div>
- <div v-if="!inline" class="diff-grid-right right-side">
+ <div
+ v-if="!inline"
+ data-testid="right-side"
+ class="diff-grid-right right-side"
+ @dragover.prevent
+ @dragenter="onDragEnter(line.right, index)"
+ @dragend="onDragEnd"
+ >
<template v-if="line.right">
<div :class="classNameMapCellRight" class="diff-td diff-line-num new_line">
<template v-if="line.right.type !== $options.CONFLICT_MARKER_THEIR">
@@ -246,10 +298,13 @@ export default {
:title="addCommentTooltipRight"
>
<button
+ :draggable="glFeatures.dragCommentSelection"
type="button"
class="add-diff-note note-button js-add-diff-note-button qa-diff-comment"
+ :class="{ 'gl-cursor-grab': dragging }"
:disabled="line.right.commentsDisabled"
@click="handleCommentButton(line.right)"
+ @dragstart="onDragStart({ ...line.right, index })"
>
<gl-icon :size="12" name="comment" />
</button>
@@ -279,13 +334,21 @@ export default {
<div
v-gl-tooltip.hover
:title="coverageState.text"
- :class="[line.right.type, coverageState.class, { hll: isHighlighted }]"
+ :class="[line.right.type, coverageState.class, { hll: isHighlighted, hll: isCommented }]"
class="diff-td line-coverage right-side"
></div>
<div
:id="line.right.line_code"
:key="line.right.rich_text"
- :class="[line.right.type, { hll: isHighlighted, parallel: !inline }]"
+ v-safe-html="line.right.rich_text"
+ :class="[
+ line.right.type,
+ {
+ hll: isHighlighted,
+ hll: isCommented,
+ parallel: !inline,
+ },
+ ]"
class="diff-td line_content with-coverage right-side"
@mousedown="handleParallelLineMouseDown"
>
diff --git a/app/assets/javascripts/diffs/components/diff_row_utils.js b/app/assets/javascripts/diffs/components/diff_row_utils.js
index 3ef271ddb8f..7606c39ad37 100644
--- a/app/assets/javascripts/diffs/components/diff_row_utils.js
+++ b/app/assets/javascripts/diffs/components/diff_row_utils.js
@@ -35,7 +35,7 @@ export const lineCode = (line) => {
return line.line_code || line.left?.line_code || line.right?.line_code;
};
-export const classNameMapCell = (line, hll, isLoggedIn, isHover) => {
+export const classNameMapCell = ({ line, hll, isLoggedIn, isHover }) => {
if (!line) return [];
const { type } = line;
@@ -54,7 +54,9 @@ export const addCommentTooltip = (line) => {
let tooltip;
if (!line) return tooltip;
- tooltip = __('Add a comment to this line');
+ tooltip = gon.drag_comment_selection
+ ? __('Add a comment to this line or drag for multiple lines')
+ : __('Add a comment to this line');
const brokenSymlinks = line.commentsDisabled;
if (brokenSymlinks) {
diff --git a/app/assets/javascripts/diffs/components/diff_view.vue b/app/assets/javascripts/diffs/components/diff_view.vue
index 84429f62a1c..79800f835f4 100644
--- a/app/assets/javascripts/diffs/components/diff_view.vue
+++ b/app/assets/javascripts/diffs/components/diff_view.vue
@@ -1,5 +1,5 @@
<script>
-import { mapGetters, mapState } from 'vuex';
+import { mapGetters, mapState, mapActions } from 'vuex';
import draftCommentsMixin from '~/diffs/mixins/draft_comments';
import DraftNote from '~/batch_comments/components/draft_note.vue';
import DiffRow from './diff_row.vue';
@@ -35,6 +35,12 @@ export default {
default: false,
},
},
+ data() {
+ return {
+ dragStart: null,
+ updatedLineRange: null,
+ };
+ },
computed: {
...mapGetters('diffs', ['commitId']),
...mapState({
@@ -52,12 +58,39 @@ export default {
},
},
methods: {
+ ...mapActions(['setSelectedCommentPosition']),
+ ...mapActions('diffs', ['showCommentForm']),
showCommentLeft(line) {
return !this.inline || line.left;
},
showCommentRight(line) {
return !this.inline || (line.right && !line.left);
},
+ onStartDragging(line) {
+ this.dragStart = line;
+ },
+ onDragOver(line) {
+ if (line.chunk !== this.dragStart.chunk) return;
+
+ let start = this.dragStart;
+ let end = line;
+
+ if (this.dragStart.index >= line.index) {
+ start = line;
+ end = this.dragStart;
+ }
+
+ this.updatedLineRange = { start, end };
+
+ this.setSelectedCommentPosition(this.updatedLineRange);
+ },
+ onStopDragging() {
+ this.showCommentForm({
+ lineCode: this.updatedLineRange?.end?.line_code,
+ fileHash: this.diffFile.file_hash,
+ });
+ this.dragStart = null;
+ },
},
userColorScheme: window.gon.user_color_scheme,
};
@@ -94,6 +127,10 @@ export default {
:is-bottom="index + 1 === diffLinesLength"
:is-commented="index >= commentedLines.startLine && index <= commentedLines.endLine"
:inline="inline"
+ :index="index"
+ @enterdragging="onDragOver"
+ @startdragging="onStartDragging"
+ @stopdragging="onStopDragging"
/>
<div
v-if="line.renderCommentRow"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
index 2d8ffb047ca..014b1ebe54b 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_table_row.vue
@@ -72,7 +72,12 @@ export default {
return this.fileLineCoverage(this.filePath, this.line.new_line);
},
classNameMapCell() {
- return classNameMapCell(this.line, this.isHighlighted, this.isLoggedIn, this.isHover);
+ return classNameMapCell({
+ line: this.line,
+ hll: this.isHighlighted,
+ isLoggedIn: this.isLoggedIn,
+ isHover: this.isHover,
+ });
},
addCommentTooltip() {
return addCommentTooltip(this.line);
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
index b1618fb0688..47eecef2385 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_table_row.vue
@@ -68,20 +68,20 @@ export default {
return this.fileLineCoverage(this.filePath, this.line.right.new_line);
},
classNameMapCellLeft() {
- return utils.classNameMapCell(
- this.line.left,
- this.isHighlighted,
- this.isLoggedIn,
- this.isLeftHover,
- );
+ return utils.classNameMapCell({
+ line: this.line.left,
+ hll: this.isHighlighted,
+ isLoggedIn: this.isLoggedIn,
+ isHover: this.isLeftHover,
+ });
},
classNameMapCellRight() {
- return utils.classNameMapCell(
- this.line.right,
- this.isHighlighted,
- this.isLoggedIn,
- this.isRightHover,
- );
+ return utils.classNameMapCell({
+ line: this.line.right,
+ hll: this.isHighlighted,
+ isLoggedIn: this.isLoggedIn,
+ isHover: this.isRightHover,
+ });
},
addCommentTooltipLeft() {
return utils.addCommentTooltip(this.line.left);
diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js
index 8712220b25e..c52da558be2 100644
--- a/app/assets/javascripts/diffs/store/utils.js
+++ b/app/assets/javascripts/diffs/store/utils.js
@@ -55,8 +55,17 @@ export const parallelizeDiffLines = (diffLines, inline) => {
let conflictStartIndex = -1;
const lines = [];
+ // `chunk` is used for dragging to select diff lines
+ // we are restricting commenting to only lines that appear between
+ // "expansion rows". Here equal chunks are lines grouped together
+ // inbetween expansion rows.
+ let chunk = 0;
+
for (let i = 0, diffLinesLength = diffLines.length, index = 0; i < diffLinesLength; i += 1) {
const line = diffLines[i];
+ line.chunk = chunk;
+
+ if (isMeta(line)) chunk += 1;
if (isRemoved(line) || isConflictOur(line) || inline) {
lines.push({
diff --git a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
index 10078d5cd64..fcb70dd45a6 100644
--- a/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/access_request_action_buttons.vue
@@ -28,13 +28,13 @@ export default {
if (this.isCurrentUser) {
return sprintf(
s__('Members|Are you sure you want to withdraw your access request for "%{source}"'),
- { source: source.name },
+ { source: source.fullName },
);
}
return sprintf(
s__('Members|Are you sure you want to deny %{usersName}\'s request to join "%{source}"'),
- { usersName: user.name, source: source.name },
+ { usersName: user.name, source: source.fullName },
);
},
},
diff --git a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
index 2b0a75640e2..9a27348f146 100644
--- a/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/invite_action_buttons.vue
@@ -25,7 +25,7 @@ export default {
s__(
'Members|Are you sure you want to revoke the invitation for %{inviteEmail} to join "%{source}"',
),
- { inviteEmail: invite.email, source: source.name },
+ { inviteEmail: invite.email, source: source.fullName },
);
},
},
diff --git a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
index f2bc9c7e876..0e5df961782 100644
--- a/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
+++ b/app/assets/javascripts/members/components/action_buttons/user_action_buttons.vue
@@ -36,7 +36,7 @@ export default {
s__('Members|Are you sure you want to remove %{usersName} from "%{source}"'),
{
usersName: user.name,
- source: source.name,
+ source: source.fullName,
},
);
}
@@ -44,7 +44,7 @@ export default {
return sprintf(
s__('Members|Are you sure you want to remove this orphaned member from "%{source}"'),
{
- source: source.name,
+ source: source.fullName,
},
);
},
diff --git a/app/assets/javascripts/members/components/modals/leave_modal.vue b/app/assets/javascripts/members/components/modals/leave_modal.vue
index 57a5da774e3..d231c7eabfa 100644
--- a/app/assets/javascripts/members/components/modals/leave_modal.vue
+++ b/app/assets/javascripts/members/components/modals/leave_modal.vue
@@ -35,7 +35,7 @@ export default {
return this.memberPath.replace(/:id$/, 'leave');
},
modalTitle() {
- return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.name });
+ return sprintf(s__('Members|Leave "%{source}"'), { source: this.member.source.fullName });
},
},
methods: {
@@ -59,7 +59,7 @@ export default {
<gl-form ref="form" :action="leavePath" method="post">
<p>
<gl-sprintf :message="$options.modalContent">
- <template #source>{{ member.source.name }}</template>
+ <template #source>{{ member.source.fullName }}</template>
</gl-sprintf>
</p>
diff --git a/app/assets/javascripts/members/components/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue
index 030d72c3420..30fcbfcd3f8 100644
--- a/app/assets/javascripts/members/components/table/member_source.vue
+++ b/app/assets/javascripts/members/components/table/member_source.vue
@@ -22,6 +22,6 @@ export default {
<template>
<span v-if="isDirectMember">{{ __('Direct member') }}</span>
<a v-else v-gl-tooltip.hover :title="__('Inherited')" :href="memberSource.webUrl">{{
- memberSource.name
+ memberSource.fullName
}}</a>
</template>
diff --git a/app/assets/javascripts/notes/components/multiline_comment_form.vue b/app/assets/javascripts/notes/components/multiline_comment_form.vue
index bb13eb87af7..9fbf2c9265c 100644
--- a/app/assets/javascripts/notes/components/multiline_comment_form.vue
+++ b/app/assets/javascripts/notes/components/multiline_comment_form.vue
@@ -1,5 +1,5 @@
<script>
-import { mapActions } from 'vuex';
+import { mapActions, mapState } from 'vuex';
import { GlFormSelect, GlSprintf } from '@gitlab/ui';
import { getSymbol, getLineClasses } from './multiline_comment_utils';
@@ -27,12 +27,13 @@ export default {
};
},
computed: {
+ ...mapState({ selectedCommentPosition: ({ notes }) => notes.selectedCommentPosition }),
lineNumber() {
return this.commentLineOptions[this.commentLineOptions.length - 1].text;
},
},
created() {
- const line = this.lineRange?.start || this.line;
+ const line = this.selectedCommentPosition?.start || this.lineRange?.start || this.line;
this.commentLineStart = {
line_code: line.line_code,
@@ -40,6 +41,8 @@ export default {
old_line: line.old_line,
new_line: line.new_line,
};
+
+ if (this.selectedCommentPosition) return;
this.highlightSelection();
},
destroyed() {
diff --git a/app/assets/javascripts/notes/stores/modules/index.js b/app/assets/javascripts/notes/stores/modules/index.js
index 4421a84a6b1..144a3d7ba90 100644
--- a/app/assets/javascripts/notes/stores/modules/index.js
+++ b/app/assets/javascripts/notes/stores/modules/index.js
@@ -15,7 +15,7 @@ export default () => ({
batchSuggestionsInfo: [],
currentlyFetchingDiscussions: false,
/**
- * selectedCommentPosition & selectedCommentPosition structures are the same as `position.line_range`:
+ * selectedCommentPosition & selectedCommentPositionHover structures are the same as `position.line_range`:
* {
* start: { line_code: string, new_line: number, old_line:number, type: string },
* end: { line_code: string, new_line: number, old_line:number, type: string },
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
index 75f5edbfd2b..7bb62cf4a73 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue
@@ -1,38 +1,18 @@
<script>
-import dateFormat from 'dateformat';
-import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import { GlAlert, GlSkeletonLoader } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
-import { getDateInPast } from '~/lib/utils/datetime_utility';
+import { GlAlert, GlTabs, GlTab } from '@gitlab/ui';
+import { s__ } from '~/locale';
import getPipelineCountByStatus from '../graphql/queries/get_pipeline_count_by_status.query.graphql';
import getProjectPipelineStatistics from '../graphql/queries/get_project_pipeline_statistics.query.graphql';
-import StatisticsList from './statistics_list.vue';
-import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue';
+import PipelineCharts from './pipeline_charts.vue';
import {
- CHART_CONTAINER_HEIGHT,
- CHART_DATE_FORMAT,
DEFAULT,
- INNER_CHART_HEIGHT,
LOAD_ANALYTICS_FAILURE,
LOAD_PIPELINES_FAILURE,
- ONE_WEEK_AGO_DAYS,
- ONE_MONTH_AGO_DAYS,
PARSE_FAILURE,
UNSUPPORTED_DATA,
- X_AXIS_LABEL_ROTATION,
- X_AXIS_TITLE_OFFSET,
} from '../constants';
-const defaultCountValues = {
- totalPipelines: {
- count: 0,
- },
- successfulPipelines: {
- count: 0,
- },
-};
-
const defaultAnalyticsValues = {
weekPipelinesTotals: [],
weekPipelinesLabels: [],
@@ -47,36 +27,40 @@ const defaultAnalyticsValues = {
pipelineTimesValues: [],
};
+const defaultCountValues = {
+ totalPipelines: {
+ count: 0,
+ },
+ successfulPipelines: {
+ count: 0,
+ },
+};
+
export default {
components: {
GlAlert,
- GlColumnChart,
- GlSkeletonLoader,
- StatisticsList,
- CiCdAnalyticsAreaChart,
+ GlTabs,
+ GlTab,
+ PipelineCharts,
DeploymentFrequencyCharts: () =>
import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'),
},
inject: {
- projectPath: {
- type: String,
- default: '',
- },
shouldRenderDeploymentFrequencyCharts: {
type: Boolean,
default: false,
},
+ projectPath: {
+ type: String,
+ default: '',
+ },
},
data() {
return {
- counts: {
- ...defaultCountValues,
- },
- analytics: {
- ...defaultAnalyticsValues,
- },
showFailureAlert: false,
failureType: null,
+ analytics: { ...defaultAnalyticsValues },
+ counts: { ...defaultCountValues },
};
},
apollo: {
@@ -134,41 +118,6 @@ export default {
};
}
},
- successRatio() {
- const { successfulPipelines, failedPipelines } = this.counts;
- const successfulCount = successfulPipelines?.count;
- const failedCount = failedPipelines?.count;
- const ratio = (successfulCount / (successfulCount + failedCount)) * 100;
-
- return failedCount === 0 ? 100 : ratio;
- },
- formattedCounts() {
- const { totalPipelines, successfulPipelines, failedPipelines } = this.counts;
-
- return {
- total: totalPipelines?.count,
- success: successfulPipelines?.count,
- failed: failedPipelines?.count,
- successRatio: this.successRatio,
- };
- },
- areaCharts() {
- const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles;
- let areaChartsData = [];
-
- try {
- areaChartsData = [
- this.buildAreaChartData(lastWeek, this.lastWeekChartData),
- this.buildAreaChartData(lastMonth, this.lastMonthChartData),
- this.buildAreaChartData(lastYear, this.lastYearChartData),
- ];
- } catch {
- areaChartsData = [];
- this.reportFailure(PARSE_FAILURE);
- }
-
- return areaChartsData;
- },
lastWeekChartData() {
return {
labels: this.analytics.weekPipelinesLabels,
@@ -190,39 +139,32 @@ export default {
success: this.analytics.yearPipelinesSuccessful,
};
},
- timesChartTransformedData() {
- return [
- {
- name: 'full',
- data: this.mergeLabelsAndValues(
- this.analytics.pipelineTimesLabels,
- this.analytics.pipelineTimesValues,
- ),
- },
- ];
+ timesChartData() {
+ return {
+ labels: this.analytics.pipelineTimesLabels,
+ values: this.analytics.pipelineTimesValues,
+ };
},
- },
- methods: {
- mergeLabelsAndValues(labels, values) {
- return labels.map((label, index) => [label, values[index]]);
+ successRatio() {
+ const { successfulPipelines, failedPipelines } = this.counts;
+ const successfulCount = successfulPipelines?.count;
+ const failedCount = failedPipelines?.count;
+ const ratio = (successfulCount / (successfulCount + failedCount)) * 100;
+
+ return failedCount === 0 ? 100 : ratio;
},
- buildAreaChartData(title, data) {
- const { labels, totals, success } = data;
+ formattedCounts() {
+ const { totalPipelines, successfulPipelines, failedPipelines } = this.counts;
return {
- title,
- data: [
- {
- name: 'all',
- data: this.mergeLabelsAndValues(labels, totals),
- },
- {
- name: 'success',
- data: this.mergeLabelsAndValues(labels, success),
- },
- ],
+ total: totalPipelines?.count,
+ success: successfulPipelines?.count,
+ failed: failedPipelines?.count,
+ successRatio: this.successRatio,
};
},
+ },
+ methods: {
hideAlert() {
this.showFailureAlert = false;
},
@@ -231,16 +173,6 @@ export default {
this.failureType = type;
},
},
- chartContainerHeight: CHART_CONTAINER_HEIGHT,
- timesChartOptions: {
- height: INNER_CHART_HEIGHT,
- xAxis: {
- axisLabel: {
- rotate: X_AXIS_LABEL_ROTATION,
- },
- nameGap: X_AXIS_TITLE_OFFSET,
- },
- },
errorTexts: {
[LOAD_ANALYTICS_FAILURE]: s__(
'PipelineCharts|An error has ocurred when retrieving the analytics data',
@@ -251,74 +183,38 @@ export default {
[PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'),
[DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'),
},
- get chartTitles() {
- const today = dateFormat(new Date(), CHART_DATE_FORMAT);
- const pastDate = (timeScale) =>
- dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT);
- return {
- lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), {
- oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS),
- today,
- }),
- lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), {
- oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS),
- today,
- }),
- lastYear: __('Pipelines for last year'),
- };
- },
- areaChartOptions: {
- xAxis: {
- name: s__('Pipeline|Date'),
- type: 'category',
- },
- yAxis: {
- name: s__('Pipeline|Pipelines'),
- },
- },
};
</script>
<template>
<div>
- <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">
- {{ failure.text }}
- </gl-alert>
- <div class="gl-mb-3">
- <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
- </div>
- <h4 class="gl-my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
- <div class="row">
- <div class="col-md-6">
- <gl-skeleton-loader v-if="$apollo.queries.counts.loading" :lines="5" />
- <statistics-list v-else :counts="formattedCounts" />
- </div>
- <div class="col-md-6">
- <strong>
- {{ __('Duration for the last 30 commits') }}
- </strong>
- <gl-column-chart
- :height="$options.chartContainerHeight"
- :option="$options.timesChartOptions"
- :bars="timesChartTransformedData"
- :y-axis-title="__('Minutes')"
- :x-axis-title="__('Commit')"
- x-axis-type="category"
+ <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">{{
+ failure.text
+ }}</gl-alert>
+ <gl-tabs v-if="shouldRenderDeploymentFrequencyCharts">
+ <gl-tab :title="__('Pipelines')">
+ <pipeline-charts
+ :counts="formattedCounts"
+ :last-week="lastWeekChartData"
+ :last-month="lastMonthChartData"
+ :last-year="lastYearChartData"
+ :times-chart="timesChartData"
+ :loading="$apollo.queries.counts.loading"
+ @report-failure="reportFailure"
/>
- </div>
- </div>
- <hr />
- <h4 class="gl-my-4">{{ __('Pipelines charts') }}</h4>
- <ci-cd-analytics-area-chart
- v-for="(chart, index) in areaCharts"
- :key="index"
- :chart-data="chart.data"
- :area-chart-options="$options.areaChartOptions"
- >
- {{ chart.title }}
- </ci-cd-analytics-area-chart>
- <template v-if="shouldRenderDeploymentFrequencyCharts">
- <hr />
- <deployment-frequency-charts />
- </template>
+ </gl-tab>
+ <gl-tab :title="__('Deployments')">
+ <deployment-frequency-charts />
+ </gl-tab>
+ </gl-tabs>
+ <pipeline-charts
+ v-else
+ :counts="formattedCounts"
+ :last-week="lastWeekChartData"
+ :last-month="lastMonthChartData"
+ :last-year="lastYearChartData"
+ :times-chart="timesChartData"
+ :loading="$apollo.queries.counts.loading"
+ @report-failure="reportFailure"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue b/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue
index 915d334f949..03e02659060 100644
--- a/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue
+++ b/app/assets/javascripts/projects/pipelines/charts/components/app_legacy.vue
@@ -1,26 +1,12 @@
<script>
-import dateFormat from 'dateformat';
-import { GlColumnChart } from '@gitlab/ui/dist/charts';
-import { __, s__, sprintf } from '~/locale';
-import { getDateInPast } from '~/lib/utils/datetime_utility';
-import StatisticsList from './statistics_list.vue';
-import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue';
-
-import {
- CHART_CONTAINER_HEIGHT,
- INNER_CHART_HEIGHT,
- X_AXIS_LABEL_ROTATION,
- X_AXIS_TITLE_OFFSET,
- CHART_DATE_FORMAT,
- ONE_WEEK_AGO_DAYS,
- ONE_MONTH_AGO_DAYS,
-} from '../constants';
+import { GlTabs, GlTab } from '@gitlab/ui';
+import PipelineCharts from './pipeline_charts.vue';
export default {
components: {
- StatisticsList,
- GlColumnChart,
- CiCdAnalyticsAreaChart,
+ GlTabs,
+ GlTab,
+ PipelineCharts,
DeploymentFrequencyCharts: () =>
import('ee_component/projects/pipelines/charts/components/deployment_frequency_charts.vue'),
},
@@ -54,121 +40,41 @@ export default {
},
data() {
return {
- timesChartTransformedData: [
- {
- name: 'full',
- data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
- },
- ],
+ // this loading flag gives the echarts library just enough time
+ // to ensure all DOM nodes have been mounted.
+ //
+ // https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1131
+ loading: true,
};
},
- computed: {
- areaCharts() {
- const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles;
-
- return [
- this.buildAreaChartData(lastWeek, this.lastWeekChartData),
- this.buildAreaChartData(lastMonth, this.lastMonthChartData),
- this.buildAreaChartData(lastYear, this.lastYearChartData),
- ];
- },
- },
- methods: {
- mergeLabelsAndValues(labels, values) {
- return labels.map((label, index) => [label, values[index]]);
- },
- buildAreaChartData(title, data) {
- const { labels, totals, success } = data;
-
- return {
- title,
- data: [
- {
- name: 'all',
- data: this.mergeLabelsAndValues(labels, totals),
- },
- {
- name: 'success',
- data: this.mergeLabelsAndValues(labels, success),
- },
- ],
- };
- },
- },
- chartContainerHeight: CHART_CONTAINER_HEIGHT,
- timesChartOptions: {
- height: INNER_CHART_HEIGHT,
- xAxis: {
- axisLabel: {
- rotate: X_AXIS_LABEL_ROTATION,
- },
- nameGap: X_AXIS_TITLE_OFFSET,
- },
- },
- get chartTitles() {
- const today = dateFormat(new Date(), CHART_DATE_FORMAT);
- const pastDate = (timeScale) =>
- dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT);
- return {
- lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), {
- oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS),
- today,
- }),
- lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), {
- oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS),
- today,
- }),
- lastYear: __('Pipelines for last year'),
- };
- },
- areaChartOptions: {
- xAxis: {
- name: s__('Pipeline|Date'),
- type: 'category',
- },
- yAxis: {
- name: s__('Pipeline|Pipelines'),
- },
+ async mounted() {
+ await this.$nextTick();
+ this.loading = false;
},
};
</script>
<template>
- <div>
- <div class="mb-3">
- <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
- </div>
- <h4 class="my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
- <div class="row">
- <div class="col-md-6">
- <statistics-list :counts="counts" />
- </div>
- <div class="col-md-6">
- <strong>
- {{ __('Duration for the last 30 commits') }}
- </strong>
- <gl-column-chart
- :height="$options.chartContainerHeight"
- :option="$options.timesChartOptions"
- :bars="timesChartTransformedData"
- :y-axis-title="__('Minutes')"
- :x-axis-title="__('Commit')"
- x-axis-type="category"
- />
- </div>
- </div>
- <hr />
- <h4 class="my-4">{{ __('Pipelines charts') }}</h4>
- <ci-cd-analytics-area-chart
- v-for="(chart, index) in areaCharts"
- :key="index"
- :chart-data="chart.data"
- :area-chart-options="$options.areaChartOptions"
- >
- {{ chart.title }}
- </ci-cd-analytics-area-chart>
- <template v-if="shouldRenderDeploymentFrequencyCharts">
- <hr />
+ <gl-tabs v-if="shouldRenderDeploymentFrequencyCharts">
+ <gl-tab :title="__('Pipelines')">
+ <pipeline-charts
+ :counts="counts"
+ :last-week="lastWeekChartData"
+ :last-month="lastMonthChartData"
+ :last-year="lastYearChartData"
+ :times-chart="timesChartData"
+ :loading="loading"
+ />
+ </gl-tab>
+ <gl-tab :title="__('Deployments')">
<deployment-frequency-charts />
- </template>
- </div>
+ </gl-tab>
+ </gl-tabs>
+ <pipeline-charts
+ v-else
+ :counts="counts"
+ :last-week="lastWeekChartData"
+ :last-month="lastMonthChartData"
+ :last-year="lastYearChartData"
+ :times-chart="timesChartData"
+ />
</template>
diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
new file mode 100644
index 00000000000..bec4ab407f0
--- /dev/null
+++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue
@@ -0,0 +1,176 @@
+<script>
+import dateFormat from 'dateformat';
+import { GlColumnChart } from '@gitlab/ui/dist/charts';
+import { GlSkeletonLoader } from '@gitlab/ui';
+import { __, s__, sprintf } from '~/locale';
+import { getDateInPast } from '~/lib/utils/datetime_utility';
+import {
+ CHART_CONTAINER_HEIGHT,
+ CHART_DATE_FORMAT,
+ INNER_CHART_HEIGHT,
+ ONE_WEEK_AGO_DAYS,
+ ONE_MONTH_AGO_DAYS,
+ X_AXIS_LABEL_ROTATION,
+ X_AXIS_TITLE_OFFSET,
+ PARSE_FAILURE,
+} from '../constants';
+import StatisticsList from './statistics_list.vue';
+import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue';
+
+export default {
+ components: {
+ GlColumnChart,
+ GlSkeletonLoader,
+ StatisticsList,
+ CiCdAnalyticsAreaChart,
+ },
+ props: {
+ counts: {
+ required: true,
+ type: Object,
+ },
+ loading: {
+ required: false,
+ default: false,
+ type: Boolean,
+ },
+ lastWeek: {
+ required: true,
+ type: Object,
+ },
+ lastMonth: {
+ required: true,
+ type: Object,
+ },
+ lastYear: {
+ required: true,
+ type: Object,
+ },
+ timesChart: {
+ required: true,
+ type: Object,
+ },
+ },
+ computed: {
+ areaCharts() {
+ const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles;
+ const charts = [
+ { title: lastWeek, data: this.lastWeek },
+ { title: lastMonth, data: this.lastMonth },
+ { title: lastYear, data: this.lastYear },
+ ];
+ let areaChartsData = [];
+
+ try {
+ areaChartsData = charts.map(this.buildAreaChartData);
+ } catch {
+ areaChartsData = [];
+ this.vm.$emit('report-failure', PARSE_FAILURE);
+ }
+
+ return areaChartsData;
+ },
+ timesChartTransformedData() {
+ return [
+ {
+ name: 'full',
+ data: this.mergeLabelsAndValues(this.timesChart.labels, this.timesChart.values),
+ },
+ ];
+ },
+ },
+ methods: {
+ mergeLabelsAndValues(labels, values) {
+ return labels.map((label, index) => [label, values[index]]);
+ },
+ buildAreaChartData({ title, data }) {
+ const { labels, totals, success } = data;
+
+ return {
+ title,
+ data: [
+ {
+ name: 'all',
+ data: this.mergeLabelsAndValues(labels, totals),
+ },
+ {
+ name: 'success',
+ data: this.mergeLabelsAndValues(labels, success),
+ },
+ ],
+ };
+ },
+ },
+ chartContainerHeight: CHART_CONTAINER_HEIGHT,
+ timesChartOptions: {
+ height: INNER_CHART_HEIGHT,
+ xAxis: {
+ axisLabel: {
+ rotate: X_AXIS_LABEL_ROTATION,
+ },
+ nameGap: X_AXIS_TITLE_OFFSET,
+ },
+ },
+ areaChartOptions: {
+ xAxis: {
+ name: s__('Pipeline|Date'),
+ type: 'category',
+ },
+ yAxis: {
+ name: s__('Pipeline|Pipelines'),
+ },
+ },
+ get chartTitles() {
+ const today = dateFormat(new Date(), CHART_DATE_FORMAT);
+ const pastDate = (timeScale) =>
+ dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT);
+ return {
+ lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), {
+ oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS),
+ today,
+ }),
+ lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), {
+ oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS),
+ today,
+ }),
+ lastYear: __('Pipelines for last year'),
+ };
+ },
+};
+</script>
+<template>
+ <div>
+ <div class="gl-mb-3">
+ <h3>{{ s__('PipelineCharts|CI / CD Analytics') }}</h3>
+ </div>
+ <h4 class="gl-my-4">{{ s__('PipelineCharts|Overall statistics') }}</h4>
+ <div class="row">
+ <div class="col-md-6">
+ <gl-skeleton-loader v-if="loading" :lines="5" />
+ <statistics-list v-else :counts="counts" />
+ </div>
+ <div v-if="!loading" class="col-md-6">
+ <strong>{{ __('Duration for the last 30 commits') }}</strong>
+ <gl-column-chart
+ :height="$options.chartContainerHeight"
+ :option="$options.timesChartOptions"
+ :bars="timesChartTransformedData"
+ :y-axis-title="__('Minutes')"
+ :x-axis-title="__('Commit')"
+ x-axis-type="category"
+ />
+ </div>
+ </div>
+ <template v-if="!loading">
+ <hr />
+ <h4 class="gl-my-4">{{ __('Pipelines charts') }}</h4>
+ <ci-cd-analytics-area-chart
+ v-for="(chart, index) in areaCharts"
+ :key="index"
+ :chart-data="chart.data"
+ :area-chart-options="$options.areaChartOptions"
+ >{{ chart.title }}</ci-cd-analytics-area-chart
+ >
+ </template>
+ </div>
+</template>
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index 00230d61a46..41fc4d3dd4e 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -986,6 +986,8 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu {
.labels-select-wrapper {
&.is-standalone {
+ min-width: $input-md-width;
+
.labels-select-dropdown-contents {
max-height: 350px;
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index d5a0371e386..674ba1a307b 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -516,9 +516,12 @@ $line-removed-dark-transparent: rgba(246, 53, 85, 0.2);
$line-number-old: #f9d7dc;
$line-number-new: #ddfbe6;
$line-number-select: #fbf2da;
+$line-number-commented: #dae5fb;
$line-target-blue: $blue-50;
$line-select-yellow: #fcf8e7;
$line-select-yellow-dark: #f0e2bd;
+$line-commented-blue: #e8effc;
+$line-commented-blue-dark: #bccef0;
$dark-diff-match-bg: rgba($white, 0.3);
$dark-diff-match-color: rgba($white, 0.1);
$diff-image-info-color: #808080;
diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb
index cc391868df0..ef9025ae52f 100644
--- a/app/controllers/projects/ci/pipeline_editor_controller.rb
+++ b/app/controllers/projects/ci/pipeline_editor_controller.rb
@@ -3,7 +3,7 @@
class Projects::Ci::PipelineEditorController < Projects::ApplicationController
before_action :check_can_collaborate!
before_action do
- push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: false)
+ push_frontend_feature_flag(:ci_config_visualization_tab, @project, default_enabled: :yaml)
end
feature_category :pipeline_authoring
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 8ded9c2313f..2ecbf8db576 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -33,6 +33,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:batch_suggestions, @project, default_enabled: true)
push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true)
push_frontend_feature_flag(:merge_request_widget_graphql, @project)
+ push_frontend_feature_flag(:drag_comment_selection, @project, default_enabled: true)
push_frontend_feature_flag(:unified_diff_components, @project, default_enabled: true)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true)
diff --git a/app/helpers/emails_helper.rb b/app/helpers/emails_helper.rb
index 017981c8c8e..b58ff21b257 100644
--- a/app/helpers/emails_helper.rb
+++ b/app/helpers/emails_helper.rb
@@ -214,6 +214,29 @@ module EmailsHelper
end
end
+ def group_membership_expiration_changed_text(member, group)
+ if member.expires?
+ days = (member.expires_at - Date.today).to_i
+ days_formatted = pluralize(days, 'day')
+
+ _('Your %{group} membership will now expire in %{days}.') % { group: group.human_name, days: days_formatted }
+ else
+ _('Your membership in %{group} no longer expires.') % { group: group.human_name }
+ end
+ end
+
+ def group_membership_expiration_changed_link(member, group, format: nil)
+ url = group_group_members_url(group, search: member.user.username)
+
+ case format
+ when :html
+ link_to = generate_link('group membership', url).html_safe
+ _('For additional information, review your %{link_to} or contact your group owner.').html_safe % { link_to: link_to }
+ else
+ _('For additional information, review your group membership: %{link_to} or contact your group owner.') % { link_to: url }
+ end
+ end
+
def instance_access_request_text(user, format: nil)
gitlab_host = Gitlab.config.gitlab.host
diff --git a/app/helpers/groups/group_members_helper.rb b/app/helpers/groups/group_members_helper.rb
index adc9d85a384..a4159ed6b19 100644
--- a/app/helpers/groups/group_members_helper.rb
+++ b/app/helpers/groups/group_members_helper.rb
@@ -18,7 +18,7 @@ module Groups::GroupMembersHelper
end
def members_data_json(group, members)
- members_data(group, members).to_json
+ MemberSerializer.new.represent(members, { current_user: current_user, group: group }).to_json
end
# Overridden in `ee/app/helpers/ee/groups/group_members_helper.rb`
@@ -38,84 +38,6 @@ module Groups::GroupMembersHelper
group_id: group.id
}
end
-
- private
-
- def members_data(group, members)
- members.map do |member|
- user = member.user
- source = member.source
-
- data = {
- id: member.id,
- created_at: member.created_at,
- expires_at: member.expires_at&.to_time,
- requested_at: member.requested_at,
- can_update: member.can_update?,
- can_remove: member.can_remove?,
- access_level: {
- string_value: member.human_access,
- integer_value: member.access_level
- },
- source: {
- id: source.id,
- name: source.full_name,
- web_url: Gitlab::UrlBuilder.build(source)
- },
- valid_roles: member.valid_level_roles
- }.merge(member_created_by_data(member.created_by))
-
- if member.invite?
- data[:invite] = member_invite_data(member)
- elsif user.present?
- data[:user] = member_user_data(user)
- end
-
- data
- end
- end
-
- def member_created_by_data(created_by)
- return {} unless created_by.present?
-
- {
- created_by: {
- name: created_by.name,
- web_url: Gitlab::UrlBuilder.build(created_by)
- }
- }
- end
-
- def member_user_data(user)
- {
- id: user.id,
- name: user.name,
- username: user.username,
- web_url: Gitlab::UrlBuilder.build(user),
- avatar_url: avatar_icon_for_user(user, AVATAR_SIZE),
- blocked: user.blocked?,
- two_factor_enabled: user.two_factor_enabled?
- }.merge(member_user_status_data(user.status))
- end
-
- def member_user_status_data(status)
- return {} unless status.present?
-
- {
- status: {
- emoji: status.emoji,
- message_html: status.message_html
- }
- }
- end
-
- def member_invite_data(member)
- {
- email: member.invite_email,
- avatar_url: avatar_icon_for_email(member.invite_email, AVATAR_SIZE),
- can_resend: member.can_resend_invite?
- }
- end
end
Groups::GroupMembersHelper.prepend_if_ee('EE::Groups::GroupMembersHelper')
diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb
index 02726c4354e..69f5fe1430a 100644
--- a/app/mailers/emails/members.rb
+++ b/app/mailers/emails/members.rb
@@ -114,6 +114,23 @@ module Emails
subject: subject('Invitation declined'))
end
+ def member_expiration_date_updated_email(member_source_type, member_id)
+ @member_source_type = member_source_type
+ @member_id = member_id
+
+ return unless member_exists?
+
+ subject = if member.expires?
+ _('Group membership expiration date changed')
+ else
+ _('Group membership expiration date removed')
+ end
+
+ member_email_with_layout(
+ to: member.user.notification_email_for(notification_group),
+ subject: subject(subject))
+ end
+
# rubocop: disable CodeReuse/ActiveRecord
def member
@member ||= Member.find_by(id: @member_id)
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index f1f0fc3b11c..a399ffc32de 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -209,7 +209,7 @@ class CommitStatus < ApplicationRecord
def group_name
# 'rspec:linux: 1/10' => 'rspec:linux'
- common_name = name.to_s.gsub(%r{\d+[\s:\/\\]+\d+\s*}, '')
+ common_name = name.to_s.gsub(%r{\b\d+[\s:\/\\]+\d+\s*}, '')
# 'rspec:linux: [aws, max memory]' => 'rspec:linux', 'rspec:linux: [aws]' => 'rspec:linux'
common_name.gsub!(%r{: \[.*\]\s*\z}, '')
diff --git a/app/models/member.rb b/app/models/member.rb
index 687830f5267..2e79b50d6b7 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -13,6 +13,8 @@ class Member < ApplicationRecord
include FromUnion
include UpdateHighestRole
+ AVATAR_SIZE = 40
+
attr_accessor :raw_invite_token
belongs_to :created_by, class_name: "User"
diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb
index dc300e8f540..c30f6dc81ee 100644
--- a/app/models/members/group_member.rb
+++ b/app/models/members/group_member.rb
@@ -74,6 +74,10 @@ class GroupMember < Member
run_after_commit { notification_service.update_group_member(self) }
end
+ if saved_change_to_expires_at?
+ run_after_commit { notification_service.updated_group_member_expiration(self) }
+ end
+
super
end
diff --git a/app/presenters/packages/nuget/service_index_presenter.rb b/app/presenters/packages/nuget/service_index_presenter.rb
index 67476097714..b3cc912b811 100644
--- a/app/presenters/packages/nuget/service_index_presenter.rb
+++ b/app/presenters/packages/nuget/service_index_presenter.rb
@@ -100,7 +100,7 @@ module Packages
case scope
when :group
- api_v4_groups_packages_nuget_metadata_package_name_package_version_path(
+ api_v4_groups___packages_nuget_metadata_package_name_package_version_path(
params,
true
)
@@ -115,7 +115,7 @@ module Packages
def search_service_url
case scope
when :group
- api_v4_groups_packages_nuget_query_path(id: @project_or_group.id)
+ api_v4_groups___packages_nuget_query_path(id: @project_or_group.id)
when :project
api_v4_projects_packages_nuget_query_path(id: @project_or_group.id)
end
diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb
index 7b0de3bce4e..681e629244f 100644
--- a/app/serializers/diffs_metadata_entity.rb
+++ b/app/serializers/diffs_metadata_entity.rb
@@ -18,8 +18,30 @@ class DiffsMetadataEntity < DiffsEntity
options[:merge_request].can_be_merged_by?(request.current_user)
end
+ expose :project_path
+ expose :project_name
+
+ expose :username
+ expose :user_full_name
+
private
+ def project_path
+ request.project&.full_path
+ end
+
+ def project_name
+ request.project&.name
+ end
+
+ def username
+ request.current_user&.username
+ end
+
+ def user_full_name
+ request.current_user&.name
+ end
+
def presenter(merge_request)
@presenters ||= {}
@presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: request.current_user) # rubocop: disable CodeReuse/Presenter
diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb
new file mode 100644
index 00000000000..584ba4c62de
--- /dev/null
+++ b/app/serializers/member_entity.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class MemberEntity < Grape::Entity
+ include RequestAwareEntity
+ include AvatarsHelper
+
+ expose :id
+ expose :created_at
+ expose :expires_at do |member|
+ member.expires_at&.to_time
+ end
+ expose :requested_at
+
+ expose :created_by, if: -> (member) { member.created_by.present? } do |member|
+ UserEntity.represent(member.created_by, only: [:name, :web_url])
+ end
+
+ expose :can_update do |member|
+ member.can_update?
+ end
+
+ expose :can_remove do |member|
+ member.can_remove?
+ end
+
+ expose :access_level do
+ expose :human_access, as: :string_value
+ expose :access_level, as: :integer_value
+ end
+
+ expose :source do |member|
+ GroupEntity.represent(member.source, only: [:id, :full_name, :web_url])
+ end
+
+ expose :valid_level_roles, as: :valid_roles
+
+ expose :user, if: -> (member) { member.user.present? }, using: MemberUserEntity
+
+ expose :invite, if: -> (member) { member.invite? } do
+ expose :email do |member|
+ member.invite_email
+ end
+
+ expose :avatar_url do |member|
+ avatar_icon_for_email(member.invite_email, Member::AVATAR_SIZE)
+ end
+
+ expose :can_resend do |member|
+ member.can_resend_invite?
+ end
+ end
+end
+
+MemberEntity.prepend_if_ee('EE::MemberEntity')
diff --git a/app/serializers/member_serializer.rb b/app/serializers/member_serializer.rb
new file mode 100644
index 00000000000..b34d7f30a58
--- /dev/null
+++ b/app/serializers/member_serializer.rb
@@ -0,0 +1,5 @@
+# frozen_string_literal: true
+
+class MemberSerializer < BaseSerializer
+ entity MemberEntity
+end
diff --git a/app/serializers/member_user_entity.rb b/app/serializers/member_user_entity.rb
new file mode 100644
index 00000000000..a022966c041
--- /dev/null
+++ b/app/serializers/member_user_entity.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class MemberUserEntity < UserEntity
+ unexpose :show_status
+ unexpose :path
+ unexpose :state
+ unexpose :status_tooltip_html
+
+ expose :avatar_url do |user|
+ user.avatar_url(size: Member::AVATAR_SIZE, only_path: false)
+ end
+
+ expose :blocked do |user|
+ user.blocked?
+ end
+
+ expose :two_factor_enabled do |user|
+ user.two_factor_enabled?
+ end
+
+ expose :status, if: -> (user) { user.status.present? } do
+ expose :emoji do |user|
+ user.status.emoji
+ end
+ end
+end
+
+MemberUserEntity.prepend_if_ee('EE::MemberUserEntity')
diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb
index d0bd3206556..5a71e0eac7c 100644
--- a/app/services/notification_service.rb
+++ b/app/services/notification_service.rb
@@ -481,6 +481,12 @@ class NotificationService
mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later
end
+ def updated_group_member_expiration(group_member)
+ return true unless group_member.notifiable?(:mention)
+
+ mailer.member_expiration_date_updated_email(group_member.real_source_type, group_member.id).deliver_later
+ end
+
def project_was_moved(project, old_path_with_namespace)
recipients = project_moved_recipients(project)
recipients = notifiable_users(recipients, :custom, custom_action: :moved_project, project: project)
diff --git a/app/views/admin/dev_ops_report/show.html.haml b/app/views/admin/dev_ops_report/show.html.haml
index 733c0de9fe9..7659f02bdce 100644
--- a/app/views/admin/dev_ops_report/show.html.haml
+++ b/app/views/admin/dev_ops_report/show.html.haml
@@ -3,7 +3,8 @@
.container
.gl-mt-3
- - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature, default_enabled: false) && License.feature_available?(:devops_adoption)
+ - feature_already_in_use = Analytics::DevopsAdoption::Segment.any?
+ - if Gitlab.ee? && Feature.enabled?(:devops_adoption_feature, default_enabled: feature_already_in_use) && License.feature_available?(:devops_adoption)
= render_if_exists 'admin/dev_ops_report/devops_tabs'
- else
= render 'report'
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index 5cadabd5f90..e02b8333c60 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -300,8 +300,8 @@
= link_to _('Auto DevOps'), help_page_path('topics/autodevops/index.md')
%span= _('uses Kubernetes clusters to deploy your code!')
%hr
- %button.btn.btn-success.btn-sm.dismiss-feature-highlight{ type: 'button' }
- %span= _("Got it!")
+ %button.gl-button.btn.btn-success.btn-sm.dismiss-feature-highlight{ type: 'button' }
+ %span.gl-mr-2= _("Got it!")
= sprite_icon('thumb-up')
- if project_nav_tab? :environments
diff --git a/app/views/notify/member_expiration_date_updated_email.html.haml b/app/views/notify/member_expiration_date_updated_email.html.haml
new file mode 100644
index 00000000000..6c4db22eeaa
--- /dev/null
+++ b/app/views/notify/member_expiration_date_updated_email.html.haml
@@ -0,0 +1,6 @@
+= email_default_heading(say_hi(@member.user))
+
+%p
+ = group_membership_expiration_changed_text(@member, @member_source)
+%p
+ = group_membership_expiration_changed_link(@member, @member_source, format: :html)
diff --git a/app/views/notify/member_expiration_date_updated_email.text.erb b/app/views/notify/member_expiration_date_updated_email.text.erb
new file mode 100644
index 00000000000..8b3a5a55e77
--- /dev/null
+++ b/app/views/notify/member_expiration_date_updated_email.text.erb
@@ -0,0 +1,5 @@
+<%= say_hi(@member.user) %>
+
+<%= group_membership_expiration_changed_text(@member, @member_source) %>
+
+<%= group_membership_expiration_changed_link(@member, @member_source) %>
diff --git a/app/views/projects/issues/_design_management.html.haml b/app/views/projects/issues/_design_management.html.haml
index ad0605b10a8..a2ff9620c0c 100644
--- a/app/views/projects/issues/_design_management.html.haml
+++ b/app/views/projects/issues/_design_management.html.haml
@@ -3,7 +3,7 @@
- requirements_link_url = help_page_path('user/project/issues/design_management', anchor: 'requirements')
- requirements_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: requirements_link_url }
- link_end = '</a>'.html_safe
-- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS and have admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
+- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS and have an admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
- if @project.design_management_enabled?
- add_page_startup_graphql_call('design_management/get_design_list', { fullPath: @project.full_path, iid: @issue.iid.to_s, atVersion: nil })