Commit 146df170 authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Kushal Pandya

Added Annotations to designs

- changed image overlay
- created components for discussion and notes
- added tests
- refactored GraphQL queries structure
parent 48426f0a
......@@ -36,6 +36,10 @@ MarkdownPreview.prototype.showPreview = function($form) {
mdText = $form.find('textarea.markdown-area').val();
if (mdText === undefined) {
return;
}
if (mdText.trim().length === 0) {
preview.text(this.emptyMessage);
this.hideReferencedUsers($form);
......
......@@ -27,7 +27,6 @@ export default {
return {
width: 0,
height: 0,
isLoaded: false,
};
},
computed: {
......@@ -63,8 +62,6 @@ export default {
this.height = contentImg.naturalHeight;
this.$nextTick(() => {
this.isLoaded = true;
this.$emit('imgLoaded', {
width: this.width,
height: this.height,
......
......@@ -3,7 +3,7 @@
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/660) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.2.
CAUTION: **Warning:**
This an __alpha__ feature and is subject to change at any time without
This an **alpha** feature and is subject to change at any time without
prior notice.
## Overview
......@@ -56,3 +56,14 @@ of the design, and will replace the previous version.
Images on the Design Management page can be enlarged by clicking on them.
## Adding annotations to designs
When a design image is displayed, you can add annotations to it by clicking on
the image. A badge is added to the image and a form is displayed to start a new
discussion. For example:
![Starting a new discussion on design](img/adding_note_to_design_1.png)
When submitted, the form saves a badge linked to the discussion on the image. Different discussions have different badge numbers. For example:
![Discussions on design annotations](img/adding_note_to_design_2.png)
<script>
import { s__ } from '~/locale';
import createFlash from '~/flash';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import createNoteMutation from '../../graphql/mutations/createNote.mutation.graphql';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import DesignNote from './design_note.vue';
import DesignReplyForm from './design_reply_form.vue';
import { extractCurrentDiscussion } from '../../utils/design_management_utils';
export default {
components: {
DesignNote,
ReplyPlaceholder,
DesignReplyForm,
},
props: {
discussion: {
type: Object,
required: true,
},
noteableId: {
type: String,
required: true,
},
designId: {
type: String,
required: true,
},
discussionIndex: {
type: Number,
required: true,
},
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
discussionComment: '',
isFormRendered: false,
isNoteSaving: false,
};
},
computed: {
isSubmitButtonDisabled() {
return this.discussionComment.trim().length === 0;
},
},
methods: {
addDiscussionComment() {
this.isNoteSaving = true;
return this.$apollo
.mutate({
mutation: createNoteMutation,
variables: {
input: {
noteableId: this.noteableId,
body: this.discussionComment,
discussionId: this.discussion.id,
},
},
update: (store, { data: { createNote } }) => {
const data = store.readQuery({
query: getDesignQuery,
variables: {
id: this.designId,
},
});
const currentDiscussion = extractCurrentDiscussion(
data.design.discussions,
this.discussion.id,
);
currentDiscussion.node.notes.edges.push({
__typename: 'NoteEdge',
node: createNote.note,
});
store.writeQuery({ query: getDesignQuery, data });
},
})
.then(() => {
this.discussionComment = '';
this.hideForm();
})
.catch(e => {
createFlash(s__('DesignManagement|Could not add a new comment. Please try again'));
throw e;
})
.finally(() => {
this.isNoteSaving = false;
});
},
hideForm() {
this.isFormRendered = false;
},
showForm() {
this.isFormRendered = true;
},
},
};
</script>
<template>
<div class="design-discussion-wrapper">
<div class="badge badge-pill" type="button">{{ discussionIndex }}</div>
<div class="design-discussion bordered-box position-relative">
<design-note v-for="note in discussion.notes" :key="note.id" :note="note" />
<div class="reply-wrapper">
<reply-placeholder
v-if="!isFormRendered"
class="qa-discussion-reply"
:button-text="__('Reply...')"
@onClick="showForm"
/>
<design-reply-form
v-else
v-model="discussionComment"
:is-saving="isNoteSaving"
:markdown-preview-path="markdownPreviewPath"
@submitForm="addDiscussionComment"
@cancelForm="hideForm"
/>
</div>
</div>
</div>
</template>
<script>
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
UserAvatarLink,
TimelineEntryItem,
TimeAgoTooltip,
},
props: {
note: {
type: Object,
required: true,
},
},
computed: {
author() {
return this.note.author;
},
},
};
</script>
<template>
<timeline-entry-item class="design-note note-form">
<user-avatar-link
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="author.username"
:img-size="40"
/>
<a
v-once
:href="author.webUrl"
class="js-user-link"
:data-user-id="author.id"
:data-username="author.username"
>
<span class="note-header-author-name bold">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
<span class="note-headline-light">@{{ author.username }}</span>
</a>
<span class="note-headline-light note-headline-meta">
<span class="system-note-message"> <slot></slot> </span>
<template v-if="note.createdAt">
<span class="system-note-separator"></span>
<span class="note-timestamp system-note-separator">
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
</span>
</template>
</span>
<div class="note-text md" v-html="note.bodyHtml"></div>
</timeline-entry-item>
</template>
<script>
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
export default {
name: 'DesignReplyForm',
components: {
MarkdownField,
},
props: {
markdownPreviewPath: {
type: String,
required: false,
default: '',
},
value: {
type: String,
required: true,
},
isSaving: {
type: Boolean,
required: true,
},
},
computed: {
hasValue() {
return this.value.trim().length > 0;
},
},
mounted() {
this.$refs.textarea.focus();
},
methods: {
submitForm() {
if (this.hasValue) this.$emit('submitForm');
},
},
};
</script>
<template>
<form class="new-note common-note-form">
<markdown-field
:markdown-preview-path="markdownPreviewPath"
:can-attach-file="false"
:enable-autocomplete="false"
markdown-docs-path="/help/user/markdown"
class="bordered-box"
>
<textarea
slot="textarea"
ref="textarea"
:value="value"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
dir="auto"
data-supports-quick-actions="false"
:aria-label="__('Description')"
:placeholder="__('Write a comment…')"
@input="$emit('input', $event.target.value)"
@keydown.meta.enter="submitForm"
@keydown.ctrl.enter="submitForm"
@keydown.esc.exact="$emit('cancelForm')"
>
</textarea>
</markdown-field>
<div class="note-form-actions">
<button
:disabled="!hasValue || isSaving"
class="btn btn-success js-comment-button js-comment-submit-button
qa-comment-button"
type="submit"
data-track-event="click_button"
@click.prevent="$emit('submitForm')"
>
{{ __('Save comment') }}
</button>
</div>
</form>
</template>
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'DesignOverlay',
components: {
Icon,
},
props: {
position: {
type: Object,
required: true,
},
notes: {
type: Array,
required: false,
default: () => [],
},
currentCommentForm: {
type: Object,
required: false,
default: null,
},
},
computed: {
overlayDimensions() {
return {
width: `${this.position.width}px`,
height: `${this.position.height}px`,
left: `calc(50% - ${this.position.width / 2}px)`,
top: `calc(50% - ${this.position.height / 2}px)`,
};
},
},
methods: {
clickedImage(x, y) {
this.$emit('openCommentForm', { x, y });
},
getNotePosition(data) {
const { x, y, width, height } = data;
const widthRatio = this.position.width / width;
const heightRatio = this.position.height / height;
return {
left: `${Math.round(x * widthRatio)}px`,
top: `${Math.round(y * heightRatio)}px`,
};
},
},
};
</script>
<template>
<div class="position-absolute image-diff-overlay frame" :style="overlayDimensions">
<button
type="button"
class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
@click="clickedImage($event.offsetX, $event.offsetY)"
></button>
<button
v-for="(note, index) in notes"
:key="note.id"
:style="getNotePosition(note.position)"
class="js-image-badge badge badge-pill position-absolute"
type="button"
>
{{ index + 1 }}
</button>
<button
v-if="currentCommentForm"
:style="getNotePosition(currentCommentForm)"
:aria-label="__('Comment form position')"
class="btn-transparent comment-indicator position-absolute"
type="button"
>
<icon name="image-comment-dark" />
</button>
</div>
</template>
<script>
import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui';
export default {
......@@ -16,9 +17,39 @@ export default {
required: false,
default: '',
},
isLoading: {
type: Boolean,
required: true,
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
},
mounted() {
this.onImgLoad();
this.resizeThrottled = _.throttle(this.onImgLoad, 400);
window.addEventListener('resize', this.resizeThrottled, false);
},
methods: {
onImgLoad() {
requestIdleCallback(this.calculateImgSize, { timeout: 1000 });
},
calculateImgSize() {
const { contentImg } = this.$refs;
if (!contentImg) return;
this.$nextTick(() => {
const naturalRatio = contentImg.naturalWidth / contentImg.naturalHeight;
const visibleRatio = contentImg.width / contentImg.height;
const position = {
// Handling the case where img element takes more width than visible image thanks to object-fit: contain
width:
naturalRatio < visibleRatio
? contentImg.clientHeight * naturalRatio
: contentImg.clientWidth,
height: contentImg.clientHeight,
};
this.$emit('setOverlayDimensions', position);
});
},
},
};
......@@ -26,7 +57,12 @@ export default {
<template>
<div class="d-flex align-items-center h-100 w-100 p-3 overflow-hidden js-design-image">
<gl-loading-icon v-if="isLoading" class="ml-auto mr-auto" size="md" color="light" />
<img v-else :src="image" :alt="name" class="ml-auto mr-auto img-fluid mh-100" />
<img
ref="contentImg"
:src="image"
:alt="name"
class="ml-auto mr-auto img-fluid mh-100 design-image"
@load="onImgLoad"
/>
</div>
</template>
......@@ -17,10 +17,6 @@ export default {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
name: {
type: String,
required: false,
......@@ -58,11 +54,8 @@ export default {
<icon :size="18" name="close" />
</router-link>
<div>
<gl-loading-icon v-if="isLoading" size="md" class="mt-2 mb-2" />
<template v-else>
<h2 class="m-0">{{ name }}</h2>
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</template>
<h2 class="m-0">{{ name }}</h2>
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</div>
<pagination :id="id" class="ml-auto" />
</header>
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import appDataQuery from './queries/appData.graphql';
import allDesigns from './queries/allDesigns.graphql';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import appDataQuery from './graphql/queries/appData.query.graphql';
import projectQuery from './graphql/queries/project.query.graphql';
Vue.use(VueApollo);
const defaultClient = createDefaultClient({
Query: {
design(ctx, { id }, { cache }) {
design(ctx, { id }, { cache, client }) {
const { projectPath, issueIid } = cache.readQuery({ query: appDataQuery });
const result = cache.readQuery({
query: allDesigns,
variables: { fullPath: projectPath, iid: issueIid },
});
return result.project.issue.designs.designs.edges.find(({ node }) => node.filename === id)
.node;
return client
.query({
query: projectQuery,
variables: { fullPath: projectPath, iid: issueIid },
})
.then(({ data, errors }) => {
if (errors) {
createFlash(
s__('DesignManagement|An error occurred while loading designs. Please try again.'),
);
throw new Error(errors);
}
const edge = data.project.issue.designs.designs.edges.find(
({ node }) => node.filename === id,
);
return edge.node;
})
.catch(() => {
createFlash(
s__('DesignManagement|An error occurred while loading designs. Please try again.'),
);
});
},
},
});
......
#import "./designNote.fragment.graphql"
#import "./diffRefs.fragment.graphql"
fragment DesignListItem on Design {
id
image
filename
fullPath
diffRefs {
...DesignDiffRefs
}
discussions {
edges {
node {
id
replyId
notes {
edges {
node {
...DesignNote
}
}
}
}
}
}
}
#import "./diffRefs.fragment.graphql"
fragment DesignNote on Note {
id
author {
avatarUrl
name
username
webUrl
}
body
bodyHtml
createdAt
position {
diffRefs {
...DesignDiffRefs
}
x
y
height
width
}
}
fragment DesignDiffRefs on DiffRefs {
baseSha
startSha
headSha
}
#import "../fragments/designNote.fragment.graphql"
mutation createImageDiffNote($input: CreateImageDiffNoteInput!) {
createImageDiffNote(input: $input) {
note {
...DesignNote
discussion {
id
replyId
notes {
edges {
node {
...DesignNote
}
}
}
}
}
}
}
#import "../fragments/designNote.fragment.graphql"
mutation createNote($input: CreateNoteInput!) {
createNote(input: $input) {
note {
...DesignNote
}
}
}
#import "./designListFragment.graphql"
#import "../fragments/designList.fragment.graphql"
mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
......
#import "../fragments/designList.fragment.graphql"
query getDesign($id: String!) {
design(id: $id) @client {
image
filename
...DesignListItem
}
}
#import "./designListFragment.graphql"
#import "../fragments/designList.fragment.graphql"
query getVersionDesigns($fullPath: ID!, $iid: String!, $atVersion: ID!) {
project(fullPath: $fullPath) {
......
#import "./designListFragment.graphql"
#import "../fragments/designList.fragment.graphql"
query project($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
......
......@@ -3,7 +3,7 @@ import Vue from 'vue';
import createRouter from './router';
import App from './components/app.vue';
import apolloProvider from './graphql';
import allDesigns from './queries/allDesigns.graphql';
import projectQuery from './graphql/queries/project.query.graphql';
export default () => {
const el = document.getElementById('js-design-management');
......@@ -28,7 +28,7 @@ export default () => {
apolloProvider.clients.defaultClient
.watchQuery({
query: allDesigns,
query: projectQuery,
variables: {
fullPath: projectPath,
iid: issueIid,
......
import appDataQuery from '../queries/appData.graphql';
import getVersionDesignsQuery from '../queries/getVersionDesigns.query.graphql';
import projectQuery from '../queries/project.query.graphql';
import appDataQuery from '../graphql/queries/appData.query.graphql';
import getVersionDesignsQuery from '../graphql/queries/getVersionDesigns.query.graphql';
import projectQuery from '../graphql/queries/project.query.graphql';
import { extractNodes } from '../utils/design_management_utils';
export default {
apollo: {
......@@ -20,7 +21,7 @@ export default {
iid: this.issueIid,
};
},
update: data => data.project.issue.designs.designs.edges.map(({ node }) => node),
update: data => extractNodes(data.project.issue.designs.designs),
error() {
this.error = true;
},
......@@ -38,7 +39,7 @@ export default {
skip() {
this.$apollo.queries.versionDesigns.skip = !this.hasValidVersion();
},
update: data => data.project.issue.designs.designs.edges.map(({ node }) => node),
update: data => extractNodes(data.project.issue.designs.designs),
},
},
data() {
......
<script>
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui';
import Toolbar from '../../components/toolbar/index.vue';
import DesignImage from '../../components/image.vue';
import getDesignQuery from '../../queries/getDesign.graphql';
import DesignOverlay from '../../components/design_overlay.vue';
import DesignDiscussion from '../../components/design_notes/design_discussion.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
import createImageDiffNoteMutation from '../../graphql/mutations/createImageDiffNote.mutation.graphql';
import appDataQuery from '../../graphql/queries/appData.query.graphql';
import { extractDiscussions } from '../../utils/design_management_utils';
export default {
components: {
DesignImage,
DesignOverlay,
DesignDiscussion,
Toolbar,
DesignReplyForm,
GlLoadingIcon,
},
props: {
id: {
......@@ -19,6 +30,14 @@ export default {
data() {
return {
design: {},
comment: '',
annotationCoordinates: null,
overlayDimensions: {
width: 0,
height: 0,
},
projectPath: '',
isNoteSaving: false,
};
},
apollo: {
......@@ -36,24 +55,173 @@ export default {
}
},
},
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath } }) {
this.projectPath = projectPath;
},
},
},
computed: {
isLoading() {
return this.$apollo.queries.design.loading;
},
discussions() {
return extractDiscussions(this.design.discussions);
},
discussionStartingNotes() {
return this.discussions.map(discussion => discussion.notes[0]);
},
markdownPreviewPath() {
return `/${this.projectPath}/preview_markdown?target_type=Issue`;
},
isSubmitButtonDisabled() {
return this.comment.trim().length === 0;
},
renderDiscussions() {
return this.discussions.length || this.annotationCoordinates;
},
},
methods: {
addImageDiffNote() {
const { x, y, width, height } = this.annotationCoordinates;
this.isNoteSaving = true;
return this.$apollo
.mutate({
mutation: createImageDiffNoteMutation,
variables: {
input: {
noteableId: this.design.id,
body: this.comment,
position: {
headSha: this.design.diffRefs.headSha,
baseSha: this.design.diffRefs.baseSha,
startSha: this.design.diffRefs.startSha,
x,
y,
width,
height,
paths: {
newPath: this.design.fullPath,
},
},
},
},
update: (store, { data: { createImageDiffNote } }) => {
const data = store.readQuery({
query: getDesignQuery,
variables: {
id: this.id,
},
});
const newDiscussion = {
__typename: 'DiscussionEdge',
node: {
__typename: 'Discussion',
id: createImageDiffNote.note.discussion.id,
replyId: createImageDiffNote.note.discussion.replyId,
notes: {
__typename: 'NoteConnection',
edges: [
{
__typename: 'NoteEdge',
node: createImageDiffNote.note,
},
],
},
},
};
data.design.discussions.edges.push(newDiscussion);
store.writeQuery({ query: getDesignQuery, data });
},
})
.then(() => {
this.closeCommentForm();
this.isNoteSaving = false;
})
.catch(e => {
this.isNoteSaving = false;
createFlash(s__('DesignManagement|Could not create new discussion, please try again.'));
throw e;
});
},
openCommentForm(position) {
const { x, y } = position;
const { width, height } = this.overlayDimensions;
this.annotationCoordinates = {
...this.annotationCoordinates,
x,
y,
width,
height,
};
},
closeCommentForm() {
this.comment = '';
this.annotationCoordinates = null;
},
setOverlayDimensions(position) {
this.overlayDimensions.width = position.width;
this.overlayDimensions.height = position.height;
},
},
beforeRouteUpdate(to, from, next) {
this.closeCommentForm();
next();
},
};
</script>
<template>
<div class="design-detail fixed-top w-100 position-bottom-0 d-flex flex-column">
<toolbar
:id="id"
:is-loading="isLoading"
:name="design.filename"
:updated-at="design.updatedAt"
:updated-by="design.updatedBy"
/>
<design-image :is-loading="isLoading" :image="design.image" :name="design.filename" />
<div class="design-detail fixed-top w-100 position-bottom-0 d-sm-flex justify-content-center">
<gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" />
<template v-else>
<div class="d-flex flex-column w-100">
<toolbar
:id="id"
:name="design.filename"
:updated-at="design.updatedAt"
:updated-by="design.updatedBy"
/>
<div class="d-flex flex-column w-100 h-100 mh-100 position-relative">
<design-image
:image="design.image"
:name="design.filename"
@setOverlayDimensions="setOverlayDimensions"
/>
<design-overlay
:position="overlayDimensions"
:notes="discussionStartingNotes"
:current-comment-form="annotationCoordinates"
@openCommentForm="openCommentForm"
/>
</div>
</div>
<div class="image-notes">
<template v-if="renderDiscussions">
<design-discussion
v-for="(discussion, index) in discussions"
:key="discussion.id"
:discussion="discussion"
:design-id="id"
:noteable-id="design.id"
:discussion-index="index + 1"
:markdown-preview-path="markdownPreviewPath"
/>
<design-reply-form
v-if="annotationCoordinates"
v-model="comment"
:is-saving="isNoteSaving"
:markdown-preview-path="markdownPreviewPath"
@submitForm="addImageDiffNote"
@cancelForm="closeCommentForm"
/>
</template>
<h2 v-else class="new-discussion-disclaimer m-0">
{{ __("Click the image where you'd like to start a new discussion") }}
</h2>
</div>
</template>
</div>
</template>
......@@ -6,10 +6,10 @@ import { s__, sprintf } from '~/locale';
import DesignList from '../components/list/index.vue';
import UploadForm from '../components/upload/form.vue';
import EmptyState from '../components/empty_state.vue';
import uploadDesignMutation from '../queries/uploadDesign.graphql';
import permissionsQuery from '../queries/permissions.graphql';
import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql';
import permissionsQuery from '../graphql/queries/permissions.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import projectQuery from '../queries/project.query.graphql';
import projectQuery from '../graphql/queries/project.query.graphql';
const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
......@@ -93,6 +93,13 @@ export default {
id: -_.uniqueId(),
image: '',
filename: file.name,
fullPath: '',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
versions: {
__typename: 'DesignVersionConnection',
edges: {
......@@ -188,15 +195,14 @@ export default {
},
})
.then(() => {
this.isSaving = false;
this.$router.push('/designs');
})
.catch(e => {
this.isSaving = false;
createFlash(s__('DesignManagement|Error uploading a new design. Please try again'));
throw e;
})
.finally(() => {
this.isSaving = false;
});
},
},
......
#import "./designListFragment.graphql"
query allDesigns($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
designs {
designs {
edges {
node {
...DesignListItem
}
}
}
}
}
}
}
/**
* Returns formatted array that doesn't contain
* `edges`->`node` nesting
*
* @param {Array} elements
*/
export const extractNodes = elements => elements.edges.map(({ node }) => node);
/**
* Returns formatted array of discussions that doesn't contain
* `edges`->`node` nesting for child notes
*
* @param {Array} discussions
*/
export const extractDiscussions = discussions =>
discussions.edges.map(discussion => {
const discussionNode = { ...discussion.node };
discussionNode.notes = extractNodes(discussionNode.notes);
return discussionNode;
});
/**
* Returns a discussion with the given id from discussions array
*
* @param {Array} discussions
*/
export const extractCurrentDiscussion = (discussions, id) =>
discussions.edges.find(({ node }) => node.id === id);
.design-image {
object-fit: contain;
}
.design-detail {
overflow-y: scroll;
}
.image-notes {
overflow-y: scroll;
padding: 0 $gl-padding;
padding-top: 50px;
background-color: $white-light;
min-width: 400px;
li {
list-style: none;
}
.badge.badge-pill {
margin-left: $gl-padding;
background-color: $blue-400;
color: $white-light;
border: $white-light 1px solid;
min-height: 28px;
padding: 7px 10px;
border-radius: $gl-padding;
}
.design-discussion {
padding: $gl-padding 0;
margin: $gl-padding 0;
&::before {
content: '';
border-left: 1px solid $gray-200;
position: absolute;
left: 28px;
top: -18px;
height: 18px;
}
.design-note {
padding: $gl-padding;
a {
color: inherit;
}
}
.reply-wrapper {
padding: $gl-padding;
padding-bottom: 0;
}
}
.reply-wrapper {
border-top: 1px solid $border-color;
}
.new-discussion-disclaimer {
line-height: 20px;
}
}
@media (max-width: map-get($grid-breakpoints, sm)) {
.design-detail {
overflow-y: scroll;
}
.image-notes {
overflow-y: auto;
min-width: 100%;
}
}
---
title: Resolve Add point of interest discussions to designs
merge_request: 14648
author:
type: added
......@@ -6,7 +6,7 @@ exports[`Design management large image component renders image 1`] = `
>
<img
alt="test"
class="ml-auto mr-auto img-fluid mh-100"
class="ml-auto mr-auto img-fluid mh-100 design-image"
src="test.jpg"
/>
</div>
......@@ -15,12 +15,12 @@ exports[`Design management large image component renders image 1`] = `
exports[`Design management large image component renders loading state 1`] = `
<div
class="d-flex align-items-center h-100 w-100 p-3 overflow-hidden js-design-image"
isloading="true"
>
<glloadingicon-stub
class="ml-auto mr-auto"
color="light"
label="Loading"
size="md"
<img
alt=""
class="ml-auto mr-auto img-fluid mh-100 design-image"
src=""
/>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue';
import DesignDiscussion from 'ee/design_management/components/design_notes/design_discussion.vue';
import DesignNote from 'ee/design_management/components/design_notes/design_note.vue';
import DesignReplyForm from 'ee/design_management/components/design_notes/design_reply_form.vue';
import createNoteMutation from 'ee/design_management/graphql/mutations/createNote.mutation.graphql';
describe('Design discussions component', () => {
let wrapper;
const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder);
const findReplyForm = () => wrapper.find(DesignReplyForm);
const mutationVariables = {
mutation: createNoteMutation,
update: expect.anything(),
variables: {
input: {
noteableId: 'noteable-id',
body: 'test',
discussionId: '0',
},
},
};
const mutate = jest.fn(() => Promise.resolve());
const $apollo = {
mutate,
};
function createComponent(props = {}) {
wrapper = shallowMount(DesignDiscussion, {
sync: false,
propsData: {
discussion: {
id: '0',
notes: [
{
id: '1',
},
{
id: '2',
},
],
},
noteableId: 'noteable-id',
designId: 'design-id',
discussionIndex: 1,
...props,
},
stubs: {
ReplyPlaceholder,
},
mocks: { $apollo },
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders correct amount of discussion notes', () => {
expect(wrapper.findAll(DesignNote).length).toBe(2);
});
it('renders reply placeholder by default', () => {
expect(findReplyPlaceholder().exists()).toBe(true);
});
it('hides reply placeholder and opens form on placeholder click', () => {
findReplyPlaceholder().trigger('click');
wrapper.vm.$nextTick(() => {
expect(findReplyPlaceholder().exists()).toBe(false);
expect(findReplyForm().exists()).toBe(true);
});
});
it('calls mutation on submitting form and closes the form', () => {
wrapper.setData({
discussionComment: 'test',
isFormRendered: true,
});
wrapper.vm.$nextTick(() => {
findReplyForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
const addComment = wrapper.vm.addDiscussionComment();
return addComment.then(() => {
expect(findReplyForm().exists()).toBe(false);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import DesignNote from 'ee/design_management/components/design_notes/design_note.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
describe('Design note component', () => {
let wrapper;
const findUserAvatar = () => wrapper.find(UserAvatarLink);
const findUserLink = () => wrapper.find('.js-user-link');
function createComponent(props = {}) {
wrapper = shallowMount(DesignNote, {
sync: false,
propsData: {
note: {},
...props,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('should render an author', () => {
createComponent({
note: {
author: {
id: 'author-id',
},
},
});
expect(findUserAvatar().exists()).toBe(true);
expect(findUserLink().exists()).toBe(true);
});
it('should render a time ago tooltip if note has createdAt property', () => {
createComponent({
note: {
createdAt: '2019-07-26T15:02:20Z',
author: {
id: 'author-id',
},
},
});
expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
});
});
import { shallowMount } from '@vue/test-utils';
import DesignReplyForm from 'ee/design_management/components/design_notes/design_reply_form.vue';
describe('Design reply form component', () => {
let wrapper;
const findTextarea = () => wrapper.find('textarea');
const findSubmitButton = () => wrapper.find('.js-comment-submit-button');
function createComponent(props = {}) {
wrapper = shallowMount(DesignReplyForm, {
sync: false,
propsData: {
value: '',
isSaving: false,
...props,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('textarea has focus after component mount', () => {
createComponent();
expect(findTextarea().element).toEqual(document.activeElement);
});
describe('when form has no text', () => {
beforeEach(() => {
createComponent({
value: '',
});
});
it('submit button is disabled', () => {
expect(findSubmitButton().attributes().disabled).toBeTruthy();
});
it('does not emit submitForm event on textarea ctrl+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
ctrlKey: true,
});
expect(wrapper.emitted('submitForm')).toBeFalsy();
});
it('does not emit submitForm event on textarea meta+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
metaKey: true,
});
expect(wrapper.emitted('submitForm')).toBeFalsy();
});
});
describe('when form has text', () => {
beforeEach(() => {
createComponent({
value: 'test',
});
});
it('submit button is enabled', () => {
expect(findSubmitButton().attributes().disabled).toBeFalsy();
});
it('emits submitForm event on button click', () => {
findSubmitButton().trigger('click');
expect(wrapper.emitted('submitForm')).toBeTruthy();
});
it('emits submitForm event on textarea ctrl+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
ctrlKey: true,
});
expect(wrapper.emitted('submitForm')).toBeTruthy();
});
it('emits submitForm event on textarea meta+enter keydown', () => {
findTextarea().trigger('keydown.enter', {
metaKey: true,
});
expect(wrapper.emitted('submitForm')).toBeTruthy();
});
it('emits input event on changing textarea content', () => {
findTextarea().setValue('test2');
expect(wrapper.emitted('input')).toBeTruthy();
});
it('emits cancelForm event on escape keydown on textarea', () => {
findTextarea().trigger('keydown.esc');
expect(wrapper.emitted('cancelForm')).toBeTruthy();
});
});
});
import { shallowMount } from '@vue/test-utils';
import DesignOverlay from 'ee/design_management/components/design_overlay.vue';
describe('Design overlay component', () => {
let wrapper;
const notes = [
{
position: {
height: 100,
width: 100,
x: 10,
y: 15,
},
},
{
position: {
height: 50,
width: 50,
x: 25,
y: 25,
},
},
];
const findAllNotes = () => wrapper.findAll('.js-image-badge');
const findCommentBadge = () => wrapper.find('.comment-indicator');
const findFirstBadge = () => findAllNotes().at(0);
const findSecondBadge = () => findAllNotes().at(1);
function createComponent(props = {}) {
wrapper = shallowMount(DesignOverlay, {
sync: false,
propsData: {
position: {
width: 100,
height: 100,
},
...props,
},
});
}
it('should have correct inline style', () => {
createComponent();
expect(wrapper.find('.image-diff-overlay').attributes().style).toBe(
'width: 100px; height: 100px;',
);
});
it('should emit a correct event when clicking on overlay', () => {
createComponent();
wrapper.find('.image-diff-overlay-add-comment').trigger('click', { offsetX: 10, offsetY: 10 });
expect(wrapper.emitted('openCommentForm')).toEqual([[{ x: 10, y: 10 }]]);
});
describe('when has notes', () => {
beforeEach(() => {
createComponent({
notes,
});
});
it('should render a correct amount of notes', () => {
expect(findAllNotes().length).toBe(notes.length);
});
it('should have a correct style for each note badge', () => {
expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
});
});
it('should render a new comment badge when there is a new form', () => {
createComponent({
currentCommentForm: {
height: 100,
width: 100,
x: 25,
y: 25,
},
});
expect(findCommentBadge().exists()).toBe(true);
expect(findCommentBadge().attributes().style).toBe('left: 25px; top: 25px;');
});
it('should recalculate badges positions on window resize', () => {
createComponent({
notes,
position: {
width: 400,
height: 400,
},
});
expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;');
wrapper.setProps({
position: {
width: 200,
height: 200,
},
});
wrapper.vm.$nextTick(() => {
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
});
});
});
......@@ -31,4 +31,6 @@ describe('Design management large image component', () => {
expect(vm.element).toMatchSnapshot();
});
it('emits a setDimensions event on resize', () => {});
});
......@@ -35,34 +35,3 @@ exports[`Design management toolbar component renders design and updated data 1`]
/>
</header>
`;
exports[`Design management toolbar component renders loading icon 1`] = `
<header
class="d-flex w-100 p-2 bg-white align-items-center js-design-header"
>
<a
aria-label="Go back to designs"
class="mr-3 text-plain"
>
<icon-stub
cssclasses=""
name="close"
size="18"
/>
</a>
<div>
<glloadingicon-stub
class="mt-2 mb-2"
color="orange"
label="Loading"
size="md"
/>
</div>
<pagination-stub
class="ml-auto"
id="1"
/>
</header>
`;
......@@ -35,12 +35,6 @@ describe('Design management toolbar component', () => {
});
}
it('renders loading icon', () => {
createComponent(true);
expect(vm.element).toMatchSnapshot();
});
it('renders design and updated data', () => {
createComponent();
......
export default {
id: 'design-id',
filename: 'test.jpg',
fullPath: 'full-design-path',
image: 'test.jpg',
updatedAt: '01-01-2019',
updatedBy: {
name: 'test',
},
discussions: {
edges: [
{
node: {
id: 'discussion-id',
replyId: 'discussion-reply-id',
notes: {
edges: [
{
node: {
id: 'note-id',
body: '123',
},
},
],
},
},
},
],
},
diffRefs: {
headSha: 'headSha',
baseSha: 'baseSha',
startSha: 'startSha',
},
};
......@@ -2,36 +2,26 @@
exports[`Design management design index page renders design index 1`] = `
<div
class="design-detail fixed-top w-100 position-bottom-0 d-flex flex-column"
class="design-detail fixed-top w-100 position-bottom-0 d-sm-flex justify-content-center"
>
<toolbar-stub
id="1"
name="test.jpg"
updatedby="[object Object]"
/>
<designimage-stub
image="test.jpg"
name="test.jpg"
<glloadingicon-stub
class="align-self-center"
color="orange"
label="Loading"
size="xl"
/>
</div>
`;
exports[`Design management design index page sets loading state 1`] = `
<div
class="design-detail fixed-top w-100 position-bottom-0 d-flex flex-column"
class="design-detail fixed-top w-100 position-bottom-0 d-sm-flex justify-content-center"
>
<toolbar-stub
id="1"
isloading="true"
name=""
updatedby="[object Object]"
/>
<designimage-stub
image=""
isloading="true"
name=""
<glloadingicon-stub
class="align-self-center"
color="orange"
label="Loading"
size="xl"
/>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import DesignIndex from 'ee/design_management/pages/design/index.vue';
import DesignDiscussion from 'ee/design_management/components/design_notes/design_discussion.vue';
import DesignReplyForm from 'ee/design_management/components/design_notes/design_reply_form.vue';
import createImageDiffNoteMutation from 'ee/design_management/graphql/mutations/createImageDiffNote.mutation.graphql';
import design from '../../mock_data/design';
describe('Design management design index page', () => {
let vm;
let wrapper;
const newComment = 'new comment';
const annotationCoordinates = {
x: 10,
y: 10,
width: 100,
height: 100,
};
const mutationVariables = {
mutation: createImageDiffNoteMutation,
update: expect.anything(),
variables: {
input: {
body: newComment,
noteableId: design.id,
position: {
headSha: 'headSha',
baseSha: 'baseSha',
startSha: 'startSha',
paths: {
newPath: 'full-design-path',
},
...annotationCoordinates,
},
},
},
};
const mutate = jest.fn(() => Promise.resolve());
const findDiscussions = () => wrapper.findAll(DesignDiscussion);
const findDiscussionForm = () => wrapper.find(DesignReplyForm);
function createComponent(loading = false) {
const $apollo = {
......@@ -11,34 +46,120 @@ describe('Design management design index page', () => {
loading,
},
},
mutate,
};
vm = shallowMount(DesignIndex, {
wrapper = shallowMount(DesignIndex, {
sync: false,
propsData: { id: '1' },
mocks: { $apollo },
});
}
function setDesign() {
createComponent(true);
wrapper.vm.$apollo.queries.design.loading = false;
}
afterEach(() => {
wrapper.destroy();
});
it('sets loading state', () => {
createComponent(true);
expect(vm.element).toMatchSnapshot();
expect(wrapper.element).toMatchSnapshot();
});
it('renders design index', () => {
createComponent();
setDesign();
wrapper.setData({
design,
});
vm.setData({
expect(wrapper.element).toMatchSnapshot();
});
describe('when has no discussions', () => {
beforeEach(() => {
setDesign();
wrapper.setData({
design: {
...design,
discussions: {
edges: [],
},
},
});
});
it('does not render discussions', () => {
expect(findDiscussions().exists()).toBe(false);
});
it('renders a message about possibility to create a new discussion', () => {
expect(wrapper.find('.new-discussion-disclaimer').exists()).toBe(true);
});
});
describe('when has discussions', () => {
beforeEach(() => {
setDesign();
wrapper.setData({
design,
});
});
it('renders correct amount of discussions', () => {
expect(wrapper.findAll(DesignDiscussion).length).toBe(1);
});
});
it('opens a new discussion form', () => {
setDesign();
wrapper.setData({
design: {
filename: 'test.jpg',
image: 'test.jpg',
updatedAt: '01-01-2019',
updatedBy: {
name: 'test',
...design,
discussions: {
edges: [],
},
},
});
expect(vm.element).toMatchSnapshot();
wrapper.vm.openCommentForm({ x: 0, y: 0 });
wrapper.vm.$nextTick(() => {
expect(findDiscussionForm().exists()).toBe(true);
});
});
it('sends a mutation on submitting form and closes form', () => {
setDesign();
wrapper.setData({
design: {
...design,
discussions: {
edges: [],
},
},
annotationCoordinates,
comment: newComment,
});
wrapper.vm.$nextTick(() => {
findDiscussionForm().vm.$emit('submitForm');
expect(mutate).toHaveBeenCalledWith(mutationVariables);
const addNote = wrapper.vm.addImageDiffNote();
return addNote.then(() => {
expect(findDiscussionForm().exists()).toBe(false);
});
});
});
});
......@@ -2,7 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router';
import Index from 'ee/design_management/pages/index.vue';
import UploadForm from 'ee/design_management/components/upload/form.vue';
import uploadDesignQuery from 'ee/design_management/queries/uploadDesign.graphql';
import uploadDesignQuery from 'ee/design_management/graphql/mutations/uploadDesign.mutation.graphql';
const localVue = createLocalVue();
localVue.use(VueRouter);
......@@ -119,6 +119,13 @@ describe('Design management index page', () => {
id: expect.anything(),
image: '',
filename: 'test',
fullPath: '',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
versions: {
__typename: 'DesignVersionConnection',
edges: {
......
......@@ -3014,6 +3014,9 @@ msgstr ""
msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
msgstr ""
msgid "Click the image where you'd like to start a new discussion"
msgstr ""
msgid "Click to expand it."
msgstr ""
......@@ -4800,6 +4803,15 @@ msgstr ""
msgid "DesignManagement|Add designs"
msgstr ""
msgid "DesignManagement|An error occurred while loading designs. Please try again."
msgstr ""
msgid "DesignManagement|Could not add a new comment. Please try again"
msgstr ""
msgid "DesignManagement|Could not create new discussion, please try again."
msgstr ""
msgid "DesignManagement|Could not find design, please try again."
msgstr ""
......@@ -12100,6 +12112,9 @@ msgstr ""
msgid "Reply to this email directly or %{view_it_on_gitlab}."
msgstr ""
msgid "Reply..."
msgstr ""
msgid "Repo by URL"
msgstr ""
......@@ -16661,6 +16676,9 @@ msgstr ""
msgid "Write a comment or drag your files here…"
msgstr ""
msgid "Write a comment…"
msgstr ""
msgid "Write access allowed"
msgstr ""
......
......@@ -75,3 +75,18 @@ global.MutationObserver = () => ({
disconnect: () => {},
observe: () => {},
});
Object.assign(global, {
requestIdleCallback(cb) {
const start = Date.now();
return setTimeout(() => {
cb({
didTimeout: false,
timeRemaining: () => Math.max(0, 50 - (Date.now() - start)),
});
});
},
cancelIdleCallback(id) {
clearTimeout(id);
},
});
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