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) { ...@@ -36,6 +36,10 @@ MarkdownPreview.prototype.showPreview = function($form) {
mdText = $form.find('textarea.markdown-area').val(); mdText = $form.find('textarea.markdown-area').val();
if (mdText === undefined) {
return;
}
if (mdText.trim().length === 0) { if (mdText.trim().length === 0) {
preview.text(this.emptyMessage); preview.text(this.emptyMessage);
this.hideReferencedUsers($form); this.hideReferencedUsers($form);
......
...@@ -27,7 +27,6 @@ export default { ...@@ -27,7 +27,6 @@ export default {
return { return {
width: 0, width: 0,
height: 0, height: 0,
isLoaded: false,
}; };
}, },
computed: { computed: {
...@@ -63,8 +62,6 @@ export default { ...@@ -63,8 +62,6 @@ export default {
this.height = contentImg.naturalHeight; this.height = contentImg.naturalHeight;
this.$nextTick(() => { this.$nextTick(() => {
this.isLoaded = true;
this.$emit('imgLoaded', { this.$emit('imgLoaded', {
width: this.width, width: this.width,
height: this.height, height: this.height,
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/660) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.2. > [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/660) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.2.
CAUTION: **Warning:** 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. prior notice.
## Overview ## Overview
...@@ -56,3 +56,14 @@ of the design, and will replace the previous version. ...@@ -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. 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> <script>
import _ from 'underscore';
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } from '@gitlab/ui';
export default { export default {
...@@ -16,9 +17,39 @@ export default { ...@@ -16,9 +17,39 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
isLoading: { },
type: Boolean, beforeDestroy() {
required: true, 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 { ...@@ -26,7 +57,12 @@ export default {
<template> <template>
<div class="d-flex align-items-center h-100 w-100 p-3 overflow-hidden js-design-image"> <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
<img v-else :src="image" :alt="name" class="ml-auto mr-auto img-fluid mh-100" /> ref="contentImg"
:src="image"
:alt="name"
class="ml-auto mr-auto img-fluid mh-100 design-image"
@load="onImgLoad"
/>
</div> </div>
</template> </template>
...@@ -17,10 +17,6 @@ export default { ...@@ -17,10 +17,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
isLoading: {
type: Boolean,
required: true,
},
name: { name: {
type: String, type: String,
required: false, required: false,
...@@ -58,11 +54,8 @@ export default { ...@@ -58,11 +54,8 @@ export default {
<icon :size="18" name="close" /> <icon :size="18" name="close" />
</router-link> </router-link>
<div> <div>
<gl-loading-icon v-if="isLoading" size="md" class="mt-2 mb-2" /> <h2 class="m-0">{{ name }}</h2>
<template v-else> <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
<h2 class="m-0">{{ name }}</h2>
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</template>
</div> </div>
<pagination :id="id" class="ml-auto" /> <pagination :id="id" class="ml-auto" />
</header> </header>
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import appDataQuery from './queries/appData.graphql'; import createFlash from '~/flash';
import allDesigns from './queries/allDesigns.graphql'; import { s__ } from '~/locale';
import appDataQuery from './graphql/queries/appData.query.graphql';
import projectQuery from './graphql/queries/project.query.graphql';
Vue.use(VueApollo); Vue.use(VueApollo);
const defaultClient = createDefaultClient({ const defaultClient = createDefaultClient({
Query: { Query: {
design(ctx, { id }, { cache }) { design(ctx, { id }, { cache, client }) {
const { projectPath, issueIid } = cache.readQuery({ query: appDataQuery }); const { projectPath, issueIid } = cache.readQuery({ query: appDataQuery });
const result = cache.readQuery({ return client
query: allDesigns, .query({
variables: { fullPath: projectPath, iid: issueIid }, query: projectQuery,
}); variables: { fullPath: projectPath, iid: issueIid },
})
return result.project.issue.designs.designs.edges.find(({ node }) => node.filename === id) .then(({ data, errors }) => {
.node; 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!) { mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) {
designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) { designManagementUpload(input: { projectPath: $projectPath, iid: $iid, files: $files }) {
......
#import "../fragments/designList.fragment.graphql"
query getDesign($id: String!) { query getDesign($id: String!) {
design(id: $id) @client { design(id: $id) @client {
image ...DesignListItem
filename
} }
} }
#import "./designListFragment.graphql" #import "../fragments/designList.fragment.graphql"
query getVersionDesigns($fullPath: ID!, $iid: String!, $atVersion: ID!) { query getVersionDesigns($fullPath: ID!, $iid: String!, $atVersion: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
......
#import "./designListFragment.graphql" #import "../fragments/designList.fragment.graphql"
query project($fullPath: ID!, $iid: String!) { query project($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
......
...@@ -3,7 +3,7 @@ import Vue from 'vue'; ...@@ -3,7 +3,7 @@ import Vue from 'vue';
import createRouter from './router'; import createRouter from './router';
import App from './components/app.vue'; import App from './components/app.vue';
import apolloProvider from './graphql'; import apolloProvider from './graphql';
import allDesigns from './queries/allDesigns.graphql'; import projectQuery from './graphql/queries/project.query.graphql';
export default () => { export default () => {
const el = document.getElementById('js-design-management'); const el = document.getElementById('js-design-management');
...@@ -28,7 +28,7 @@ export default () => { ...@@ -28,7 +28,7 @@ export default () => {
apolloProvider.clients.defaultClient apolloProvider.clients.defaultClient
.watchQuery({ .watchQuery({
query: allDesigns, query: projectQuery,
variables: { variables: {
fullPath: projectPath, fullPath: projectPath,
iid: issueIid, iid: issueIid,
......
import appDataQuery from '../queries/appData.graphql'; import appDataQuery from '../graphql/queries/appData.query.graphql';
import getVersionDesignsQuery from '../queries/getVersionDesigns.query.graphql'; import getVersionDesignsQuery from '../graphql/queries/getVersionDesigns.query.graphql';
import projectQuery from '../queries/project.query.graphql'; import projectQuery from '../graphql/queries/project.query.graphql';
import { extractNodes } from '../utils/design_management_utils';
export default { export default {
apollo: { apollo: {
...@@ -20,7 +21,7 @@ export default { ...@@ -20,7 +21,7 @@ export default {
iid: this.issueIid, iid: this.issueIid,
}; };
}, },
update: data => data.project.issue.designs.designs.edges.map(({ node }) => node), update: data => extractNodes(data.project.issue.designs.designs),
error() { error() {
this.error = true; this.error = true;
}, },
...@@ -38,7 +39,7 @@ export default { ...@@ -38,7 +39,7 @@ export default {
skip() { skip() {
this.$apollo.queries.versionDesigns.skip = !this.hasValidVersion(); 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() { data() {
......
<script> <script>
import createFlash from '~/flash'; import createFlash from '~/flash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { GlLoadingIcon } from '@gitlab/ui';
import Toolbar from '../../components/toolbar/index.vue'; import Toolbar from '../../components/toolbar/index.vue';
import DesignImage from '../../components/image.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 { export default {
components: { components: {
DesignImage, DesignImage,
DesignOverlay,
DesignDiscussion,
Toolbar, Toolbar,
DesignReplyForm,
GlLoadingIcon,
}, },
props: { props: {
id: { id: {
...@@ -19,6 +30,14 @@ export default { ...@@ -19,6 +30,14 @@ export default {
data() { data() {
return { return {
design: {}, design: {},
comment: '',
annotationCoordinates: null,
overlayDimensions: {
width: 0,
height: 0,
},
projectPath: '',
isNoteSaving: false,
}; };
}, },
apollo: { apollo: {
...@@ -36,24 +55,173 @@ export default { ...@@ -36,24 +55,173 @@ export default {
} }
}, },
}, },
appData: {
query: appDataQuery,
manual: true,
result({ data: { projectPath } }) {
this.projectPath = projectPath;
},
},
}, },
computed: { computed: {
isLoading() { isLoading() {
return this.$apollo.queries.design.loading; 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> </script>
<template> <template>
<div class="design-detail fixed-top w-100 position-bottom-0 d-flex flex-column"> <div class="design-detail fixed-top w-100 position-bottom-0 d-sm-flex justify-content-center">
<toolbar <gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" />
:id="id" <template v-else>
:is-loading="isLoading" <div class="d-flex flex-column w-100">
:name="design.filename" <toolbar
:updated-at="design.updatedAt" :id="id"
:updated-by="design.updatedBy" :name="design.filename"
/> :updated-at="design.updatedAt"
<design-image :is-loading="isLoading" :image="design.image" :name="design.filename" /> :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> </div>
</template> </template>
...@@ -6,10 +6,10 @@ import { s__, sprintf } from '~/locale'; ...@@ -6,10 +6,10 @@ import { s__, sprintf } from '~/locale';
import DesignList from '../components/list/index.vue'; import DesignList from '../components/list/index.vue';
import UploadForm from '../components/upload/form.vue'; import UploadForm from '../components/upload/form.vue';
import EmptyState from '../components/empty_state.vue'; import EmptyState from '../components/empty_state.vue';
import uploadDesignMutation from '../queries/uploadDesign.graphql'; import uploadDesignMutation from '../graphql/mutations/uploadDesign.mutation.graphql';
import permissionsQuery from '../queries/permissions.graphql'; import permissionsQuery from '../graphql/queries/permissions.query.graphql';
import allDesignsMixin from '../mixins/all_designs'; 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; const MAXIMUM_FILE_UPLOAD_LIMIT = 10;
...@@ -93,6 +93,13 @@ export default { ...@@ -93,6 +93,13 @@ export default {
id: -_.uniqueId(), id: -_.uniqueId(),
image: '', image: '',
filename: file.name, filename: file.name,
fullPath: '',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
versions: { versions: {
__typename: 'DesignVersionConnection', __typename: 'DesignVersionConnection',
edges: { edges: {
...@@ -188,15 +195,14 @@ export default { ...@@ -188,15 +195,14 @@ export default {
}, },
}) })
.then(() => { .then(() => {
this.isSaving = false;
this.$router.push('/designs'); this.$router.push('/designs');
}) })
.catch(e => { .catch(e => {
this.isSaving = false;
createFlash(s__('DesignManagement|Error uploading a new design. Please try again')); createFlash(s__('DesignManagement|Error uploading a new design. Please try again'));
throw e; 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`] = ` ...@@ -6,7 +6,7 @@ exports[`Design management large image component renders image 1`] = `
> >
<img <img
alt="test" 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" src="test.jpg"
/> />
</div> </div>
...@@ -15,12 +15,12 @@ exports[`Design management large image component renders image 1`] = ` ...@@ -15,12 +15,12 @@ exports[`Design management large image component renders image 1`] = `
exports[`Design management large image component renders loading state 1`] = ` exports[`Design management large image component renders loading state 1`] = `
<div <div
class="d-flex align-items-center h-100 w-100 p-3 overflow-hidden js-design-image" class="d-flex align-items-center h-100 w-100 p-3 overflow-hidden js-design-image"
isloading="true"
> >
<glloadingicon-stub <img
class="ml-auto mr-auto" alt=""
color="light" class="ml-auto mr-auto img-fluid mh-100 design-image"
label="Loading" src=""
size="md"
/> />
</div> </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', () => { ...@@ -31,4 +31,6 @@ describe('Design management large image component', () => {
expect(vm.element).toMatchSnapshot(); 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`] ...@@ -35,34 +35,3 @@ exports[`Design management toolbar component renders design and updated data 1`]
/> />
</header> </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', () => { ...@@ -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', () => { it('renders design and updated data', () => {
createComponent(); 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 @@ ...@@ -2,36 +2,26 @@
exports[`Design management design index page renders design index 1`] = ` exports[`Design management design index page renders design index 1`] = `
<div <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 <glloadingicon-stub
id="1" class="align-self-center"
name="test.jpg" color="orange"
updatedby="[object Object]" label="Loading"
/> size="xl"
<designimage-stub
image="test.jpg"
name="test.jpg"
/> />
</div> </div>
`; `;
exports[`Design management design index page sets loading state 1`] = ` exports[`Design management design index page sets loading state 1`] = `
<div <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 <glloadingicon-stub
id="1" class="align-self-center"
isloading="true" color="orange"
name="" label="Loading"
updatedby="[object Object]" size="xl"
/>
<designimage-stub
image=""
isloading="true"
name=""
/> />
</div> </div>
`; `;
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import DesignIndex from 'ee/design_management/pages/design/index.vue'; 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', () => { 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) { function createComponent(loading = false) {
const $apollo = { const $apollo = {
...@@ -11,34 +46,120 @@ describe('Design management design index page', () => { ...@@ -11,34 +46,120 @@ describe('Design management design index page', () => {
loading, loading,
}, },
}, },
mutate,
}; };
vm = shallowMount(DesignIndex, { wrapper = shallowMount(DesignIndex, {
sync: false,
propsData: { id: '1' }, propsData: { id: '1' },
mocks: { $apollo }, mocks: { $apollo },
}); });
} }
function setDesign() {
createComponent(true);
wrapper.vm.$apollo.queries.design.loading = false;
}
afterEach(() => {
wrapper.destroy();
});
it('sets loading state', () => { it('sets loading state', () => {
createComponent(true); createComponent(true);
expect(vm.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('renders design index', () => { 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: { design: {
filename: 'test.jpg', ...design,
image: 'test.jpg', discussions: {
updatedAt: '01-01-2019', edges: [],
updatedBy: {
name: 'test',
}, },
}, },
}); });
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'; ...@@ -2,7 +2,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import Index from 'ee/design_management/pages/index.vue'; import Index from 'ee/design_management/pages/index.vue';
import UploadForm from 'ee/design_management/components/upload/form.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(); const localVue = createLocalVue();
localVue.use(VueRouter); localVue.use(VueRouter);
...@@ -119,6 +119,13 @@ describe('Design management index page', () => { ...@@ -119,6 +119,13 @@ describe('Design management index page', () => {
id: expect.anything(), id: expect.anything(),
image: '', image: '',
filename: 'test', filename: 'test',
fullPath: '',
diffRefs: {
__typename: 'DiffRefs',
baseSha: '',
startSha: '',
headSha: '',
},
versions: { versions: {
__typename: 'DesignVersionConnection', __typename: 'DesignVersionConnection',
edges: { edges: {
......
...@@ -3014,6 +3014,9 @@ msgstr "" ...@@ -3014,6 +3014,9 @@ msgstr ""
msgid "Click the button below to begin the install process by navigating to the Kubernetes page" msgid "Click the button below to begin the install process by navigating to the Kubernetes page"
msgstr "" msgstr ""
msgid "Click the image where you'd like to start a new discussion"
msgstr ""
msgid "Click to expand it." msgid "Click to expand it."
msgstr "" msgstr ""
...@@ -4800,6 +4803,15 @@ msgstr "" ...@@ -4800,6 +4803,15 @@ msgstr ""
msgid "DesignManagement|Add designs" msgid "DesignManagement|Add designs"
msgstr "" 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." msgid "DesignManagement|Could not find design, please try again."
msgstr "" msgstr ""
...@@ -12100,6 +12112,9 @@ msgstr "" ...@@ -12100,6 +12112,9 @@ msgstr ""
msgid "Reply to this email directly or %{view_it_on_gitlab}." msgid "Reply to this email directly or %{view_it_on_gitlab}."
msgstr "" msgstr ""
msgid "Reply..."
msgstr ""
msgid "Repo by URL" msgid "Repo by URL"
msgstr "" msgstr ""
...@@ -16661,6 +16676,9 @@ msgstr "" ...@@ -16661,6 +16676,9 @@ msgstr ""
msgid "Write a comment or drag your files here…" msgid "Write a comment or drag your files here…"
msgstr "" msgstr ""
msgid "Write a comment…"
msgstr ""
msgid "Write access allowed" msgid "Write access allowed"
msgstr "" msgstr ""
......
...@@ -75,3 +75,18 @@ global.MutationObserver = () => ({ ...@@ -75,3 +75,18 @@ global.MutationObserver = () => ({
disconnect: () => {}, disconnect: () => {},
observe: () => {}, 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