diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-14 21:10:59 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-01-14 21:10:59 +0300 |
commit | ea3306a15e945e694afba62dc93b17500ffaec7f (patch) | |
tree | aa444f7fe503e5650bfdb6500ed428b4619ff37b /app | |
parent | 8106ac487c3b52471e2ca894c65c13162c2fb1a8 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
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 }) |