Commit 3658a9b8 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 80f36a1e 4a5ce587
...@@ -16,12 +16,12 @@ import { sprintf, s__ } from '~/locale'; ...@@ -16,12 +16,12 @@ import { sprintf, s__ } from '~/locale';
* *
* @param {String[]} items * @param {String[]} items
*/ */
export const toNounSeriesText = (items) => { export const toNounSeriesText = (items, { onlyCommas = false } = {}) => {
if (items.length === 0) { if (items.length === 0) {
return ''; return '';
} else if (items.length === 1) { } else if (items.length === 1) {
return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false); return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false);
} else if (items.length === 2) { } else if (items.length === 2 && !onlyCommas) {
return sprintf( return sprintf(
s__('nounSeries|%{firstItem} and %{lastItem}'), s__('nounSeries|%{firstItem} and %{lastItem}'),
{ {
...@@ -33,7 +33,7 @@ export const toNounSeriesText = (items) => { ...@@ -33,7 +33,7 @@ export const toNounSeriesText = (items) => {
} }
return items.reduce((item, nextItem, idx) => return items.reduce((item, nextItem, idx) =>
idx === items.length - 1 idx === items.length - 1 && !onlyCommas
? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }, false) ? sprintf(s__('nounSeries|%{item}, and %{lastItem}'), { item, lastItem: nextItem }, false)
: sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }, false), : sprintf(s__('nounSeries|%{item}, %{nextItem}'), { item, nextItem }, false),
); );
......
<script>
import EmailParticipantsWarning from './email_participants_warning.vue';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
const DEFAULT_NOTEABLE_TYPE = 'Issue';
export default {
components: {
EmailParticipantsWarning,
NoteableWarning,
},
props: {
noteableData: {
type: Object,
required: true,
},
noteableType: {
type: String,
required: false,
default: DEFAULT_NOTEABLE_TYPE,
},
withAlertContainer: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isLocked() {
return Boolean(this.noteableData.discussion_locked);
},
isConfidential() {
return Boolean(this.noteableData.confidential);
},
hasWarning() {
return this.isConfidential || this.isLocked;
},
emailParticipants() {
return this.noteableData.issue_email_participants?.map(({ email }) => email) || [];
},
},
};
</script>
<template>
<div
class="comment-warning-wrapper gl-border-solid gl-border-1 gl-rounded-base gl-border-gray-100"
>
<div
v-if="withAlertContainer"
class="error-alert"
data-testid="comment-field-alert-container"
></div>
<noteable-warning
v-if="hasWarning"
class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100 gl-rounded-base gl-rounded-bottom-left-none gl-rounded-bottom-right-none"
:is-locked="isLocked"
:is-confidential="isConfidential"
:noteable-type="noteableType"
:locked-noteable-docs-path="noteableData.locked_discussion_docs_path"
:confidential-noteable-docs-path="noteableData.confidential_issues_docs_path"
/>
<slot></slot>
<email-participants-warning
v-if="emailParticipants.length"
class="gl-border-t-1 gl-border-t-solid gl-border-t-gray-100 gl-rounded-base gl-rounded-top-left-none! gl-rounded-top-right-none!"
:emails="emailParticipants"
/>
</div>
</template>
...@@ -17,18 +17,17 @@ import { ...@@ -17,18 +17,17 @@ import {
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants'; import * as constants from '../constants';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue'; import markdownField from '~/vue_shared/components/markdown/field.vue';
import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import noteSignedOutWidget from './note_signed_out_widget.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue';
import discussionLockedWidget from './discussion_locked_widget.vue'; import discussionLockedWidget from './discussion_locked_widget.vue';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
export default { export default {
name: 'CommentForm', name: 'CommentForm',
components: { components: {
NoteableWarning,
noteSignedOutWidget, noteSignedOutWidget,
discussionLockedWidget, discussionLockedWidget,
markdownField, markdownField,
...@@ -36,6 +35,7 @@ export default { ...@@ -36,6 +35,7 @@ export default {
GlButton, GlButton,
TimelineEntryItem, TimelineEntryItem,
GlIcon, GlIcon,
CommentFieldLayout,
}, },
mixins: [glFeatureFlagsMixin(), issuableStateMixin], mixins: [glFeatureFlagsMixin(), issuableStateMixin],
props: { props: {
...@@ -287,6 +287,9 @@ export default { ...@@ -287,6 +287,9 @@ export default {
Autosize.update(this.$refs.textarea); Autosize.update(this.$refs.textarea);
}); });
}, },
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
}, },
}; };
</script> </script>
...@@ -309,46 +312,41 @@ export default { ...@@ -309,46 +312,41 @@ export default {
</div> </div>
<div class="timeline-content timeline-content-form"> <div class="timeline-content timeline-content-form">
<form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form"> <form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
<div class="error-alert"></div> <comment-field-layout
:with-alert-container="true"
<noteable-warning :noteable-data="getNoteableData"
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
:noteable-type="noteableType" :noteable-type="noteableType"
:locked-noteable-docs-path="lockedIssueDocsPath"
:confidential-noteable-docs-path="confidentialIssueDocsPath"
/>
<markdown-field
ref="markdownField"
:is-submitting="isSubmitting"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
:textarea-value="note"
> >
<textarea <markdown-field
id="note-body" ref="markdownField"
ref="textarea" :is-submitting="isSubmitting"
slot="textarea" :markdown-preview-path="markdownPreviewPath"
v-model="note" :markdown-docs-path="markdownDocsPath"
dir="auto" :quick-actions-docs-path="quickActionsDocsPath"
:disabled="isSubmitting" :add-spacing-classes="false"
name="note[note]" :textarea-value="note"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area" >
data-qa-selector="comment_field" <template #textarea>
data-testid="comment-field" <textarea
:data-supports-quick-actions="!glFeatures.tributeAutocomplete" id="note-body"
:aria-label="__('Description')" ref="textarea"
:placeholder="__('Write a comment or drag your files here…')" v-model="note"
@keydown.up="editCurrentUserLastNote()" dir="auto"
@keydown.meta.enter="handleSave()" :disabled="isSubmitting"
@keydown.ctrl.enter="handleSave()" name="note[note]"
></textarea> class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
</markdown-field> data-qa-selector="comment_field"
data-testid="comment-field"
:data-supports-quick-actions="!glFeatures.tributeAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.up="editCurrentUserLastNote()"
@keydown.meta.enter="handleSave()"
@keydown.ctrl.enter="handleSave()"
></textarea>
</template>
</markdown-field>
</comment-field-layout>
<div class="note-form-actions"> <div class="note-form-actions">
<div <div
class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown" class="btn-group gl-mr-3 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
......
<script>
import { GlSprintf } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { toNounSeriesText } from '~/lib/utils/grammar';
export default {
components: {
GlSprintf,
},
props: {
emails: {
type: Array,
required: true,
},
numberOfLessParticipants: {
type: Number,
required: false,
default: 3,
},
},
data() {
return {
isShowingMoreParticipants: false,
};
},
computed: {
title() {
return this.moreParticipantsAvailable
? toNounSeriesText(this.lessParticipants, { onlyCommas: true })
: toNounSeriesText(this.emails);
},
lessParticipants() {
return this.emails.slice(0, this.numberOfLessParticipants);
},
moreLabel() {
return sprintf(s__('EmailParticipantsWarning|and %{moreCount} more'), {
moreCount: this.emails.length - this.numberOfLessParticipants,
});
},
moreParticipantsAvailable() {
return !this.isShowingMoreParticipants && this.emails.length > this.numberOfLessParticipants;
},
message() {
return this.moreParticipantsAvailable
? s__('EmailParticipantsWarning|%{emails}, %{andMore} will be notified of your comment.')
: s__('EmailParticipantsWarning|%{emails} will be notified of your comment.');
},
},
methods: {
showMoreParticipants() {
this.isShowingMoreParticipants = true;
},
},
};
</script>
<template>
<div class="issuable-note-warning" data-testid="email-participants-warning">
<gl-sprintf :message="message">
<template #andMore>
<button type="button" class="btn-transparent btn-link" @click="showMoreParticipants">
{{ moreLabel }}
</button>
</template>
<template #emails>
<span>{{ title }}</span>
</template>
</gl-sprintf>
</div>
</template>
...@@ -3,19 +3,19 @@ ...@@ -3,19 +3,19 @@
import { mapGetters, mapActions, mapState } from 'vuex'; import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
import markdownField from '~/vue_shared/components/markdown/field.vue'; import markdownField from '~/vue_shared/components/markdown/field.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import issuableStateMixin from '../mixins/issuable_state'; import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable'; import resolvable from '../mixins/resolvable';
import { __, sprintf } from '~/locale'; import { __, sprintf } from '~/locale';
import { getDraft, updateDraft } from '~/lib/utils/autosave'; import { getDraft, updateDraft } from '~/lib/utils/autosave';
import CommentFieldLayout from './comment_field_layout.vue';
export default { export default {
name: 'NoteForm', name: 'NoteForm',
components: { components: {
NoteableWarning,
markdownField, markdownField,
CommentFieldLayout,
}, },
mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable], mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
props: { props: {
...@@ -303,6 +303,9 @@ export default { ...@@ -303,6 +303,9 @@ export default {
this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve); this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve);
}, },
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
}, },
}; };
</script> </script>
...@@ -316,46 +319,41 @@ export default { ...@@ -316,46 +319,41 @@ export default {
></div> ></div>
<div class="flash-container timeline-content"></div> <div class="flash-container timeline-content"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form"> <form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<noteable-warning <comment-field-layout :noteable-data="getNoteableData">
v-if="hasWarning(getNoteableData)" <markdown-field
:is-locked="isLocked(getNoteableData)" :markdown-preview-path="markdownPreviewPath"
:is-confidential="isConfidential(getNoteableData)" :markdown-docs-path="markdownDocsPath"
:locked-noteable-docs-path="lockedIssueDocsPath" :quick-actions-docs-path="quickActionsDocsPath"
:confidential-noteable-docs-path="confidentialIssueDocsPath" :line="line"
/> :note="discussionNote"
:can-suggest="canSuggest"
<markdown-field :add-spacing-classes="false"
:markdown-preview-path="markdownPreviewPath" :help-page-path="helpPagePath"
:markdown-docs-path="markdownDocsPath" :show-suggest-popover="showSuggestPopover"
:quick-actions-docs-path="quickActionsDocsPath" :textarea-value="updatedNoteBody"
:line="line" @handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
:note="discussionNote" >
:can-suggest="canSuggest" <template #textarea>
:add-spacing-classes="false" <textarea
:help-page-path="helpPagePath" id="note_note"
:show-suggest-popover="showSuggestPopover" ref="textarea"
:textarea-value="updatedNoteBody" v-model="updatedNoteBody"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')" :data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
> name="note[note]"
<textarea class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
id="note_note" data-qa-selector="reply_field"
ref="textarea" dir="auto"
slot="textarea" :aria-label="__('Description')"
v-model="updatedNoteBody" :placeholder="__('Write a comment or drag your files here…')"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete" @keydown.meta.enter="handleKeySubmit()"
name="note[note]" @keydown.ctrl.enter="handleKeySubmit()"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form" @keydown.exact.up="editMyLastNote()"
data-qa-selector="reply_field" @keydown.exact.esc="cancelHandler(true)"
dir="auto" @input="onInput"
:aria-label="__('Description')" ></textarea>
:placeholder="__('Write a comment or drag your files here…')" </template>
@keydown.meta.enter="handleKeySubmit()" </markdown-field>
@keydown.ctrl.enter="handleKeySubmit()" </comment-field-layout>
@keydown.exact.up="editMyLastNote()"
@keydown.exact.esc="cancelHandler(true)"
@input="onInput"
></textarea>
</markdown-field>
<div class="note-form-actions clearfix"> <div class="note-form-actions clearfix">
<template v-if="showBatchCommentsActions"> <template v-if="showBatchCommentsActions">
<p v-if="showResolveDiscussionToggle"> <p v-if="showResolveDiscussionToggle">
......
...@@ -12,21 +12,10 @@ export default { ...@@ -12,21 +12,10 @@ export default {
lockedIssueDocsPath() { lockedIssueDocsPath() {
return this.getNoteableDataByProp('locked_discussion_docs_path'); return this.getNoteableDataByProp('locked_discussion_docs_path');
}, },
confidentialIssueDocsPath() {
return this.getNoteableDataByProp('confidential_issues_docs_path');
},
}, },
methods: { methods: {
isConfidential(issue) {
return Boolean(issue.confidential);
},
isLocked(issue) { isLocked(issue) {
return Boolean(issue.discussion_locked); return Boolean(issue.discussion_locked);
}, },
hasWarning(issue) {
return this.isConfidential(issue) || this.isLocked(issue);
},
}, },
}; };
<script> <script>
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinksLayer from '../graph_shared/links_layer.vue';
import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue'; import StageColumnComponent from './stage_column_component.vue';
import { DOWNSTREAM, MAIN, UPSTREAM } from './constants'; import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
...@@ -8,6 +9,7 @@ import { reportToSentry } from './utils'; ...@@ -8,6 +9,7 @@ import { reportToSentry } from './utils';
export default { export default {
name: 'PipelineGraph', name: 'PipelineGraph',
components: { components: {
LinksLayer,
LinkedGraphWrapper, LinkedGraphWrapper,
LinkedPipelinesColumn, LinkedPipelinesColumn,
StageColumnComponent, StageColumnComponent,
...@@ -32,9 +34,15 @@ export default { ...@@ -32,9 +34,15 @@ export default {
DOWNSTREAM, DOWNSTREAM,
UPSTREAM, UPSTREAM,
}, },
CONTAINER_REF: 'PIPELINE_LINKS_CONTAINER_REF',
BASE_CONTAINER_ID: 'pipeline-links-container',
data() { data() {
return { return {
hoveredJobName: '', hoveredJobName: '',
measurements: {
width: 0,
height: 0,
},
pipelineExpanded: { pipelineExpanded: {
jobName: '', jobName: '',
expanded: false, expanded: false,
...@@ -42,6 +50,9 @@ export default { ...@@ -42,6 +50,9 @@ export default {
}; };
}, },
computed: { computed: {
containerId() {
return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`;
},
downstreamPipelines() { downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : []; return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
}, },
...@@ -54,12 +65,13 @@ export default { ...@@ -54,12 +65,13 @@ export default {
hasUpstreamPipelines() { hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0); return Boolean(this.pipeline?.upstream?.length > 0);
}, },
// The two show checks prevent upstream / downstream from showing redundant linked columns // The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() { showDownstreamPipelines() {
return ( return (
this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
); );
}, },
// The show upstream check prevents showing redundant linked columns
showUpstreamPipelines() { showUpstreamPipelines() {
return ( return (
this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
...@@ -72,7 +84,19 @@ export default { ...@@ -72,7 +84,19 @@ export default {
errorCaptured(err, _vm, info) { errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
}, },
mounted() {
this.measurements = this.getMeasurements();
},
methods: { methods: {
getMeasurements() {
return {
width: this.$refs[this.containerId].scrollWidth,
height: this.$refs[this.containerId].scrollHeight,
};
},
onError(errorType) {
this.$emit('error', errorType);
},
setJob(jobName) { setJob(jobName) {
this.hoveredJobName = jobName; this.hoveredJobName = jobName;
}, },
...@@ -88,43 +112,57 @@ export default { ...@@ -88,43 +112,57 @@ export default {
<template> <template>
<div class="js-pipeline-graph"> <div class="js-pipeline-graph">
<div <div
:id="containerId"
:ref="containerId"
class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap"
:class="{ 'gl-py-5': !isLinkedPipeline }" :class="{ 'gl-py-5': !isLinkedPipeline }"
> >
<linked-graph-wrapper> <links-layer
<template #upstream> :pipeline-data="graph"
<linked-pipelines-column :pipeline-id="pipeline.id"
v-if="showUpstreamPipelines" :container-id="containerId"
:linked-pipelines="upstreamPipelines" :container-measurements="measurements"
:column-title="__('Upstream')" :highlighted-job="hoveredJobName"
:type="$options.pipelineTypeConstants.UPSTREAM" default-link-color="gl-stroke-transparent"
@error="emit('error', errorType)" @error="onError"
/> >
</template> <linked-graph-wrapper>
<template #main> <template #upstream>
<stage-column-component <linked-pipelines-column
v-for="stage in graph" v-if="showUpstreamPipelines"
:key="stage.name" :linked-pipelines="upstreamPipelines"
:title="stage.name" :column-title="__('Upstream')"
:groups="stage.groups" :type="$options.pipelineTypeConstants.UPSTREAM"
:action="stage.status.action" @error="onError"
:job-hovered="hoveredJobName" />
:pipeline-expanded="pipelineExpanded" </template>
@refreshPipelineGraph="$emit('refreshPipelineGraph')" <template #main>
/> <stage-column-component
</template> v-for="stage in graph"
<template #downstream> :key="stage.name"
<linked-pipelines-column :title="stage.name"
v-if="showDownstreamPipelines" :groups="stage.groups"
:linked-pipelines="downstreamPipelines" :action="stage.status.action"
:column-title="__('Downstream')" :job-hovered="hoveredJobName"
:type="$options.pipelineTypeConstants.DOWNSTREAM" :pipeline-expanded="pipelineExpanded"
@downstreamHovered="setJob" :pipeline-id="pipeline.id"
@pipelineExpandToggle="togglePipelineExpanded" @refreshPipelineGraph="$emit('refreshPipelineGraph')"
@error="emit('error', errorType)" @jobHover="setJob"
/> />
</template> </template>
</linked-graph-wrapper> <template #downstream>
<linked-pipelines-column
v-if="showDownstreamPipelines"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
@downstreamHovered="setJob"
@pipelineExpandToggle="togglePipelineExpanded"
@error="onError"
/>
</template>
</linked-graph-wrapper>
</links-layer>
</div> </div>
</div> </div>
</template> </template>
...@@ -23,8 +23,16 @@ export default { ...@@ -23,8 +23,16 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
pipelineId: {
type: Number,
required: false,
default: -1,
},
}, },
computed: { computed: {
computedJobId() {
return this.pipelineId > -1 ? `${this.group.name}-${this.pipelineId}` : '';
},
tooltipText() { tooltipText() {
const { name, status } = this.group; const { name, status } = this.group;
return `${name} - ${status.label}`; return `${name} - ${status.label}`;
...@@ -41,7 +49,7 @@ export default { ...@@ -41,7 +49,7 @@ export default {
}; };
</script> </script>
<template> <template>
<div class="ci-job-dropdown-container dropdown dropright"> <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button <button
v-gl-tooltip.hover="{ boundary: 'viewport' }" v-gl-tooltip.hover="{ boundary: 'viewport' }"
:title="tooltipText" :title="tooltipText"
......
...@@ -74,6 +74,11 @@ export default { ...@@ -74,6 +74,11 @@ export default {
required: false, required: false,
default: () => ({}), default: () => ({}),
}, },
pipelineId: {
type: Number,
required: false,
default: -1,
},
}, },
computed: { computed: {
boundary() { boundary() {
...@@ -85,6 +90,9 @@ export default { ...@@ -85,6 +90,9 @@ export default {
hasDetails() { hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status); return accessValue(this.dataMethod, 'hasDetails', this.status);
}, },
computedJobId() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
},
status() { status() {
return this.job && this.job.status ? this.job.status : {}; return this.job && this.job.status ? this.job.status : {};
}, },
...@@ -146,6 +154,7 @@ export default { ...@@ -146,6 +154,7 @@ export default {
</script> </script>
<template> <template>
<div <div
:id="computedJobId"
class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
data-qa-selector="job_item_container" data-qa-selector="job_item_container"
> >
......
...@@ -24,6 +24,10 @@ export default { ...@@ -24,6 +24,10 @@ export default {
type: Array, type: Array,
required: true, required: true,
}, },
pipelineId: {
type: Number,
required: true,
},
action: { action: {
type: Object, type: Object,
required: false, required: false,
...@@ -94,16 +98,19 @@ export default { ...@@ -94,16 +98,19 @@ export default {
:key="getGroupId(group)" :key="getGroupId(group)"
data-testid="stage-column-group" data-testid="stage-column-group"
class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width" class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
@mouseenter="$emit('jobHover', group.name)"
@mouseleave="$emit('jobHover', '')"
> >
<job-item <job-item
v-if="group.size === 1" v-if="group.size === 1"
:job="group.jobs[0]" :job="group.jobs[0]"
:job-hovered="jobHovered" :job-hovered="jobHovered"
:pipeline-expanded="pipelineExpanded" :pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
css-class-job-name="gl-build-content" css-class-job-name="gl-build-content"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/> />
<job-group-dropdown v-else :group="group" /> <job-group-dropdown v-else :group="group" :pipeline-id="pipelineId" />
</div> </div>
</template> </template>
</main-graph-wrapper> </main-graph-wrapper>
......
import * as d3 from 'd3'; import * as d3 from 'd3';
import { createUniqueLinkId } from '../../utils';
export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
/** /**
* This function expects its first argument data structure * This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`, * to be the same shaped as the one generated by `parseData`,
...@@ -7,12 +9,11 @@ import { createUniqueLinkId } from '../../utils'; ...@@ -7,12 +9,11 @@ import { createUniqueLinkId } from '../../utils';
* we find the nodes in the graph, calculate their coordinates and * we find the nodes in the graph, calculate their coordinates and
* trace the lines that represent the needs of each job. * trace the lines that represent the needs of each job.
* @param {Object} nodeDict - Resulting object of `parseData` with nodes and links * @param {Object} nodeDict - Resulting object of `parseData` with nodes and links
* @param {Object} jobs - An object where each key is the job name that contains the job data * @param {String} containerID - Id for the svg the links will be draw in
* @param {ref} svg - Reference to the svg we draw in
* @returns {Array} Links that contain all the information about them * @returns {Array} Links that contain all the information about them
*/ */
export const generateLinksData = ({ links }, containerID) => { export const generateLinksData = ({ links }, containerID, modifier = '') => {
const containerEl = document.getElementById(containerID); const containerEl = document.getElementById(containerID);
return links.map((link) => { return links.map((link) => {
const path = d3.path(); const path = d3.path();
...@@ -20,8 +21,11 @@ export const generateLinksData = ({ links }, containerID) => { ...@@ -20,8 +21,11 @@ export const generateLinksData = ({ links }, containerID) => {
const sourceId = link.source; const sourceId = link.source;
const targetId = link.target; const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId); const modifiedSourceId = `${sourceId}${modifier}`;
const targetNodeEl = document.getElementById(targetId); const modifiedTargetId = `${targetId}${modifier}`;
const sourceNodeEl = document.getElementById(modifiedSourceId);
const targetNodeEl = document.getElementById(modifiedTargetId);
const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect(); const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
const targetNodeCoordinates = targetNodeEl.getBoundingClientRect(); const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
...@@ -35,11 +39,11 @@ export const generateLinksData = ({ links }, containerID) => { ...@@ -35,11 +39,11 @@ export const generateLinksData = ({ links }, containerID) => {
// from the total to make sure it's aligned properly. We then make the line // from the total to make sure it's aligned properly. We then make the line
// positioned in the center of the job node by adding half the height // positioned in the center of the job node by adding half the height
// of the job pill. // of the job pill.
const paddingLeft = Number( const paddingLeft = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-left').replace('px', ''), window.getComputedStyle(containerEl, null).getPropertyValue('padding-left'),
); );
const paddingTop = Number( const paddingTop = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-top').replace('px', ''), window.getComputedStyle(containerEl, null).getPropertyValue('padding-top'),
); );
const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft; const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft;
......
<script>
import { isEmpty } from 'lodash';
import { DRAW_FAILURE } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
import { parseData } from '../parsing_utils';
import { generateLinksData } from './drawing_utils';
export default {
name: 'LinksInner',
STROKE_WIDTH: 2,
props: {
containerId: {
type: String,
required: true,
},
containerMeasurements: {
type: Object,
required: true,
},
pipelineId: {
type: Number,
required: true,
},
pipelineData: {
type: Array,
required: true,
},
defaultLinkColor: {
type: String,
required: false,
default: 'gl-stroke-gray-200',
},
highlightedJob: {
type: String,
required: false,
default: '',
},
},
data() {
return {
links: [],
needsObject: null,
};
},
computed: {
hasHighlightedJob() {
return Boolean(this.highlightedJob);
},
isPipelineDataEmpty() {
return isEmpty(this.pipelineData);
},
highlightedJobs() {
// If you are hovering on a job, then the jobs we want to highlight are:
// The job you are currently hovering + all of its needs.
return this.hasHighlightedJob
? [this.highlightedJob, ...this.needsObject[this.highlightedJob]]
: [];
},
highlightedLinks() {
// If you are hovering on a job, then the links we want to highlight are:
// All the links whose `source` and `target` are highlighted jobs.
if (this.hasHighlightedJob) {
const filteredLinks = this.links.filter((link) => {
return (
this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target)
);
});
return filteredLinks.map((link) => link.ref);
}
return [];
},
viewBox() {
return [0, 0, this.containerMeasurements.width, this.containerMeasurements.height];
},
},
watch: {
highlightedJob() {
// On first hover, generate the needs reference
if (!this.needsObject) {
const jobs = createJobsHash(this.pipelineData);
this.needsObject = generateJobNeedsDict(jobs) ?? {};
}
},
},
mounted() {
if (!isEmpty(this.pipelineData)) {
this.prepareLinkData();
}
},
methods: {
isLinkHighlighted(linkRef) {
return this.highlightedLinks.includes(linkRef);
},
prepareLinkData() {
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
const parsedData = parseData(arrayOfJobs);
this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`);
} catch {
this.$emit('error', DRAW_FAILURE);
}
},
getLinkClasses(link) {
return [
this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor,
{ 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
];
},
},
};
</script>
<template>
<div class="gl-display-flex gl-relative">
<svg
id="link-svg"
class="gl-absolute"
:viewBox="viewBox"
:width="`${containerMeasurements.width}px`"
:height="`${containerMeasurements.height}px`"
>
<template>
<path
v-for="link in links"
:key="link.path"
:ref="link.ref"
:d="link.path"
class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
:class="getLinkClasses(link)"
:stroke-width="$options.STROKE_WIDTH"
/>
</template>
</svg>
<slot></slot>
</div>
</template>
<script>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
import LinksInner from './links_inner.vue';
export default {
name: 'LinksLayer',
components: {
GlAlert,
LinksInner,
},
MAX_GROUPS: 200,
props: {
containerMeasurements: {
type: Object,
required: true,
},
pipelineData: {
type: Array,
required: true,
},
},
data() {
return {
alertDismissed: false,
showLinksOverride: false,
};
},
i18n: {
showLinksAnyways: __('Show links anyways'),
tooManyJobs: __(
'This graph has a large number of jobs and showing the links between them may have performance implications.',
),
},
computed: {
containerZero() {
return !this.containerMeasurements.width || !this.containerMeasurements.height;
},
numGroups() {
return this.pipelineData.reduce((acc, { groups }) => {
return acc + Number(groups.length);
}, 0);
},
showAlert() {
return !this.showLinkedLayers && !this.alertDismissed;
},
showLinkedLayers() {
return (
!this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS)
);
},
},
methods: {
dismissAlert() {
this.alertDismissed = true;
},
overrideShowLinks() {
this.dismissAlert();
this.showLinksOverride = true;
},
},
};
</script>
<template>
<links-inner
v-if="showLinkedLayers"
:container-measurements="containerMeasurements"
:pipeline-data="pipelineData"
v-bind="$attrs"
v-on="$listeners"
>
<slot></slot>
</links-inner>
<div v-else>
<gl-alert
v-if="showAlert"
class="gl-w-max-content gl-ml-4"
:primary-button-text="$options.i18n.showLinksAnyways"
@primaryAction="overrideShowLinks"
@dismiss="dismissAlert"
>
{{ $options.i18n.tooManyJobs }}
</gl-alert>
<slot></slot>
</div>
</template>
<script> <script>
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { generateLinksData } from '../graph_shared/drawing_utils';
import JobPill from './job_pill.vue'; import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue'; import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils'; import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils'; import { createJobsHash, generateJobNeedsDict } from '../../utils';
......
...@@ -158,7 +158,7 @@ export default async function () { ...@@ -158,7 +158,7 @@ export default async function () {
); );
const { pipelineProjectPath, pipelineIid } = dataset; const { pipelineProjectPath, pipelineIid } = dataset;
createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid); createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid);
} catch { } catch {
Flash(__('An error occurred while loading the pipeline.')); Flash(__('An error occurred while loading the pipeline.'));
} }
......
...@@ -6,8 +6,6 @@ export const validateParams = (params) => { ...@@ -6,8 +6,6 @@ export const validateParams = (params) => {
return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
}; };
export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
/** /**
* This function takes the stages array and transform it * This function takes the stages array and transform it
* into a hash where each key is a job name and the job data * into a hash where each key is a job name and the job data
......
...@@ -100,8 +100,6 @@ ...@@ -100,8 +100,6 @@
color: $orange-600; color: $orange-600;
background-color: $orange-50; background-color: $orange-50;
border-radius: $border-radius-default $border-radius-default 0 0; border-radius: $border-radius-default $border-radius-default 0 0;
border: 1px solid $border-gray-normal;
border-bottom: 0;
padding: 3px 12px; padding: 3px 12px;
margin: auto; margin: auto;
align-items: center; align-items: center;
...@@ -454,3 +452,9 @@ table { ...@@ -454,3 +452,9 @@ table {
.markdown-selector { .markdown-selector {
color: $blue-600; color: $blue-600;
} }
.comment-warning-wrapper {
.md-area {
border: 0;
}
}
...@@ -71,6 +71,10 @@ class IssueEntity < IssuableEntity ...@@ -71,6 +71,10 @@ class IssueEntity < IssuableEntity
expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue| expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue|
help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project') help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
end end
expose :issue_email_participants do |issue|
issue.issue_email_participants.map { |x| { email: x.email } }
end
end end
IssueEntity.prepend_if_ee('::EE::IssueEntity') IssueEntity.prepend_if_ee('::EE::IssueEntity')
---
title: Generate page-info for connections of preloaded associations
merge_request: 51642
author:
type: fixed
...@@ -714,28 +714,6 @@ consul['configuration'] = { ...@@ -714,28 +714,6 @@ consul['configuration'] = {
The manual steps for this configuration are the same as for the [example recommended setup](#example-recommended-setup-manual-steps). The manual steps for this configuration are the same as for the [example recommended setup](#example-recommended-setup-manual-steps).
### Manual failover procedure for Patroni
While Patroni supports automatic failover, you also have the ability to perform
a manual one, where you have two slightly different options:
- **Failover**: allows you to perform a manual failover when there are no healthy nodes.
You can perform this action in any PostgreSQL node:
```shell
sudo gitlab-ctl patroni failover
```
- **Switchover**: only works when the cluster is healthy and allows you to schedule a switchover (it can happen immediately).
You can perform this action in any PostgreSQL node:
```shell
sudo gitlab-ctl patroni switchover
```
For further details on this subject, see the
[Patroni documentation](https://patroni.readthedocs.io/en/latest/rest_api.html#switchover-and-failover-endpoints).
## Patroni ## Patroni
NOTE: NOTE:
...@@ -828,6 +806,38 @@ want to signal Patroni to reload its configuration or restart PostgreSQL process ...@@ -828,6 +806,38 @@ want to signal Patroni to reload its configuration or restart PostgreSQL process
must use the `reload` or `restart` sub-commands of `gitlab-ctl patroni` instead. These two sub-commands are wrappers of must use the `reload` or `restart` sub-commands of `gitlab-ctl patroni` instead. These two sub-commands are wrappers of
the same `patronictl` commands. the same `patronictl` commands.
### Manual failover procedure for Patroni
While Patroni supports automatic failover, you also have the ability to perform
a manual one, where you have two slightly different options:
- **Failover**: allows you to perform a manual failover when there are no healthy nodes.
You can perform this action in any PostgreSQL node:
```shell
sudo gitlab-ctl patroni failover
```
- **Switchover**: only works when the cluster is healthy and allows you to schedule a switchover (it can happen immediately).
You can perform this action in any PostgreSQL node:
```shell
sudo gitlab-ctl patroni switchover
```
For further details on this subject, see the
[Patroni documentation](https://patroni.readthedocs.io/en/latest/rest_api.html#switchover-and-failover-endpoints).
#### Geo secondary site considerations
Similar to `repmgr`, when a Geo secondary site is replicating from a primary site that uses `Patroni` and `PgBouncer`, [replicating through PgBouncer is not supported](https://github.com/pgbouncer/pgbouncer/issues/382#issuecomment-517911529) and the secondary must replicate directly from the leader node in the `Patroni` cluster. Therefore, when there is an automatic or manual failover in the `Patroni` cluster, you will need to manually re-point your secondary site to replicate from the new leader with:
```shell
sudo gitlab-ctl replicate-geo-database --host=<new_leader_ip> --replication-slot=<slot_name>
```
Otherwise, the replication will not happen anymore, even if the original node gets re-added as a follower node. This will re-sync your secondary site database and may take a long time depending on the amount of data to sync. You may also need to run `gitlab-ctl reconfigure` if replication is still not working after re-syncing.
### Recovering the Patroni cluster ### Recovering the Patroni cluster
To recover the old primary and rejoin it to the cluster as a replica, you can simply start Patroni with: To recover the old primary and rejoin it to the cluster as a replica, you can simply start Patroni with:
...@@ -1222,7 +1232,7 @@ When a Geo secondary site is replicating from a primary site that uses `repmgr` ...@@ -1222,7 +1232,7 @@ When a Geo secondary site is replicating from a primary site that uses `repmgr`
sudo gitlab-ctl replicate-geo-database --host=<new_leader_ip> --replication-slot=<slot_name> sudo gitlab-ctl replicate-geo-database --host=<new_leader_ip> --replication-slot=<slot_name>
``` ```
Otherwise, the replication will not happen anymore, even if the original node gets re-added as a follower node. This will re-sync your secondary site database and may take a long time depending on the amount of data to sync. Otherwise, the replication will not happen anymore, even if the original node gets re-added as a follower node. This will re-sync your secondary site database and may take a long time depending on the amount of data to sync. You may also need to run `gitlab-ctl reconfigure` if replication is still not working after re-syncing.
### Repmgr Restore procedure ### Repmgr Restore procedure
......
...@@ -48933,8 +48933,8 @@ ...@@ -48933,8 +48933,8 @@
"kind": "NON_NULL", "kind": "NON_NULL",
"name": null, "name": null,
"ofType": { "ofType": {
"kind": "OBJECT", "kind": "SCALAR",
"name": "PackageComposerJsonType", "name": "Time",
"ofType": null "ofType": null
} }
}, },
...@@ -49608,8 +49608,8 @@ ...@@ -49608,8 +49608,8 @@
"kind": "NON_NULL", "kind": "NON_NULL",
"name": null, "name": null,
"ofType": { "ofType": {
"kind": "SCALAR", "kind": "OBJECT",
"name": "Time", "name": "PackageComposerJsonType",
"ofType": null "ofType": null
} }
}, },
...@@ -67,9 +67,14 @@ module Gitlab ...@@ -67,9 +67,14 @@ module Gitlab
# next page # next page
true true
elsif first elsif first
# If we count the number of requested items plus one (`limit_value + 1`), case sliced_nodes
# then if we get `limit_value + 1` then we know there is a next page when Array
relation_count(set_limit(sliced_nodes, limit_value + 1)) == limit_value + 1 sliced_nodes.size > limit_value
else
# If we count the number of requested items plus one (`limit_value + 1`),
# then if we get `limit_value + 1` then we know there is a next page
relation_count(set_limit(sliced_nodes, limit_value + 1)) == limit_value + 1
end
else else
false false
end end
...@@ -157,8 +162,8 @@ module Gitlab ...@@ -157,8 +162,8 @@ module Gitlab
list = OrderInfo.build_order_list(items) list = OrderInfo.build_order_list(items)
if loaded?(items) if loaded?(items) && !before.present? && !after.present?
@order_list = list.presence || [items.primary_key] @order_list = list.presence || [OrderInfo.new(items.primary_key)]
# already sorted, or trivially sorted # already sorted, or trivially sorted
next items if list.present? || items.size <= 1 next items if list.present? || items.size <= 1
...@@ -194,7 +199,7 @@ module Gitlab ...@@ -194,7 +199,7 @@ module Gitlab
ordering = { 'id' => node[:id].to_s } ordering = { 'id' => node[:id].to_s }
order_list.each do |field| order_list.each do |field|
field_name = field.attribute_name field_name = field.try(:attribute_name) || field
field_value = node[field_name] field_value = node[field_name]
ordering[field_name] = if field_value.is_a?(Time) ordering[field_name] = if field_value.is_a?(Time)
field_value.strftime('%Y-%m-%d %H:%M:%S.%N %Z') field_value.strftime('%Y-%m-%d %H:%M:%S.%N %Z')
......
...@@ -40,7 +40,10 @@ module Gitlab ...@@ -40,7 +40,10 @@ module Gitlab
# "issues"."id" > 500 # "issues"."id" > 500
# #
def conditions def conditions
attr_values = order_list.map { |field| decoded_cursor[field.attribute_name] } attr_values = order_list.map do |field|
name = field.try(:attribute_name) || field
decoded_cursor[name]
end
if order_list.count == 1 && attr_values.first.nil? if order_list.count == 1 && attr_values.first.nil?
raise Gitlab::Graphql::Errors::ArgumentError.new('Before/after cursor invalid: `nil` was provided as only sortable value') raise Gitlab::Graphql::Errors::ArgumentError.new('Before/after cursor invalid: `nil` was provided as only sortable value')
......
...@@ -10617,6 +10617,15 @@ msgstr "" ...@@ -10617,6 +10617,15 @@ msgstr ""
msgid "EmailError|Your account has been blocked. If you believe this is in error, contact a staff member." msgid "EmailError|Your account has been blocked. If you believe this is in error, contact a staff member."
msgstr "" msgstr ""
msgid "EmailParticipantsWarning|%{emails} will be notified of your comment."
msgstr ""
msgid "EmailParticipantsWarning|%{emails}, %{andMore} will be notified of your comment."
msgstr ""
msgid "EmailParticipantsWarning|and %{moreCount} more"
msgstr ""
msgid "EmailToken|reset it" msgid "EmailToken|reset it"
msgstr "" msgstr ""
...@@ -25947,6 +25956,9 @@ msgstr "" ...@@ -25947,6 +25956,9 @@ msgstr ""
msgid "Show latest version" msgid "Show latest version"
msgstr "" msgstr ""
msgid "Show links anyways"
msgstr ""
msgid "Show list" msgid "Show list"
msgstr "" msgstr ""
...@@ -28799,6 +28811,9 @@ msgstr "" ...@@ -28799,6 +28811,9 @@ msgstr ""
msgid "This field is required." msgid "This field is required."
msgstr "" msgstr ""
msgid "This graph has a large number of jobs and showing the links between them may have performance implications."
msgstr ""
msgid "This group" msgid "This group"
msgstr "" msgstr ""
......
...@@ -227,6 +227,22 @@ RSpec.describe Projects::IssuesController do ...@@ -227,6 +227,22 @@ RSpec.describe Projects::IssuesController do
end end
end end
describe "GET #show" do
before do
sign_in(user)
project.add_developer(user)
end
it "returns issue_email_participants" do
participants = create_list(:issue_email_participant, 2, issue: issue)
get :show, params: { namespace_id: project.namespace, project_id: project, id: issue.iid }, format: :json
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['issue_email_participants']).to contain_exactly({ "email" => participants[0].email }, { "email" => participants[1].email })
end
end
describe 'GET #new' do describe 'GET #new' do
it 'redirects to signin if not logged in' do it 'redirects to signin if not logged in' do
get :new, params: { namespace_id: project.namespace, project_id: project } get :new, params: { namespace_id: project.namespace, project_id: project }
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'viewing an issue', :js do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:note) { create(:note_on_issue, project: project, noteable: issue) }
let_it_be(:participants) { create_list(:issue_email_participant, 4, issue: issue) }
before do
sign_in(user)
visit project_issue_path(project, issue)
end
shared_examples 'email participants warning' do |selector|
it 'shows the correct message' do
expect(find(selector)).to have_content(", and 1 more will be notified of your comment")
end
end
context 'for a new note' do
it_behaves_like 'email participants warning', '.new-note'
end
context 'for a reply form' do
before do
find('.js-reply-button').click
end
it_behaves_like 'email participants warning', '.note-edit-form'
end
end
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import CommentFieldLayout from '~/notes/components/comment_field_layout.vue';
import EmailParticipantsWarning from '~/notes/components/email_participants_warning.vue';
import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue';
describe('Comment Field Layout Component', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const LOCKED_DISCUSSION_DOCS_PATH = 'docs/locked/path';
const CONFIDENTIAL_ISSUES_DOCS_PATH = 'docs/confidential/path';
const noteableDataMock = {
confidential: false,
discussion_locked: false,
locked_discussion_docs_path: LOCKED_DISCUSSION_DOCS_PATH,
confidential_issues_docs_path: CONFIDENTIAL_ISSUES_DOCS_PATH,
};
const findIssuableNoteWarning = () => wrapper.find(NoteableWarning);
const findEmailParticipantsWarning = () => wrapper.find(EmailParticipantsWarning);
const findErrorAlert = () => wrapper.findByTestId('comment-field-alert-container');
const createWrapper = (props = {}, slots = {}) => {
wrapper = extendedWrapper(
shallowMount(CommentFieldLayout, {
propsData: {
noteableData: noteableDataMock,
...props,
},
slots,
}),
);
};
describe('.error-alert', () => {
it('does not exist by default', () => {
createWrapper();
expect(findErrorAlert().exists()).toBe(false);
});
it('exists when withAlertContainer is true', () => {
createWrapper({ withAlertContainer: true });
expect(findErrorAlert().isVisible()).toBe(true);
});
});
describe('issue is not confidential and not locked', () => {
it('does not show IssuableNoteWarning', () => {
createWrapper();
expect(findIssuableNoteWarning().exists()).toBe(false);
});
});
describe('issue is confidential', () => {
beforeEach(() => {
createWrapper({
noteableData: { ...noteableDataMock, confidential: true },
});
});
it('shows IssuableNoteWarning', () => {
expect(findIssuableNoteWarning().isVisible()).toBe(true);
});
it('sets IssuableNoteWarning props', () => {
expect(findIssuableNoteWarning().props()).toMatchObject({
isLocked: false,
isConfidential: true,
lockedNoteableDocsPath: LOCKED_DISCUSSION_DOCS_PATH,
confidentialNoteableDocsPath: CONFIDENTIAL_ISSUES_DOCS_PATH,
});
});
});
describe('issue is locked', () => {
beforeEach(() => {
createWrapper({
noteableData: { ...noteableDataMock, discussion_locked: true },
});
});
it('shows IssuableNoteWarning', () => {
expect(findIssuableNoteWarning().isVisible()).toBe(true);
});
it('sets IssuableNoteWarning props', () => {
expect(findIssuableNoteWarning().props()).toMatchObject({
isConfidential: false,
isLocked: true,
lockedNoteableDocsPath: LOCKED_DISCUSSION_DOCS_PATH,
confidentialNoteableDocsPath: CONFIDENTIAL_ISSUES_DOCS_PATH,
});
});
});
describe('issue has no email participants', () => {
it('does not show EmailParticipantsWarning', () => {
createWrapper();
expect(findEmailParticipantsWarning().exists()).toBe(false);
});
});
describe('issue has email participants', () => {
beforeEach(() => {
createWrapper({
noteableData: {
...noteableDataMock,
issue_email_participants: [
{ email: 'someone@gitlab.com' },
{ email: 'another@gitlab.com' },
],
},
});
});
it('shows EmailParticipantsWarning', () => {
expect(findEmailParticipantsWarning().isVisible()).toBe(true);
});
it('sets EmailParticipantsWarning props', () => {
expect(findEmailParticipantsWarning().props('emails')).toEqual([
'someone@gitlab.com',
'another@gitlab.com',
]);
});
});
});
...@@ -181,7 +181,7 @@ describe('issue_comment_form component', () => { ...@@ -181,7 +181,7 @@ describe('issue_comment_form component', () => {
describe('edit mode', () => { describe('edit mode', () => {
beforeEach(() => { beforeEach(() => {
mountComponent(); mountComponent({ mountFunction: mount });
}); });
it('should enter edit mode when arrow up is pressed', () => { it('should enter edit mode when arrow up is pressed', () => {
...@@ -200,7 +200,7 @@ describe('issue_comment_form component', () => { ...@@ -200,7 +200,7 @@ describe('issue_comment_form component', () => {
describe('event enter', () => { describe('event enter', () => {
beforeEach(() => { beforeEach(() => {
mountComponent(); mountComponent({ mountFunction: mount });
}); });
it('should save note when cmd+enter is pressed', () => { it('should save note when cmd+enter is pressed', () => {
...@@ -368,17 +368,6 @@ describe('issue_comment_form component', () => { ...@@ -368,17 +368,6 @@ describe('issue_comment_form component', () => {
}); });
}); });
}); });
describe('issue is confidential', () => {
it('shows information warning', () => {
mountComponent({
noteableData: { ...noteableDataMock, confidential: true },
mountFunction: mount,
});
expect(wrapper.find('[data-testid="confidential-warning"]').exists()).toBe(true);
});
});
}); });
describe('user is not logged in', () => { describe('user is not logged in', () => {
......
import { mount } from '@vue/test-utils';
import EmailParticipantsWarning from '~/notes/components/email_participants_warning.vue';
describe('Email Participants Warning Component', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findMoreButton = () => wrapper.find('button');
const createWrapper = (emails) => {
wrapper = mount(EmailParticipantsWarning, {
propsData: { emails },
});
};
describe('with 3 or less emails', () => {
beforeEach(() => {
createWrapper(['a@gitlab.com', 'b@gitlab.com', 'c@gitlab.com']);
});
it('more button does not exist', () => {
expect(findMoreButton().exists()).toBe(false);
});
it('all emails are displayed', () => {
expect(wrapper.text()).toBe(
'a@gitlab.com, b@gitlab.com, and c@gitlab.com will be notified of your comment.',
);
});
});
describe('with more than 3 emails', () => {
beforeEach(() => {
createWrapper(['a@gitlab.com', 'b@gitlab.com', 'c@gitlab.com', 'd@gitlab.com']);
});
it('only displays first 3 emails', () => {
expect(wrapper.text()).toContain('a@gitlab.com, b@gitlab.com, c@gitlab.com');
expect(wrapper.text()).not.toContain('d@gitlab.com');
});
it('more button does exist', () => {
expect(findMoreButton().exists()).toBe(true);
});
it('more button displays the correct wordage', () => {
expect(findMoreButton().text()).toBe('and 1 more');
});
describe('when more button clicked', () => {
beforeEach(() => {
findMoreButton().trigger('click');
});
it('more button no longer exists', () => {
expect(findMoreButton().exists()).toBe(false);
});
it('all emails are displayed', () => {
expect(wrapper.text()).toBe(
'a@gitlab.com, b@gitlab.com, c@gitlab.com, and d@gitlab.com will be notified of your comment.',
);
});
});
});
});
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import createStore from '~/notes/stores'; import createStore from '~/notes/stores';
import NoteForm from '~/notes/components/note_form.vue'; import NoteForm from '~/notes/components/note_form.vue';
import batchComments from '~/batch_comments/stores/modules/batch_comments'; import batchComments from '~/batch_comments/stores/modules/batch_comments';
...@@ -19,7 +19,7 @@ describe('issue_note_form component', () => { ...@@ -19,7 +19,7 @@ describe('issue_note_form component', () => {
let props; let props;
const createComponentWrapper = () => { const createComponentWrapper = () => {
return shallowMount(NoteForm, { return mount(NoteForm, {
store, store,
propsData: props, propsData: props,
}); });
......
...@@ -30,7 +30,7 @@ job_test_2: ...@@ -30,7 +30,7 @@ job_test_2:
job_build: job_build:
stage: build stage: build
script: script:
- echo "build" - echo "build"
needs: ["job_test_2"] needs: ["job_test_2"]
`; `;
......
...@@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import { GRAPHQL } from '~/pipelines/components/graph/constants'; import { GRAPHQL } from '~/pipelines/components/graph/constants';
import { import {
generateResponse, generateResponse,
...@@ -13,6 +14,7 @@ describe('graph component', () => { ...@@ -13,6 +14,7 @@ describe('graph component', () => {
let wrapper; let wrapper;
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent); const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const defaultProps = { const defaultProps = {
...@@ -28,6 +30,9 @@ describe('graph component', () => { ...@@ -28,6 +30,9 @@ describe('graph component', () => {
provide: { provide: {
dataMethod: GRAPHQL, dataMethod: GRAPHQL,
}, },
stubs: {
'links-inner': true,
},
}); });
}; };
...@@ -45,6 +50,10 @@ describe('graph component', () => { ...@@ -45,6 +50,10 @@ describe('graph component', () => {
expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length); expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length);
}); });
it('renders the links layer', () => {
expect(findLinksLayer().exists()).toBe(true);
});
describe('when column requests a refresh', () => { describe('when column requests a refresh', () => {
beforeEach(() => { beforeEach(() => {
findStageColumns().at(0).vm.$emit('refreshPipelineGraph'); findStageColumns().at(0).vm.$emit('refreshPipelineGraph');
......
...@@ -30,6 +30,7 @@ const mockGroups = Array(4) ...@@ -30,6 +30,7 @@ const mockGroups = Array(4)
const defaultProps = { const defaultProps = {
title: 'Fish', title: 'Fish',
groups: mockGroups, groups: mockGroups,
pipelineId: 159,
}; };
describe('stage column component', () => { describe('stage column component', () => {
...@@ -92,36 +93,51 @@ describe('stage column component', () => { ...@@ -92,36 +93,51 @@ describe('stage column component', () => {
}); });
describe('job', () => { describe('job', () => {
beforeEach(() => { describe('text handling', () => {
createComponent({ beforeEach(() => {
method: mount, createComponent({
props: { method: mount,
groups: [ props: {
{ groups: [
id: 4259, {
name: '<img src=x onerror=alert(document.domain)>', id: 4259,
status: { name: '<img src=x onerror=alert(document.domain)>',
icon: 'status_success', status: {
label: 'success', icon: 'status_success',
tooltip: '<img src=x onerror=alert(document.domain)>', label: 'success',
tooltip: '<img src=x onerror=alert(document.domain)>',
},
}, },
}, ],
], title: 'test <img src=x onerror=alert(document.domain)>',
title: 'test <img src=x onerror=alert(document.domain)>', },
}, });
}); });
});
it('capitalizes and escapes name', () => { it('capitalizes and escapes name', () => {
expect(findStageColumnTitle().text()).toBe( expect(findStageColumnTitle().text()).toBe(
'Test &lt;img src=x onerror=alert(document.domain)&gt;', 'Test &lt;img src=x onerror=alert(document.domain)&gt;',
); );
});
it('escapes id', () => {
expect(findStageColumnGroup().attributes('id')).toBe(
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;',
);
});
}); });
it('escapes id', () => { describe('interactions', () => {
expect(findStageColumnGroup().attributes('id')).toBe( beforeEach(() => {
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;', createComponent({ method: mount });
); });
it('emits jobHovered event on mouseenter and mouseleave', async () => {
await findStageColumnGroup().trigger('mouseenter');
expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name]]);
await findStageColumnGroup().trigger('mouseleave');
expect(wrapper.emitted().jobHover).toEqual([[defaultProps.groups[0].name], ['']]);
});
}); });
}); });
......
import { createUniqueLinkId } from '~/pipelines/utils'; import { createUniqueLinkId } from '~/pipelines/components/graph_shared/drawing_utils';
export const yamlString = `stages: export const yamlString = `stages:
- empty - empty
......
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlButton } from '@gitlab/ui';
import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue';
import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue';
import { generateResponse, mockPipelineResponse } from '../graph/mock_data';
describe('links layer component', () => {
let wrapper;
const findAlert = () => wrapper.find(GlAlert);
const findShowAnyways = () => findAlert().find(GlButton);
const findLinksInner = () => wrapper.find(LinksInner);
const pipeline = generateResponse(mockPipelineResponse, 'root/fungi-xoxo');
const containerId = `pipeline-links-container-${pipeline.id}`;
const slotContent = "<div>Ceci n'est pas un graphique</div>";
const tooManyStages = Array(101)
.fill(0)
.flatMap(() => pipeline.stages);
const defaultProps = {
containerId,
containerMeasurements: { width: 400, height: 400 },
pipelineId: pipeline.id,
pipelineData: pipeline.stages,
};
const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => {
wrapper = mountFn(LinksLayer, {
propsData: {
...defaultProps,
...props,
},
slots: {
default: slotContent,
},
stubs: {
'links-inner': true,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('with data under max stages', () => {
beforeEach(() => {
createComponent();
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('renders the inner links component', () => {
expect(findLinksInner().exists()).toBe(true);
});
});
describe('with more than the max number of stages', () => {
describe('rendering', () => {
beforeEach(() => {
createComponent({ props: { pipelineData: tooManyStages } });
});
it('renders the default slot', () => {
expect(wrapper.html()).toContain(slotContent);
});
it('renders the alert component', () => {
expect(findAlert().exists()).toBe(true);
});
it('does not render the inner links component', () => {
expect(findLinksInner().exists()).toBe(false);
});
});
describe('interactions', () => {
beforeEach(() => {
createComponent({ mountFn: mount, props: { pipelineData: tooManyStages } });
});
it('renders the disable button', () => {
expect(findShowAnyways().exists()).toBe(true);
expect(findShowAnyways().text()).toBe(wrapper.vm.$options.i18n.showLinksAnyways);
});
it('shows links when override is clicked', async () => {
expect(findLinksInner().exists()).toBe(false);
await findShowAnyways().trigger('click');
expect(findLinksInner().exists()).toBe(true);
});
});
});
});
...@@ -21,6 +21,47 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do ...@@ -21,6 +21,47 @@ RSpec.describe Gitlab::Graphql::Pagination::Keyset::Connection do
Gitlab::Json.parse(Base64Bp.urlsafe_decode64(cursor)) Gitlab::Json.parse(Base64Bp.urlsafe_decode64(cursor))
end end
# see: https://gitlab.com/gitlab-org/gitlab/-/issues/297358
context 'the relation has been preloaded' do
let(:projects) { Project.all.preload(:issues) }
let(:nodes) { projects.first.issues }
before do
project = create(:project)
create_list(:issue, 3, project: project)
end
it 'is loaded' do
expect(nodes).to be_loaded
end
it 'does not error when accessing pagination information' do
connection.first = 2
expect(connection).to have_attributes(
has_previous_page: false,
has_next_page: true
)
end
it 'can generate cursors' do
connection.send(:ordered_items) # necessary to generate the order-list
expect(connection.cursor_for(nodes.first)).to be_a(String)
end
it 'can read the next page' do
connection.send(:ordered_items) # necessary to generate the order-list
ordered = nodes.reorder(id: :desc)
next_page = described_class.new(nodes,
context: context,
max_page_size: 3,
after: connection.cursor_for(ordered.second))
expect(next_page.sliced_nodes).to contain_exactly(ordered.third)
end
end
it_behaves_like 'a connection with collection methods' it_behaves_like 'a connection with collection methods'
it_behaves_like 'a redactable connection' do it_behaves_like 'a redactable connection' do
......
...@@ -252,4 +252,41 @@ RSpec.describe 'getting merge request information nested in a project' do ...@@ -252,4 +252,41 @@ RSpec.describe 'getting merge request information nested in a project' do
expect(merge_request_graphql_data['mergeStatus']).to eq('checking') expect(merge_request_graphql_data['mergeStatus']).to eq('checking')
end end
end end
# see: https://gitlab.com/gitlab-org/gitlab/-/issues/297358
context 'when the notes have been preloaded (by participants)' do
let(:query) do
<<~GQL
query($path: ID!) {
project(fullPath: $path) {
mrs: mergeRequests(first: 1) {
nodes {
participants { nodes { id } }
notes(first: 1) {
pageInfo { endCursor hasPreviousPage hasNextPage }
nodes { id }
}
}
}
}
}
GQL
end
before do
create_list(:note_on_merge_request, 3, project: project, noteable: merge_request)
end
it 'does not error' do
post_graphql(query,
current_user: current_user,
variables: { path: project.full_path })
expect(graphql_data_at(:project, :mrs, :nodes, :notes, :pageInfo)).to contain_exactly a_hash_including(
'endCursor' => String,
'hasNextPage' => true,
'hasPreviousPage' => false
)
end
end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment