Commit d6a3752f authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents b77c4555 8f7a5a74
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import imageDiff from '~/diffs/mixins/image_diff'; import imageDiff from '~/diffs/mixins/image_diff';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import DraftNote from './draft_note.vue'; import DraftNote from './draft_note.vue';
export default { export default {
components: { components: {
DraftNote, DraftNote,
DesignNotePin,
}, },
mixins: [imageDiff], mixins: [imageDiff],
props: { props: {
...@@ -31,9 +33,12 @@ export default { ...@@ -31,9 +33,12 @@ export default {
class="discussion-notes diff-discussions position-relative" class="discussion-notes diff-discussions position-relative"
> >
<div class="notes"> <div class="notes">
<span class="d-block btn-transparent badge badge-pill is-draft js-diff-notes-index"> <design-note-pin
{{ toggleText(draft, index) }} :label="toggleText(draft, index)"
</span> is-draft
class="js-diff-notes-index gl-translate-x-n50"
size="sm"
/>
<draft-note :draft="draft" /> <draft-note :draft="draft" />
</div> </div>
</div> </div>
......
...@@ -694,7 +694,7 @@ export default class Notes { ...@@ -694,7 +694,7 @@ export default class Notes {
// Convert returned HTML to a jQuery object so we can modify it further // Convert returned HTML to a jQuery object so we can modify it further
const $noteEntityEl = $(noteEntity.html); const $noteEntityEl = $(noteEntity.html);
const $noteAvatar = $noteEntityEl.find('.image-diff-avatar-link'); const $noteAvatar = $noteEntityEl.find('.image-diff-avatar-link');
const $targetNoteBadge = $targetNote.find('.badge'); const $targetNoteBadge = $targetNote.find('.design-note-pin');
$noteAvatar.append($targetNoteBadge); $noteAvatar.append($targetNoteBadge);
this.revertNoteEditForm($targetNote); this.revertNoteEditForm($targetNote);
......
...@@ -286,6 +286,7 @@ export default { ...@@ -286,6 +286,7 @@ export default {
" "
:is-inactive="isNoteInactive(note)" :is-inactive="isNoteInactive(note)"
:is-resolved="note.resolved" :is-resolved="note.resolved"
is-on-image
@mousedown.stop="onNoteMousedown($event, note)" @mousedown.stop="onNoteMousedown($event, note)"
@mouseup.stop="onNoteMouseup(note)" @mouseup.stop="onNoteMouseup(note)"
/> />
......
<script> <script>
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import { mapActions } from 'vuex'; import { mapActions } from 'vuex';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
import noteableDiscussion from '../../notes/components/noteable_discussion.vue'; import noteableDiscussion from '../../notes/components/noteable_discussion.vue';
export default { export default {
components: { components: {
noteableDiscussion, noteableDiscussion,
GlIcon, GlIcon,
DesignNotePin,
}, },
props: { props: {
discussions: { discussions: {
...@@ -62,20 +64,22 @@ export default { ...@@ -62,20 +64,22 @@ export default {
<ul :data-discussion-id="discussion.id" class="notes"> <ul :data-discussion-id="discussion.id" class="notes">
<template v-if="shouldCollapseDiscussions"> <template v-if="shouldCollapseDiscussions">
<button <button
:class="{ v-if="discussion.expanded"
'diff-notes-collapse': discussion.expanded, class="diff-notes-collapse js-diff-notes-toggle"
'btn-transparent badge badge-pill': !discussion.expanded,
}"
type="button" type="button"
class="js-diff-notes-toggle"
:aria-label="__('Show comments')" :aria-label="__('Show comments')"
@click="toggleDiscussion({ discussionId: discussion.id })" @click="toggleDiscussion({ discussionId: discussion.id })"
> >
<gl-icon v-if="discussion.expanded" name="collapse" class="collapse-icon" /> <gl-icon name="collapse" class="collapse-icon" />
<template v-else>
{{ index + 1 }}
</template>
</button> </button>
<design-note-pin
v-else
:label="index + 1"
:is-resolved="discussion.resolved"
size="sm"
class="js-diff-notes-toggle gl-translate-x-n50"
@click="toggleDiscussion({ discussionId: discussion.id })"
/>
</template> </template>
<noteable-discussion <noteable-discussion
v-show="isExpanded(discussion)" v-show="isExpanded(discussion)"
...@@ -87,9 +91,12 @@ export default { ...@@ -87,9 +91,12 @@ export default {
@noteDeleted="deleteNoteHandler" @noteDeleted="deleteNoteHandler"
> >
<template v-if="renderAvatarBadge" #avatar-badge> <template v-if="renderAvatarBadge" #avatar-badge>
<span class="badge badge-pill"> <design-note-pin
{{ index + 1 }} :label="index + 1"
</span> class="user-avatar"
:is-resolved="discussion.resolved"
size="sm"
/>
</template> </template>
</noteable-discussion> </noteable-discussion>
</ul> </ul>
......
<script> <script>
import { GlIcon } from '@gitlab/ui';
import { isArray } from 'lodash'; import { isArray } from 'lodash';
import { mapActions, mapGetters } from 'vuex'; import { mapActions, mapGetters } from 'vuex';
import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff'; import imageDiffMixin from 'ee_else_ce/diffs/mixins/image_diff';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
function calcPercent(pos, renderedSize) { function calcPercent(pos, renderedSize) {
return (100 * pos) / renderedSize; return (100 * pos) / renderedSize;
...@@ -11,7 +11,7 @@ function calcPercent(pos, renderedSize) { ...@@ -11,7 +11,7 @@ function calcPercent(pos, renderedSize) {
export default { export default {
name: 'ImageDiffOverlay', name: 'ImageDiffOverlay',
components: { components: {
GlIcon, DesignNotePin,
}, },
mixins: [imageDiffMixin], mixins: [imageDiffMixin],
props: { props: {
...@@ -36,7 +36,7 @@ export default { ...@@ -36,7 +36,7 @@ export default {
badgeClass: { badgeClass: {
type: String, type: String,
required: false, required: false,
default: 'badge badge-pill', default: '',
}, },
shouldToggleDiscussion: { shouldToggleDiscussion: {
type: Boolean, type: Boolean,
...@@ -114,30 +114,28 @@ export default { ...@@ -114,30 +114,28 @@ export default {
> >
<span class="sr-only"> {{ __('Add image comment') }} </span> <span class="sr-only"> {{ __('Add image comment') }} </span>
</button> </button>
<button
<design-note-pin
v-for="(discussion, index) in allDiscussions" v-for="(discussion, index) in allDiscussions"
:key="discussion.id" :key="discussion.id"
:style="getPosition(discussion)" :label="showCommentIcon ? null : toggleText(discussion, index)"
:class="[badgeClass, { 'is-draft': discussion.isDraft }]" :position="getPosition(discussion)"
:disabled="!shouldToggleDiscussion"
class="js-image-badge"
type="button"
:aria-label="__('Show comments')" :aria-label="__('Show comments')"
class="js-image-badge"
:class="badgeClass"
:is-draft="discussion.isDraft"
:is-resolved="discussion.resolved"
is-on-image
:disabled="!shouldToggleDiscussion"
@click="clickedToggle(discussion)" @click="clickedToggle(discussion)"
> />
<gl-icon v-if="showCommentIcon" name="image-comment-dark" :size="24" />
<template v-else> <design-note-pin
{{ toggleText(discussion, index) }}
</template>
</button>
<button
v-if="canComment && currentCommentForm" v-if="canComment && currentCommentForm"
:style="{ left: `${currentCommentForm.xPercent}%`, top: `${currentCommentForm.yPercent}%` }" :position="{
:aria-label="__('Comment form position')" left: `${currentCommentForm.xPercent}%`,
class="btn-transparent comment-indicator position-absolute" top: `${currentCommentForm.yPercent}%`,
type="button" }"
> />
<gl-icon name="image-comment-dark" :size="24" />
</button>
</div> </div>
</template> </template>
...@@ -14,7 +14,15 @@ export function createImageBadge(noteId, { x, y }, classNames = []) { ...@@ -14,7 +14,15 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
} }
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) { export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']); const buttonEl = createImageBadge(noteId, coordinate, [
'gl-display-flex',
'gl-align-items-center',
'gl-justify-content-center',
'gl-font-sm',
'design-note-pin',
'on-image',
'gl-absolute',
]);
buttonEl.textContent = badgeText; buttonEl.textContent = badgeText;
containerEl.appendChild(buttonEl); containerEl.appendChild(buttonEl);
...@@ -30,8 +38,8 @@ export function addImageCommentBadge(containerEl, { coordinate, noteId }) { ...@@ -30,8 +38,8 @@ export function addImageCommentBadge(containerEl, { coordinate, noteId }) {
export function addAvatarBadge(el, event) { export function addAvatarBadge(el, event) {
const { noteId, badgeNumber } = event.detail; const { noteId, badgeNumber } = event.detail;
// Add badge to new comment // Add design pin to new comment
const avatarBadgeEl = el.querySelector(`#${noteId} .badge`); const avatarBadgeEl = el.querySelector(`#${noteId} .design-note-pin`);
avatarBadgeEl.textContent = badgeNumber; avatarBadgeEl.textContent = badgeNumber;
avatarBadgeEl.classList.remove('hidden'); avatarBadgeEl.classList.remove('hidden');
} }
...@@ -10,12 +10,12 @@ export function setPositionDataAttribute(el, options) { ...@@ -10,12 +10,12 @@ export function setPositionDataAttribute(el, options) {
} }
export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) { export function updateDiscussionAvatarBadgeNumber(discussionEl, newBadgeNumber) {
const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .badge'); const avatarBadgeEl = discussionEl.querySelector('.image-diff-avatar-link .design-note-pin');
avatarBadgeEl.textContent = newBadgeNumber; avatarBadgeEl.textContent = newBadgeNumber;
} }
export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) { export function updateDiscussionBadgeNumber(discussionEl, newBadgeNumber) {
const discussionBadgeEl = discussionEl.querySelector('.badge'); const discussionBadgeEl = discussionEl.querySelector('.design-note-pin');
discussionBadgeEl.textContent = newBadgeNumber; discussionBadgeEl.textContent = newBadgeNumber;
} }
......
...@@ -118,7 +118,7 @@ export default class ImageDiff { ...@@ -118,7 +118,7 @@ export default class ImageDiff {
removeBadge(event) { removeBadge(event) {
const { badgeNumber } = event.detail; const { badgeNumber } = event.detail;
const indexToRemove = badgeNumber - 1; const indexToRemove = badgeNumber - 1;
const imageBadgeEls = this.imageFrameEl.querySelectorAll('.badge'); const imageBadgeEls = this.imageFrameEl.querySelectorAll('.design-note-pin');
if (this.imageBadges.length !== badgeNumber) { if (this.imageBadges.length !== badgeNumber) {
// Cascade badges count numbers for (avatar badges + image badges) // Cascade badges count numbers for (avatar badges + image badges)
......
...@@ -61,7 +61,7 @@ export default class ReplacedImageDiff extends ImageDiff { ...@@ -61,7 +61,7 @@ export default class ReplacedImageDiff extends ImageDiff {
this.currentView = newView; this.currentView = newView;
// Clear existing badges on new view // Clear existing badges on new view
const existingBadges = this.imageFrameEl.querySelectorAll('.badge'); const existingBadges = this.imageFrameEl.querySelectorAll('.design-note-pin');
[...existingBadges].map((badge) => badge.remove()); [...existingBadges].map((badge) => badge.remove());
// Remove existing references to old view image badges // Remove existing references to old view image badges
......
...@@ -28,12 +28,37 @@ export default { ...@@ -28,12 +28,37 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
isOnImage: {
type: Boolean,
required: false,
default: false,
},
isDraft: {
type: Boolean,
required: false,
default: false,
},
size: {
type: String,
required: false,
default: 'md',
validator: (value) => ['sm', 'md'].includes(value),
},
ariaLabel: {
type: String,
required: false,
default: null,
},
}, },
computed: { computed: {
isNewNote() { isNewNote() {
return this.label === null; return this.label === null;
}, },
pinLabel() { pinLabel() {
if (this.ariaLabel) {
return this.ariaLabel;
}
return this.isNewNote return this.isNewNote
? __('Comment form position') ? __('Comment form position')
: sprintf(__("Comment '%{label}' position"), { label: this.label }); : sprintf(__("Comment '%{label}' position"), { label: this.label });
...@@ -51,7 +76,10 @@ export default { ...@@ -51,7 +76,10 @@ export default {
'js-image-badge design-note-pin': !isNewNote, 'js-image-badge design-note-pin': !isNewNote,
resolved: isResolved, resolved: isResolved,
inactive: isInactive, inactive: isInactive,
draft: isDraft,
'on-image': isOnImage,
'gl-absolute': position, 'gl-absolute': position,
small: size === 'sm',
}" }"
class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm" class="gl-display-flex gl-align-items-center gl-justify-content-center gl-font-sm"
type="button" type="button"
......
$design-pin-diameter: 28px; $design-pin-diameter: 28px;
$design-pin-diameter-sm: 24px;
$t-gray-a-16-design-pin: rgba($black, 0.16); $t-gray-a-16-design-pin: rgba($black, 0.16);
.layout-page.design-detail-layout { .layout-page.design-detail-layout {
...@@ -12,24 +13,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); ...@@ -12,24 +13,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
top: 35px; top: 35px;
} }
.design-note-pin {
display: flex;
height: $design-pin-diameter;
width: $design-pin-diameter;
box-sizing: content-box;
background-color: $purple-500;
color: $white;
font-weight: $gl-font-weight-bold;
border-radius: 50%;
z-index: 1;
padding: 0;
border: 0;
&.resolved {
background-color: $gray-500;
}
}
.comment-indicator { .comment-indicator {
border-radius: 50%; border-radius: 50%;
} }
...@@ -40,35 +23,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); ...@@ -40,35 +23,6 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
cursor: grabbing; cursor: grabbing;
} }
} }
/**
* Design pin that overlays the design
*/
.frame .design-note-pin {
box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
border: $white 2px solid;
will-change: transform, box-shadow, opacity;
// NOTE: verbose transition property required for Safari
transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear;
transform-origin: 0 0;
transform: translate(-50%, -50%);
&:hover {
transform: scale(1.2) translate(-50%, -50%);
}
&:active {
box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin;
}
&.inactive {
@include gl-opacity-5;
&:hover {
@include gl-opacity-10;
}
}
}
} }
.design-scaler-wrapper { .design-scaler-wrapper {
...@@ -177,3 +131,63 @@ $t-gray-a-16-design-pin: rgba($black, 0.16); ...@@ -177,3 +131,63 @@ $t-gray-a-16-design-pin: rgba($black, 0.16);
.design-card-header { .design-card-header {
background: transparent; background: transparent;
} }
.design-note-pin {
display: flex;
height: $design-pin-diameter;
width: $design-pin-diameter;
box-sizing: content-box;
background-color: $purple-500;
color: $white;
font-weight: $gl-font-weight-bold;
border-radius: 50%;
z-index: 1;
padding: 0;
border: 0;
&.draft {
background-color: $orange-500;
}
&.resolved {
background-color: $gray-500;
}
&.on-image {
box-shadow: 0 2px 4px $t-gray-a-08, 0 0 1px $t-gray-a-24;
border: $white 2px solid;
will-change: transform, box-shadow, opacity;
// NOTE: verbose transition property required for Safari
transition: transform $general-hover-transition-duration linear, box-shadow $general-hover-transition-duration linear, opacity $general-hover-transition-duration linear;
transform-origin: 0 0;
transform: translate(-50%, -50%);
&:hover {
transform: scale(1.2) translate(-50%, -50%);
}
&:active {
box-shadow: 0 0 4px $t-gray-a-16-design-pin, 0 4px 12px $t-gray-a-16-design-pin;
}
&.inactive {
@include gl-opacity-5;
&:hover {
@include gl-opacity-10;
}
}
}
&.small {
position: absolute;
border: 1px solid $white;
height: $design-pin-diameter-sm;
width: $design-pin-diameter-sm;
}
&.user-avatar {
top: 25px;
right: 8px;
}
}
...@@ -1072,24 +1072,6 @@ table.code { ...@@ -1072,24 +1072,6 @@ table.code {
} }
} }
.frame .badge.badge-pill,
.image-diff-avatar-link .badge.badge-pill,
.user-avatar-link .badge.badge-pill,
.notes > .badge.badge-pill {
position: absolute;
background-color: $blue-400;
color: $white;
border: $white 1px solid;
min-height: $gl-padding;
padding: 5px 8px;
border-radius: 12px;
&:focus {
outline: none;
}
}
.frame .badge.badge-pill,
.frame .image-comment-badge, .frame .image-comment-badge,
.frame .comment-indicator { .frame .comment-indicator {
// Center align badges on the frame // Center align badges on the frame
...@@ -1121,11 +1103,6 @@ table.code { ...@@ -1121,11 +1103,6 @@ table.code {
} }
} }
.notes > .badge.badge-pill {
display: none;
left: -13px;
}
.discussion-notes { .discussion-notes {
min-height: 35px; min-height: 35px;
...@@ -1134,18 +1111,22 @@ table.code { ...@@ -1134,18 +1111,22 @@ table.code {
min-height: 25px; min-height: 25px;
} }
.diff-notes-expand {
display: none;
}
&.collapsed { &.collapsed {
background-color: $white; background-color: $white;
.diff-notes-expand {
display: initial;
}
.diff-notes-collapse, .diff-notes-collapse,
.note, .note,
.discussion-reply-holder { .discussion-reply-holder {
display: none; display: none;
} }
.notes > .badge.badge-pill {
display: block;
}
} }
} }
......
...@@ -125,6 +125,12 @@ module Types ...@@ -125,6 +125,12 @@ module Types
field :archived, GraphQL::Types::Boolean, null: true, method: :archived?, field :archived, GraphQL::Types::Boolean, null: true, method: :archived?,
description: 'Whether the current project is archived.' description: 'Whether the current project is archived.'
field :language, GraphQL::Types::String,
description: 'Blob language.',
method: :blob_language,
null: true,
calls_gitaly: true
def raw_text_blob def raw_text_blob
object.data unless object.binary? object.data unless object.binary?
end end
......
...@@ -32,7 +32,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ...@@ -32,7 +32,7 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
end end
def blob_language def blob_language
@_blob_language ||= Gitlab::Diff::CustomDiff.transformed_blob_language(blob) || language @_blob_language ||= Gitlab::Diff::CustomDiff.transformed_blob_language(blob) || gitattr_language || detect_language
end end
def raw_plain_data def raw_plain_data
...@@ -166,9 +166,15 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ...@@ -166,9 +166,15 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
@all_lines ||= blob.data.lines @all_lines ||= blob.data.lines
end end
def language def gitattr_language
blob.language_from_gitattributes blob.language_from_gitattributes
end end
def detect_language
return if blob.binary?
Rouge::Lexer.guess(filename: blob.path, source: blob_data(nil)) { |lex| lex.min_by(&:tag) }.tag
end
end end
BlobPresenter.prepend_mod_with('BlobPresenter') BlobPresenter.prepend_mod_with('BlobPresenter')
...@@ -33,7 +33,7 @@ class SnippetBlobPresenter < BlobPresenter ...@@ -33,7 +33,7 @@ class SnippetBlobPresenter < BlobPresenter
blob.container blob.container
end end
def language def gitattr_language
nil nil
end end
......
...@@ -9,9 +9,9 @@ ...@@ -9,9 +9,9 @@
-# to the first note position when we click on a badge diff discussion -# to the first note position when we click on a badge diff discussion
%ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } } %ul.notes{ id: "discussion_#{discussion.id}", data: { discussion_id: discussion.id, position: discussion.notes[0].position.to_json } }
- if discussion.try(:on_image?) && show_toggle - if discussion.try(:on_image?) && show_toggle
%button.gl-button.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' } %button.comment-indicator.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-font-sm.diff-notes-collapse.js-diff-notes-toggle{ type: 'button' }
= sprite_icon('collapse', css_class: 'collapse-icon') = sprite_icon('collapse', css_class: 'collapse-icon')
%button.gl-button.btn-transparent.badge.badge-pill.js-diff-notes-toggle{ type: 'button' } %button.gl-align-items-center.gl-justify-content-center.gl-font-sm.small.gl-translate-x-n50.design-note-pin.js-diff-notes-toggle.diff-notes-expand{ type: 'button' }
= badge_counter = badge_counter
= render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge } = render partial: "shared/notes/note", collection: discussion.notes, as: :note, locals: { badge_counter: badge_counter, show_image_comment_badge: show_image_comment_badge }
......
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
- elsif note_counter == 0 - elsif note_counter == 0
- counter = badge_counter if local_assigns[:badge_counter] - counter = badge_counter if local_assigns[:badge_counter]
- badge_class = "hidden" if @fresh_discussion || counter.nil? - badge_class = "hidden" if @fresh_discussion || counter.nil?
%span.badge.badge-pill{ class: badge_class } %span.gl-display-flex.gl-align-items-center.gl-justify-content-center.gl-font-sm.design-note-pin.small.user-avatar{ class: badge_class }
= counter = counter
.timeline-content .timeline-content
.note-header .note-header
......
...@@ -4362,6 +4362,27 @@ Input type: `TerraformStateUnlockInput` ...@@ -4362,6 +4362,27 @@ Input type: `TerraformStateUnlockInput`
| <a id="mutationterraformstateunlockclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationterraformstateunlockclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationterraformstateunlockerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationterraformstateunlockerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
### `Mutation.timelineEventCreate`
Input type: `TimelineEventCreateInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationtimelineeventcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationtimelineeventcreateincidentid"></a>`incidentId` | [`IssueID!`](#issueid) | Incident ID of the timeline event. |
| <a id="mutationtimelineeventcreatenote"></a>`note` | [`String!`](#string) | Text note of the timeline event. |
| <a id="mutationtimelineeventcreateoccurredat"></a>`occurredAt` | [`Time!`](#time) | Timestamp of when the event occurred. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationtimelineeventcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationtimelineeventcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationtimelineeventcreatetimelineevent"></a>`timelineEvent` | [`TimelineEventType`](#timelineeventtype) | Timeline event. |
### `Mutation.timelineEventDestroy` ### `Mutation.timelineEventDestroy`
Input type: `TimelineEventDestroyInput` Input type: `TimelineEventDestroyInput`
...@@ -14759,6 +14780,7 @@ Returns [`Tree`](#tree). ...@@ -14759,6 +14780,7 @@ Returns [`Tree`](#tree).
| <a id="repositoryblobid"></a>`id` | [`ID!`](#id) | ID of the blob. | | <a id="repositoryblobid"></a>`id` | [`ID!`](#id) | ID of the blob. |
| <a id="repositoryblobideeditpath"></a>`ideEditPath` | [`String`](#string) | Web path to edit this blob in the Web IDE. | | <a id="repositoryblobideeditpath"></a>`ideEditPath` | [`String`](#string) | Web path to edit this blob in the Web IDE. |
| <a id="repositoryblobideforkandeditpath"></a>`ideForkAndEditPath` | [`String`](#string) | Web path to edit this blob in the Web IDE using a forked project. | | <a id="repositoryblobideforkandeditpath"></a>`ideForkAndEditPath` | [`String`](#string) | Web path to edit this blob in the Web IDE using a forked project. |
| <a id="repositorybloblanguage"></a>`language` | [`String`](#string) | Blob language. |
| <a id="repositorybloblfsoid"></a>`lfsOid` | [`String`](#string) | LFS OID of the blob. | | <a id="repositorybloblfsoid"></a>`lfsOid` | [`String`](#string) | LFS OID of the blob. |
| <a id="repositoryblobmode"></a>`mode` | [`String`](#string) | Blob mode. | | <a id="repositoryblobmode"></a>`mode` | [`String`](#string) | Blob mode. |
| <a id="repositoryblobname"></a>`name` | [`String`](#string) | Blob name. | | <a id="repositoryblobname"></a>`name` | [`String`](#string) | Blob name. |
...@@ -56,10 +56,3 @@ button[disabled] { ...@@ -56,10 +56,3 @@ button[disabled] {
} }
} }
} }
.frame,
.diff-discussions {
.badge.is-draft {
background-color: $orange-500;
}
}
...@@ -80,6 +80,7 @@ module EE ...@@ -80,6 +80,7 @@ module EE
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Update mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Update
mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Destroy mount_mutation ::Mutations::IncidentManagement::EscalationPolicy::Destroy
mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Update mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Update
mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Create
mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Destroy mount_mutation ::Mutations::IncidentManagement::TimelineEvent::Destroy
mount_mutation ::Mutations::AppSec::Fuzzing::API::CiConfiguration::Create mount_mutation ::Mutations::AppSec::Fuzzing::API::CiConfiguration::Create
mount_mutation ::Mutations::AppSec::Fuzzing::Coverage::Corpus::Create, feature_flag: :corpus_management mount_mutation ::Mutations::AppSec::Fuzzing::Coverage::Corpus::Create, feature_flag: :corpus_management
......
...@@ -34,8 +34,8 @@ module Mutations ...@@ -34,8 +34,8 @@ module Mutations
raise_resource_not_available_error! 'Timeline events are not supported for this project' raise_resource_not_available_error! 'Timeline events are not supported for this project'
end end
def timeline_events_available?(timeline_event) def timeline_events_available?(object)
::Gitlab::IncidentManagement.timeline_events_available?(timeline_event.project) ::Gitlab::IncidentManagement.timeline_events_available?(object.project)
end end
end end
end end
......
# frozen_string_literal: true
module Mutations
module IncidentManagement
module TimelineEvent
class Create < Base
graphql_name 'TimelineEventCreate'
argument :incident_id, Types::GlobalIDType[::Issue],
required: true,
description: 'Incident ID of the timeline event.'
argument :note, GraphQL::Types::String,
required: true,
description: 'Text note of the timeline event.'
argument :occurred_at, Types::TimeType,
required: true,
description: 'Timestamp of when the event occurred.'
def resolve(incident_id:, **args)
incident = authorized_find!(id: incident_id)
authorize!(incident)
response ::IncidentManagement::TimelineEvents::CreateService.new(incident, current_user, args).execute
end
private
def find_object(id:)
GitlabSchema.object_from_id(id, expected_type: ::Issue).sync
end
end
end
end
end
# frozen_string_literal: true
module IncidentManagement
module TimelineEvents
DEFAULT_ACTION = 'comment'
class CreateService < TimelineEvents::BaseService
def initialize(incident, user, params)
@project = incident.project
@incident = incident
@user = user
@params = params
end
def execute
return error_no_permissions unless allowed?
timeline_event_params = {
project: project,
incident: incident,
author: user,
note: params[:note],
action: params.fetch(:action, DEFAULT_ACTION),
note_html: params[:note_html].presence || params[:note],
occurred_at: params[:occurred_at]
}
timeline_event = IncidentManagement::TimelineEvent.new(timeline_event_params)
if timeline_event.save
success(timeline_event)
else
error_in_save(timeline_event)
end
end
private
attr_reader :project, :user, :incident, :params
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::IncidentManagement::TimelineEvent::Create do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:incident) { create(:incident, project: project) }
let(:args) { { note: 'note', occurred_at: Time.current } }
specify { expect(described_class).to require_graphql_authorizations(:admin_incident_management_timeline_event) }
before do
stub_licensed_features(incident_timeline_events: true)
end
describe '#resolve' do
subject(:resolve) { mutation_for(project, current_user).resolve(incident_id: incident.to_global_id, **args) }
context 'when a user has permissions to create a timeline event' do
before do
project.add_developer(current_user)
end
context 'when TimelineEvents::CreateService responds with success' do
it 'adds timeline event to database' do
expect { resolve }.to change(IncidentManagement::TimelineEvent, :count).by(1)
end
end
context 'when TimelineEvents::CreateService responds with an error' do
let(:args) { {} }
it 'returns errors' do
expect(resolve).to eq(timeline_event: nil, errors: ["Occurred at can't be blank, Note can't be blank, and Note html can't be blank"])
end
end
end
context 'when a user has no permissions to create timeline event' do
before do
project.add_guest(current_user)
end
it 'raises an error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when timeline event feature is not available' do
before do
stub_licensed_features(incident_timeline_events: false)
end
it 'raises and error' do
expect { resolve }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
end
private
def mutation_for(project, user)
described_class.new(object: project, context: { current_user: user }, field: nil)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Creating an incident timeline event' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:incident) { create(:incident, project: project) }
let_it_be(:event_occurred_at) { Time.current }
let_it_be(:note) { 'demo note' }
let(:input) { { incident_id: incident.to_global_id.to_s, note: note, occurred_at: event_occurred_at } }
let(:mutation) do
graphql_mutation(:timeline_event_create, input) do
<<~QL
clientMutationId
errors
timelineEvent {
id
author { id username }
incident { id title }
note
editable
action
occurredAt
}
QL
end
end
let(:mutation_response) { graphql_mutation_response(:timeline_event_create) }
before do
stub_licensed_features(incident_timeline_events: true)
project.add_developer(user)
end
it 'creates incident timeline event', :aggregate_failures do
post_graphql_mutation(mutation, current_user: user)
timeline_event_response = mutation_response['timelineEvent']
expect(response).to have_gitlab_http_status(:success)
expect(timeline_event_response).to include(
'author' => {
'id' => user.to_global_id.to_s,
'username' => user.username
},
'incident' => {
'id' => incident.to_global_id.to_s,
'title' => incident.title
},
'note' => note,
'action' => 'comment',
'editable' => false,
'occurredAt' => event_occurred_at.iso8601
)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe IncidentManagement::TimelineEvents::CreateService do
let_it_be(:user_with_permissions) { create(:user) }
let_it_be(:user_without_permissions) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be_with_refind(:incident) { create(:incident, project: project) }
let(:current_user) { user_with_permissions }
let(:args) { { 'note': 'note', 'occurred_at': Time.current, 'action': 'new comment' } }
let(:service) { described_class.new(incident, current_user, args) }
before do
stub_licensed_features(incident_timeline_events: true)
end
before_all do
project.add_developer(user_with_permissions)
project.add_reporter(user_without_permissions)
end
describe '#execute' do
shared_examples 'error response' do |message|
it 'has an informative message' do
expect(execute).to be_error
expect(execute.message).to eq(message)
end
end
shared_examples 'success response' do
it 'has timeline event' do
expect(execute).to be_success
result = execute.payload[:timeline_event]
expect(result).to be_a(::IncidentManagement::TimelineEvent)
expect(result.author).to eq(current_user)
expect(result.incident).to eq(incident)
expect(result.project).to eq(project)
expect(result.note).to eq(args[:note])
end
end
subject(:execute) { service.execute }
context 'when current user is blank' do
let(:current_user) { nil }
it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
end
context 'when user does not have permissions to create timeline events' do
let(:current_user) { user_without_permissions }
it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
end
context 'when feature is not available' do
before do
stub_licensed_features(incident_timeline_events: false)
end
it_behaves_like 'error response', 'You have insufficient permissions to manage timeline events for this incident'
end
context 'when error occurs during creation' do
let(:args) { {} }
it_behaves_like 'error response', "Occurred at can't be blank, Note can't be blank, and Note html can't be blank"
end
context 'with default action' do
let(:args) { { 'note': 'note', 'occurred_at': Time.current } }
it_behaves_like 'success response'
it 'matches the default action', :aggregate_failures do
result = execute.payload[:timeline_event]
expect(result.action).to eq(IncidentManagement::TimelineEvents::DEFAULT_ACTION)
end
end
context 'with non_default action' do
it_behaves_like 'success response'
it 'matches the action from arguments', :aggregate_failures do
result = execute.payload[:timeline_event]
expect(result.action).to eq(args[:action])
end
end
it 'successfully creates a database record', :aggregate_failures do
expect { execute }.to change { ::IncidentManagement::TimelineEvent.count }.by(1)
end
end
end
...@@ -80,6 +80,10 @@ module Gitlab ...@@ -80,6 +80,10 @@ module Gitlab
super(presenter_class: BlobPresenter) super(presenter_class: BlobPresenter)
end end
def binary?
false
end
def fetch_blob def fetch_blob
path = [ref, blob_path] path = [ref, blob_path]
missing_blob = { binary_path: blob_path } missing_blob = { binary_path: blob_path }
......
...@@ -28,7 +28,7 @@ RSpec.describe 'Merge request > User creates image diff notes', :js do ...@@ -28,7 +28,7 @@ RSpec.describe 'Merge request > User creates image diff notes', :js do
it 'shows indicator and avatar badges, and allows collapsing/expanding the discussion notes' do it 'shows indicator and avatar badges, and allows collapsing/expanding the discussion notes' do
indicator = find('.js-image-badge') indicator = find('.js-image-badge')
badge = find('.image-diff-avatar-link .badge') badge = find('.image-diff-avatar-link .design-note-pin')
expect(indicator).to have_content('1') expect(indicator).to have_content('1')
expect(badge).to have_content('1') expect(badge).to have_content('1')
......
...@@ -3,6 +3,7 @@ import Vue from 'vue'; ...@@ -3,6 +3,7 @@ import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue'; import DiffFileDrafts from '~/batch_comments/components/diff_file_drafts.vue';
import DraftNote from '~/batch_comments/components/draft_note.vue'; import DraftNote from '~/batch_comments/components/draft_note.vue';
import DesignNotePin from '~/vue_shared/components/design_management/design_note_pin.vue';
Vue.use(Vuex); Vue.use(Vuex);
...@@ -40,10 +41,12 @@ describe('Batch comments diff file drafts component', () => { ...@@ -40,10 +41,12 @@ describe('Batch comments diff file drafts component', () => {
it('renders index of draft note', () => { it('renders index of draft note', () => {
factory(); factory();
expect(vm.findAll('.js-diff-notes-index').length).toEqual(2); const elements = vm.findAll(DesignNotePin);
expect(vm.findAll('.js-diff-notes-index').at(0).text()).toEqual('1'); expect(elements.length).toEqual(2);
expect(vm.findAll('.js-diff-notes-index').at(1).text()).toEqual('2'); expect(elements.at(0).props('label')).toEqual(1);
expect(elements.at(1).props('label')).toEqual(2);
}); });
}); });
...@@ -71,7 +71,7 @@ describe('DiffDiscussions', () => { ...@@ -71,7 +71,7 @@ describe('DiffDiscussions', () => {
expect(diffNotesToggle.text().trim()).toBe('1'); expect(diffNotesToggle.text().trim()).toBe('1');
expect(diffNotesToggle.classes()).toEqual( expect(diffNotesToggle.classes()).toEqual(
expect.arrayContaining(['btn-transparent', 'badge', 'badge-pill']), expect.arrayContaining(['js-diff-notes-toggle', 'gl-translate-x-n50', 'design-note-pin']),
); );
}); });
...@@ -87,8 +87,8 @@ describe('DiffDiscussions', () => { ...@@ -87,8 +87,8 @@ describe('DiffDiscussions', () => {
createComponent({ renderAvatarBadge: true }); createComponent({ renderAvatarBadge: true });
const noteableDiscussion = wrapper.find(NoteableDiscussion); const noteableDiscussion = wrapper.find(NoteableDiscussion);
expect(noteableDiscussion.find('.badge-pill').exists()).toBe(true); expect(noteableDiscussion.find('.design-note-pin').exists()).toBe(true);
expect(noteableDiscussion.find('.badge-pill').text().trim()).toBe('1'); expect(noteableDiscussion.find('.design-note-pin').text().trim()).toBe('1');
}); });
}); });
}); });
import { GlIcon } from '@gitlab/ui'; import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue';
import { createStore } from '~/mr_notes/stores'; import { createStore } from '~/mr_notes/stores';
import { imageDiffDiscussions } from '../mock_data/diff_discussions'; import { imageDiffDiscussions } from '../mock_data/diff_discussions';
...@@ -19,7 +19,7 @@ describe('Diffs image diff overlay component', () => { ...@@ -19,7 +19,7 @@ describe('Diffs image diff overlay component', () => {
extendStore(store); extendStore(store);
dispatch = jest.spyOn(store, 'dispatch').mockImplementation(); dispatch = jest.spyOn(store, 'dispatch').mockImplementation();
wrapper = shallowMount(ImageDiffOverlay, { wrapper = mount(ImageDiffOverlay, {
store, store,
parentComponent: { parentComponent: {
data() { data() {
......
...@@ -62,7 +62,10 @@ describe('badge helper', () => { ...@@ -62,7 +62,10 @@ describe('badge helper', () => {
}); });
it('should add badge classes', () => { it('should add badge classes', () => {
expect(buttonEl.className).toContain('badge badge-pill'); const classes = buttonEl.className.split(' ');
expect(classes).toEqual(
expect.arrayContaining(['design-note-pin', 'on-image', 'gl-absolute']),
);
}); });
it('should set the badge text', () => { it('should set the badge text', () => {
...@@ -105,7 +108,7 @@ describe('badge helper', () => { ...@@ -105,7 +108,7 @@ describe('badge helper', () => {
beforeEach(() => { beforeEach(() => {
containerEl.innerHTML = ` containerEl.innerHTML = `
<div id="${noteId}"> <div id="${noteId}">
<div class="badge hidden"> <div class="design-note-pin hidden">
</div> </div>
</div> </div>
`; `;
...@@ -116,7 +119,7 @@ describe('badge helper', () => { ...@@ -116,7 +119,7 @@ describe('badge helper', () => {
badgeNumber, badgeNumber,
}, },
}); });
avatarBadgeEl = containerEl.querySelector(`#${noteId} .badge`); avatarBadgeEl = containerEl.querySelector(`#${noteId} .design-note-pin`);
}); });
it('should update badge number', () => { it('should update badge number', () => {
......
...@@ -37,14 +37,16 @@ describe('domHelper', () => { ...@@ -37,14 +37,16 @@ describe('domHelper', () => {
discussionEl = document.createElement('div'); discussionEl = document.createElement('div');
discussionEl.innerHTML = ` discussionEl.innerHTML = `
<a href="#" class="image-diff-avatar-link"> <a href="#" class="image-diff-avatar-link">
<div class="badge"></div> <div class="design-note-pin"></div>
</a> </a>
`; `;
domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber); domHelper.updateDiscussionAvatarBadgeNumber(discussionEl, badgeNumber);
}); });
it('should update avatar badge number', () => { it('should update avatar badge number', () => {
expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString()); expect(discussionEl.querySelector('.design-note-pin').textContent).toEqual(
badgeNumber.toString(),
);
}); });
}); });
...@@ -54,13 +56,15 @@ describe('domHelper', () => { ...@@ -54,13 +56,15 @@ describe('domHelper', () => {
beforeEach(() => { beforeEach(() => {
discussionEl = document.createElement('div'); discussionEl = document.createElement('div');
discussionEl.innerHTML = ` discussionEl.innerHTML = `
<div class="badge"></div> <div class="design-note-pin"></div>
`; `;
domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber); domHelper.updateDiscussionBadgeNumber(discussionEl, badgeNumber);
}); });
it('should update discussion badge number', () => { it('should update discussion badge number', () => {
expect(discussionEl.querySelector('.badge').textContent).toEqual(badgeNumber.toString()); expect(discussionEl.querySelector('.design-note-pin').textContent).toEqual(
badgeNumber.toString(),
);
}); });
}); });
......
...@@ -15,9 +15,9 @@ describe('ImageDiff', () => { ...@@ -15,9 +15,9 @@ describe('ImageDiff', () => {
<div class="js-image-frame"> <div class="js-image-frame">
<img src="${TEST_HOST}/image.png"> <img src="${TEST_HOST}/image.png">
<div class="comment-indicator"></div> <div class="comment-indicator"></div>
<div id="badge-1" class="badge">1</div> <div id="badge-1" class="design-note-pin">1</div>
<div id="badge-2" class="badge">2</div> <div id="badge-2" class="design-note-pin">2</div>
<div id="badge-3" class="badge">3</div> <div id="badge-3" class="design-note-pin">3</div>
</div> </div>
<div class="note-container"> <div class="note-container">
<div class="discussion-notes"> <div class="discussion-notes">
...@@ -335,7 +335,7 @@ describe('ImageDiff', () => { ...@@ -335,7 +335,7 @@ describe('ImageDiff', () => {
describe('cascade badge count', () => { describe('cascade badge count', () => {
it('should update next imageBadgeEl value', () => { it('should update next imageBadgeEl value', () => {
const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.badge'); const imageBadgeEls = imageDiff.imageFrameEl.querySelectorAll('.design-note-pin');
expect(imageBadgeEls[0].textContent).toEqual('1'); expect(imageBadgeEls[0].textContent).toEqual('1');
expect(imageBadgeEls[1].textContent).toEqual('2'); expect(imageBadgeEls[1].textContent).toEqual('2');
......
...@@ -39,4 +39,72 @@ describe('Design note pin component', () => { ...@@ -39,4 +39,72 @@ describe('Design note pin component', () => {
createComponent({ position: null }); createComponent({ position: null });
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('applies `on-image` class when isOnImage is true', () => {
createComponent({ isOnImage: true });
expect(wrapper.find('.on-image').exists()).toBe(true);
});
it('applies `draft` class when isDraft is true', () => {
createComponent({ isDraft: true });
expect(wrapper.find('.draft').exists()).toBe(true);
});
describe('size', () => {
it('is `sm` it applies `small` class', () => {
createComponent({ size: 'sm' });
expect(wrapper.find('.small').exists()).toBe(true);
});
it('is `md` it applies no size class', () => {
createComponent({ size: 'md' });
expect(wrapper.find('.small').exists()).toBe(false);
expect(wrapper.find('.medium').exists()).toBe(false);
});
it('throws when passed any other value except `sm` or `md`', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
createComponent({ size: 'lg' });
// eslint-disable-next-line no-console
expect(console.error).toHaveBeenCalled();
});
});
describe('ariaLabel', () => {
describe('when value is passed', () => {
it('overrides default aria-label', () => {
const ariaLabel = 'Aria Label';
createComponent({ ariaLabel });
const button = wrapper.find('button');
expect(button.attributes('aria-label')).toBe(ariaLabel);
});
});
describe('when no value is passed', () => {
it('shows new note label as aria-label when label is absent', () => {
createComponent({ label: null });
const button = wrapper.find('button');
expect(button.attributes('aria-label')).toBe('Comment form position');
});
it('shows label position as aria-label when label is present', () => {
const label = 1;
createComponent({ label, isNewNote: false });
const button = wrapper.find('button');
expect(button.attributes('aria-label')).toBe(`Comment '${label}' position`);
});
});
});
}); });
...@@ -41,7 +41,8 @@ RSpec.describe Types::Repository::BlobType do ...@@ -41,7 +41,8 @@ RSpec.describe Types::Repository::BlobType do
:ide_edit_path, :ide_edit_path,
:external_storage_url, :external_storage_url,
:fork_and_edit_path, :fork_and_edit_path,
:ide_fork_and_edit_path :ide_fork_and_edit_path,
:language
) )
end end
end end
...@@ -170,13 +170,13 @@ RSpec.describe BlobPresenter do ...@@ -170,13 +170,13 @@ RSpec.describe BlobPresenter do
let(:git_blob) { blob.__getobj__ } let(:git_blob) { blob.__getobj__ }
it 'returns highlighted content' do it 'returns highlighted content' do
expect(Gitlab::Highlight).to receive(:highlight).with('files/ruby/regex.rb', git_blob.data, plain: nil, language: nil) expect(Gitlab::Highlight).to receive(:highlight).with('files/ruby/regex.rb', git_blob.data, plain: nil, language: 'ruby')
presenter.highlight presenter.highlight
end end
it 'returns plain content when :plain is true' do it 'returns plain content when :plain is true' do
expect(Gitlab::Highlight).to receive(:highlight).with('files/ruby/regex.rb', git_blob.data, plain: true, language: nil) expect(Gitlab::Highlight).to receive(:highlight).with('files/ruby/regex.rb', git_blob.data, plain: true, language: 'ruby')
presenter.highlight(plain: true) presenter.highlight(plain: true)
end end
...@@ -189,7 +189,7 @@ RSpec.describe BlobPresenter do ...@@ -189,7 +189,7 @@ RSpec.describe BlobPresenter do
end end
it 'returns limited highlighted content' do it 'returns limited highlighted content' do
expect(Gitlab::Highlight).to receive(:highlight).with('files/ruby/regex.rb', "line one\n", plain: nil, language: nil) expect(Gitlab::Highlight).to receive(:highlight).with('files/ruby/regex.rb', "line one\n", plain: nil, language: 'ruby')
presenter.highlight(to: 1) presenter.highlight(to: 1)
end end
...@@ -247,6 +247,36 @@ RSpec.describe BlobPresenter do ...@@ -247,6 +247,36 @@ RSpec.describe BlobPresenter do
end end
end end
describe '#blob_language' do
subject { presenter.blob_language }
it { is_expected.to eq('ruby') }
context 'gitlab-language contains a match' do
before do
allow(blob).to receive(:language_from_gitattributes).and_return('cpp')
end
it { is_expected.to eq('cpp') }
end
context 'when blob is ipynb' do
let(:blob) { repository.blob_at('f6b7a707', 'files/ipython/markdown-table.ipynb') }
before do
allow(Gitlab::Diff::CustomDiff).to receive(:transformed_for_diff?).and_return(true)
end
it { is_expected.to eq('md') }
end
context 'when blob is binary' do
let(:blob) { repository.blob_at('HEAD', 'Gemfile.zip') }
it { is_expected.to be_nil }
end
end
describe '#raw_plain_data' do describe '#raw_plain_data' do
let(:blob) { repository.blob_at('HEAD', file) } let(:blob) { repository.blob_at('HEAD', file) }
......
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