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';
*
* @param {String[]} items
*/
export const toNounSeriesText = (items) => {
export const toNounSeriesText = (items, { onlyCommas = false } = {}) => {
if (items.length === 0) {
return '';
} else if (items.length === 1) {
return sprintf(s__(`nounSeries|%{item}`), { item: items[0] }, false);
} else if (items.length === 2) {
} else if (items.length === 2 && !onlyCommas) {
return sprintf(
s__('nounSeries|%{firstItem} and %{lastItem}'),
{
......@@ -33,7 +33,7 @@ export const toNounSeriesText = (items) => {
}
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}, %{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 {
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import * as constants from '../constants';
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 userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import noteSignedOutWidget from './note_signed_out_widget.vue';
import discussionLockedWidget from './discussion_locked_widget.vue';
import issuableStateMixin from '../mixins/issuable_state';
import CommentFieldLayout from './comment_field_layout.vue';
export default {
name: 'CommentForm',
components: {
NoteableWarning,
noteSignedOutWidget,
discussionLockedWidget,
markdownField,
......@@ -36,6 +35,7 @@ export default {
GlButton,
TimelineEntryItem,
GlIcon,
CommentFieldLayout,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin],
props: {
......@@ -287,6 +287,9 @@ export default {
Autosize.update(this.$refs.textarea);
});
},
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
},
};
</script>
......@@ -309,46 +312,41 @@ export default {
</div>
<div class="timeline-content timeline-content-form">
<form ref="commentForm" class="new-note common-note-form gfm-form js-main-target-form">
<div class="error-alert"></div>
<noteable-warning
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
<comment-field-layout
:with-alert-container="true"
:noteable-data="getNoteableData"
: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
id="note-body"
ref="textarea"
slot="textarea"
v-model="note"
dir="auto"
:disabled="isSubmitting"
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
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>
</markdown-field>
<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"
>
<template #textarea>
<textarea
id="note-body"
ref="textarea"
v-model="note"
dir="auto"
:disabled="isSubmitting"
name="note[note]"
class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
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="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 @@
import { mapGetters, mapActions, mapState } from 'vuex';
import { mergeUrlParams } from '~/lib/utils/url_utility';
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 glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import issuableStateMixin from '../mixins/issuable_state';
import resolvable from '../mixins/resolvable';
import { __, sprintf } from '~/locale';
import { getDraft, updateDraft } from '~/lib/utils/autosave';
import CommentFieldLayout from './comment_field_layout.vue';
export default {
name: 'NoteForm',
components: {
NoteableWarning,
markdownField,
CommentFieldLayout,
},
mixins: [glFeatureFlagsMixin(), issuableStateMixin, resolvable],
props: {
......@@ -303,6 +303,9 @@ export default {
this.$emit('handleFormUpdateAddToReview', this.updatedNoteBody, shouldResolve);
},
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
},
};
</script>
......@@ -316,46 +319,41 @@ export default {
></div>
<div class="flash-container timeline-content"></div>
<form :data-line-code="lineCode" class="edit-note common-note-form js-quick-submit gfm-form">
<noteable-warning
v-if="hasWarning(getNoteableData)"
:is-locked="isLocked(getNoteableData)"
:is-confidential="isConfidential(getNoteableData)"
:locked-noteable-docs-path="lockedIssueDocsPath"
:confidential-noteable-docs-path="confidentialIssueDocsPath"
/>
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
:textarea-value="updatedNoteBody"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<textarea
id="note_note"
ref="textarea"
slot="textarea"
v-model="updatedNoteBody"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
dir="auto"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
@keydown.exact.up="editMyLastNote()"
@keydown.exact.esc="cancelHandler(true)"
@input="onInput"
></textarea>
</markdown-field>
<comment-field-layout :noteable-data="getNoteableData">
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:quick-actions-docs-path="quickActionsDocsPath"
:line="line"
:note="discussionNote"
:can-suggest="canSuggest"
:add-spacing-classes="false"
:help-page-path="helpPagePath"
:show-suggest-popover="showSuggestPopover"
:textarea-value="updatedNoteBody"
@handleSuggestDismissed="() => $emit('handleSuggestDismissed')"
>
<template #textarea>
<textarea
id="note_note"
ref="textarea"
v-model="updatedNoteBody"
:data-supports-quick-actions="!isEditing && !glFeatures.tributeAutocomplete"
name="note[note]"
class="note-textarea js-gfm-input js-note-text js-autosize markdown-area js-vue-issue-note-form"
data-qa-selector="reply_field"
dir="auto"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
@keydown.meta.enter="handleKeySubmit()"
@keydown.ctrl.enter="handleKeySubmit()"
@keydown.exact.up="editMyLastNote()"
@keydown.exact.esc="cancelHandler(true)"
@input="onInput"
></textarea>
</template>
</markdown-field>
</comment-field-layout>
<div class="note-form-actions clearfix">
<template v-if="showBatchCommentsActions">
<p v-if="showResolveDiscussionToggle">
......
......@@ -12,21 +12,10 @@ export default {
lockedIssueDocsPath() {
return this.getNoteableDataByProp('locked_discussion_docs_path');
},
confidentialIssueDocsPath() {
return this.getNoteableDataByProp('confidential_issues_docs_path');
},
},
methods: {
isConfidential(issue) {
return Boolean(issue.confidential);
},
isLocked(issue) {
return Boolean(issue.discussion_locked);
},
hasWarning(issue) {
return this.isConfidential(issue) || this.isLocked(issue);
},
},
};
<script>
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 StageColumnComponent from './stage_column_component.vue';
import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
......@@ -8,6 +9,7 @@ import { reportToSentry } from './utils';
export default {
name: 'PipelineGraph',
components: {
LinksLayer,
LinkedGraphWrapper,
LinkedPipelinesColumn,
StageColumnComponent,
......@@ -32,9 +34,15 @@ export default {
DOWNSTREAM,
UPSTREAM,
},
CONTAINER_REF: 'PIPELINE_LINKS_CONTAINER_REF',
BASE_CONTAINER_ID: 'pipeline-links-container',
data() {
return {
hoveredJobName: '',
measurements: {
width: 0,
height: 0,
},
pipelineExpanded: {
jobName: '',
expanded: false,
......@@ -42,6 +50,9 @@ export default {
};
},
computed: {
containerId() {
return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`;
},
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
......@@ -54,12 +65,13 @@ export default {
hasUpstreamPipelines() {
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() {
return (
this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
);
},
// The show upstream check prevents showing redundant linked columns
showUpstreamPipelines() {
return (
this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
......@@ -72,7 +84,19 @@ export default {
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
},
mounted() {
this.measurements = this.getMeasurements();
},
methods: {
getMeasurements() {
return {
width: this.$refs[this.containerId].scrollWidth,
height: this.$refs[this.containerId].scrollHeight,
};
},
onError(errorType) {
this.$emit('error', errorType);
},
setJob(jobName) {
this.hoveredJobName = jobName;
},
......@@ -88,43 +112,57 @@ export default {
<template>
<div class="js-pipeline-graph">
<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-py-5': !isLinkedPipeline }"
>
<linked-graph-wrapper>
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
@error="emit('error', errorType)"
/>
</template>
<template #main>
<stage-column-component
v-for="stage in graph"
:key="stage.name"
:title="stage.name"
:groups="stage.groups"
:action="stage.status.action"
:job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
/>
</template>
<template #downstream>
<linked-pipelines-column
v-if="showDownstreamPipelines"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
@downstreamHovered="setJob"
@pipelineExpandToggle="togglePipelineExpanded"
@error="emit('error', errorType)"
/>
</template>
</linked-graph-wrapper>
<links-layer
:pipeline-data="graph"
:pipeline-id="pipeline.id"
:container-id="containerId"
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
default-link-color="gl-stroke-transparent"
@error="onError"
>
<linked-graph-wrapper>
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
@error="onError"
/>
</template>
<template #main>
<stage-column-component
v-for="stage in graph"
:key="stage.name"
:title="stage.name"
:groups="stage.groups"
:action="stage.status.action"
:job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
@jobHover="setJob"
/>
</template>
<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>
</template>
......@@ -23,8 +23,16 @@ export default {
type: Object,
required: true,
},
pipelineId: {
type: Number,
required: false,
default: -1,
},
},
computed: {
computedJobId() {
return this.pipelineId > -1 ? `${this.group.name}-${this.pipelineId}` : '';
},
tooltipText() {
const { name, status } = this.group;
return `${name} - ${status.label}`;
......@@ -41,7 +49,7 @@ export default {
};
</script>
<template>
<div class="ci-job-dropdown-container dropdown dropright">
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button
v-gl-tooltip.hover="{ boundary: 'viewport' }"
:title="tooltipText"
......
......@@ -74,6 +74,11 @@ export default {
required: false,
default: () => ({}),
},
pipelineId: {
type: Number,
required: false,
default: -1,
},
},
computed: {
boundary() {
......@@ -85,6 +90,9 @@ export default {
hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status);
},
computedJobId() {
return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
},
status() {
return this.job && this.job.status ? this.job.status : {};
},
......@@ -146,6 +154,7 @@ export default {
</script>
<template>
<div
:id="computedJobId"
class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
data-qa-selector="job_item_container"
>
......
......@@ -24,6 +24,10 @@ export default {
type: Array,
required: true,
},
pipelineId: {
type: Number,
required: true,
},
action: {
type: Object,
required: false,
......@@ -94,16 +98,19 @@ export default {
:key="getGroupId(group)"
data-testid="stage-column-group"
class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width"
@mouseenter="$emit('jobHover', group.name)"
@mouseleave="$emit('jobHover', '')"
>
<job-item
v-if="group.size === 1"
:job="group.jobs[0]"
:job-hovered="jobHovered"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
css-class-job-name="gl-build-content"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/>
<job-group-dropdown v-else :group="group" />
<job-group-dropdown v-else :group="group" :pipeline-id="pipelineId" />
</div>
</template>
</main-graph-wrapper>
......
import * as d3 from 'd3';
import { createUniqueLinkId } from '../../utils';
export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`;
/**
* This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`,
......@@ -7,12 +9,11 @@ import { createUniqueLinkId } from '../../utils';
* we find the nodes in the graph, calculate their coordinates and
* trace the lines that represent the needs of each job.
* @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 {ref} svg - Reference to the svg we draw in
* @param {String} containerID - Id for the svg the links will be draw in
* @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);
return links.map((link) => {
const path = d3.path();
......@@ -20,8 +21,11 @@ export const generateLinksData = ({ links }, containerID) => {
const sourceId = link.source;
const targetId = link.target;
const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
const modifiedSourceId = `${sourceId}${modifier}`;
const modifiedTargetId = `${targetId}${modifier}`;
const sourceNodeEl = document.getElementById(modifiedSourceId);
const targetNodeEl = document.getElementById(modifiedTargetId);
const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
......@@ -35,11 +39,11 @@ export const generateLinksData = ({ links }, containerID) => {
// 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
// of the job pill.
const paddingLeft = Number(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-left').replace('px', ''),
const paddingLeft = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-left'),
);
const paddingTop = Number(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-top').replace('px', ''),
const paddingTop = parseFloat(
window.getComputedStyle(containerEl, null).getPropertyValue('padding-top'),
);
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>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
import { generateLinksData } from '../graph_shared/drawing_utils';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
import { createJobsHash, generateJobNeedsDict } from '../../utils';
......
......@@ -158,7 +158,7 @@ export default async function () {
);
const { pipelineProjectPath, pipelineIid } = dataset;
createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid);
createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
......
......@@ -6,8 +6,6 @@ export const validateParams = (params) => {
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
* into a hash where each key is a job name and the job data
......
......@@ -100,8 +100,6 @@
color: $orange-600;
background-color: $orange-50;
border-radius: $border-radius-default $border-radius-default 0 0;
border: 1px solid $border-gray-normal;
border-bottom: 0;
padding: 3px 12px;
margin: auto;
align-items: center;
......@@ -454,3 +452,9 @@ table {
.markdown-selector {
color: $blue-600;
}
.comment-warning-wrapper {
.md-area {
border: 0;
}
}
......@@ -71,6 +71,10 @@ class IssueEntity < IssuableEntity
expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue|
help_page_path('user/project/settings/index.md', anchor: 'archiving-a-project')
end
expose :issue_email_participants do |issue|
issue.issue_email_participants.map { |x| { email: x.email } }
end
end
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'] = {
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
NOTE:
......@@ -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
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
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`
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
......
......@@ -48933,8 +48933,8 @@
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PackageComposerJsonType",
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
......@@ -49608,8 +49608,8 @@
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"kind": "OBJECT",
"name": "PackageComposerJsonType",
"ofType": null
}
},
......@@ -67,9 +67,14 @@ module Gitlab
# next page
true
elsif first
# 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
case sliced_nodes
when Array
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
false
end
......@@ -157,8 +162,8 @@ module Gitlab
list = OrderInfo.build_order_list(items)
if loaded?(items)
@order_list = list.presence || [items.primary_key]
if loaded?(items) && !before.present? && !after.present?
@order_list = list.presence || [OrderInfo.new(items.primary_key)]
# already sorted, or trivially sorted
next items if list.present? || items.size <= 1
......@@ -194,7 +199,7 @@ module Gitlab
ordering = { 'id' => node[:id].to_s }
order_list.each do |field|
field_name = field.attribute_name
field_name = field.try(:attribute_name) || field
field_value = node[field_name]
ordering[field_name] = if field_value.is_a?(Time)
field_value.strftime('%Y-%m-%d %H:%M:%S.%N %Z')
......
......@@ -40,7 +40,10 @@ module Gitlab
# "issues"."id" > 500
#
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?
raise Gitlab::Graphql::Errors::ArgumentError.new('Before/after cursor invalid: `nil` was provided as only sortable value')
......
......@@ -10617,6 +10617,15 @@ msgstr ""
msgid "EmailError|Your account has been blocked. If you believe this is in error, contact a staff member."
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"
msgstr ""
......@@ -25947,6 +25956,9 @@ msgstr ""
msgid "Show latest version"
msgstr ""
msgid "Show links anyways"
msgstr ""
msgid "Show list"
msgstr ""
......@@ -28799,6 +28811,9 @@ msgstr ""
msgid "This field is required."
msgstr ""
msgid "This graph has a large number of jobs and showing the links between them may have performance implications."
msgstr ""
msgid "This group"
msgstr ""
......
......@@ -227,6 +227,22 @@ RSpec.describe Projects::IssuesController do
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
it 'redirects to signin if not logged in' do
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', () => {
describe('edit mode', () => {
beforeEach(() => {
mountComponent();
mountComponent({ mountFunction: mount });
});
it('should enter edit mode when arrow up is pressed', () => {
......@@ -200,7 +200,7 @@ describe('issue_comment_form component', () => {
describe('event enter', () => {
beforeEach(() => {
mountComponent();
mountComponent({ mountFunction: mount });
});
it('should save note when cmd+enter is pressed', () => {
......@@ -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', () => {
......
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 { shallowMount } from '@vue/test-utils';
import createStore from '~/notes/stores';
import NoteForm from '~/notes/components/note_form.vue';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
......@@ -19,7 +19,7 @@ describe('issue_note_form component', () => {
let props;
const createComponentWrapper = () => {
return shallowMount(NoteForm, {
return mount(NoteForm, {
store,
propsData: props,
});
......
......@@ -30,7 +30,7 @@ job_test_2:
job_build:
stage: build
script:
script:
- echo "build"
needs: ["job_test_2"]
`;
......
......@@ -2,6 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.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 {
generateResponse,
......@@ -13,6 +14,7 @@ describe('graph component', () => {
let wrapper;
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findLinksLayer = () => wrapper.find(LinksLayer);
const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const defaultProps = {
......@@ -28,6 +30,9 @@ describe('graph component', () => {
provide: {
dataMethod: GRAPHQL,
},
stubs: {
'links-inner': true,
},
});
};
......@@ -45,6 +50,10 @@ describe('graph component', () => {
expect(findStageColumns()).toHaveLength(defaultProps.pipeline.stages.length);
});
it('renders the links layer', () => {
expect(findLinksLayer().exists()).toBe(true);
});
describe('when column requests a refresh', () => {
beforeEach(() => {
findStageColumns().at(0).vm.$emit('refreshPipelineGraph');
......
......@@ -30,6 +30,7 @@ const mockGroups = Array(4)
const defaultProps = {
title: 'Fish',
groups: mockGroups,
pipelineId: 159,
};
describe('stage column component', () => {
......@@ -92,36 +93,51 @@ describe('stage column component', () => {
});
describe('job', () => {
beforeEach(() => {
createComponent({
method: mount,
props: {
groups: [
{
id: 4259,
name: '<img src=x onerror=alert(document.domain)>',
status: {
icon: 'status_success',
label: 'success',
tooltip: '<img src=x onerror=alert(document.domain)>',
describe('text handling', () => {
beforeEach(() => {
createComponent({
method: mount,
props: {
groups: [
{
id: 4259,
name: '<img src=x onerror=alert(document.domain)>',
status: {
icon: 'status_success',
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', () => {
expect(findStageColumnTitle().text()).toBe(
'Test &lt;img src=x onerror=alert(document.domain)&gt;',
);
it('capitalizes and escapes name', () => {
expect(findStageColumnTitle().text()).toBe(
'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', () => {
expect(findStageColumnGroup().attributes('id')).toBe(
'ci-badge-&lt;img src=x onerror=alert(document.domain)&gt;',
);
describe('interactions', () => {
beforeEach(() => {
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:
- 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
Gitlab::Json.parse(Base64Bp.urlsafe_decode64(cursor))
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 redactable connection' 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')
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
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