Commit 23ae95ad authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'moveable-new-comment-pin' into 'master'

Design view: moveable "new comment" pin

See merge request gitlab-org/gitlab!24769
parents b0bd8910 02f20276
...@@ -130,7 +130,7 @@ Once selected, click the **Delete selected** button to confirm the deletion: ...@@ -130,7 +130,7 @@ Once selected, click the **Delete selected** button to confirm the deletion:
![Delete multiple designs](img/delete_multiple_designs_v12_4.png) ![Delete multiple designs](img/delete_multiple_designs_v12_4.png)
NOTE: **Note:** **Note:**
Only the latest version of the designs can be deleted. Only the latest version of the designs can be deleted.
Deleted designs are not permanently lost; they can be Deleted designs are not permanently lost; they can be
viewed by browsing previous versions. viewed by browsing previous versions.
...@@ -144,6 +144,9 @@ which you can start a new discussion: ...@@ -144,6 +144,9 @@ which you can start a new discussion:
![Starting a new discussion on design](img/adding_note_to_design_1.png) ![Starting a new discussion on design](img/adding_note_to_design_1.png)
From GitLab 12.8 on, when you are starting a new discussion, you can adjust the badge's position by
dragging it around the image.
Different discussions have different badge numbers: Different discussions have different badge numbers:
![Discussions on design annotations](img/adding_note_to_design_2.png) ![Discussions on design annotations](img/adding_note_to_design_2.png)
......
...@@ -28,12 +28,9 @@ export default { ...@@ -28,12 +28,9 @@ export default {
return this.label === null; return this.label === null;
}, },
pinStyle() { pinStyle() {
const style = this.position; return this.repositioning
if (this.repositioning) { ? Object.assign({}, this.position, { cursor: 'move' })
style.cursor = 'move'; : this.position;
}
return style;
}, },
pinLabel() { pinLabel() {
return this.isNewNote return this.isNewNote
......
...@@ -26,6 +26,12 @@ export default { ...@@ -26,6 +26,12 @@ export default {
default: null, default: null,
}, },
}, },
data() {
return {
movingNoteNewPosition: null,
movingNoteStartPosition: null,
};
},
computed: { computed: {
overlayStyle() { overlayStyle() {
return { return {
...@@ -34,38 +40,121 @@ export default { ...@@ -34,38 +40,121 @@ export default {
...this.position, ...this.position,
}; };
}, },
isMovingCurrentComment() {
return Boolean(this.movingNoteStartPosition);
},
currentCommentPositionStyle() {
return this.isMovingCurrentComment && this.movingNoteNewPosition
? this.getNotePositionStyle(this.movingNoteNewPosition)
: this.getNotePositionStyle(this.currentCommentForm);
},
}, },
methods: { methods: {
clickedImage(x, y) { setNewNoteCoordinates({ x, y }) {
this.$emit('openCommentForm', { x, y }); this.$emit('openCommentForm', { x, y });
}, },
getNotePosition(data) { getNoteRelativePosition(position) {
const { x, y, width, height } = data; const { x, y, width, height } = position;
const widthRatio = this.dimensions.width / width; const widthRatio = this.dimensions.width / width;
const heightRatio = this.dimensions.height / height; const heightRatio = this.dimensions.height / height;
return { return {
left: `${Math.round(x * widthRatio)}px`, left: Math.round(x * widthRatio),
top: `${Math.round(y * heightRatio)}px`, top: Math.round(y * heightRatio),
};
},
getNotePositionStyle(position) {
const { left, top } = this.getNoteRelativePosition(position);
return {
left: `${left}px`,
top: `${top}px`,
};
},
getMovingNotePositionDelta(e) {
let deltaX = 0;
let deltaY = 0;
if (this.movingNoteStartPosition) {
const { clientX, clientY } = this.movingNoteStartPosition;
deltaX = e.clientX - clientX;
deltaY = e.clientY - clientY;
}
return {
deltaX,
deltaY,
};
},
isPositionInOverlay(position) {
const { top, left } = this.getNoteRelativePosition(position);
const { height, width } = this.dimensions;
return top >= 0 && top <= height && left >= 0 && left <= width;
},
onOverlayMousemove(e) {
if (!this.isMovingCurrentComment) return;
const { deltaX, deltaY } = this.getMovingNotePositionDelta(e);
const x = this.currentCommentForm.x + deltaX;
const y = this.currentCommentForm.y + deltaY;
const movingNoteNewPosition = {
x,
y,
width: this.dimensions.width,
height: this.dimensions.height,
}; };
if (!this.isPositionInOverlay(movingNoteNewPosition)) {
this.onNewNoteMouseup();
return;
}
this.movingNoteNewPosition = movingNoteNewPosition;
},
onNewNoteMousedown({ clientX, clientY }) {
this.movingNoteStartPosition = {
clientX,
clientY,
};
},
onNewNoteMouseup() {
if (!this.movingNoteNewPosition) return;
const { x, y } = this.movingNoteNewPosition;
this.setNewNoteCoordinates({ x, y });
this.movingNoteStartPosition = null;
this.movingNoteNewPosition = null;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="position-absolute image-diff-overlay frame" :style="overlayStyle"> <div
class="position-absolute image-diff-overlay frame"
:style="overlayStyle"
@mousemove="onOverlayMousemove"
@mouseleave="onNewNoteMouseup"
>
<button <button
type="button" type="button"
class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
data-qa-selector="design_image_button" data-qa-selector="design_image_button"
@click="clickedImage($event.offsetX, $event.offsetY)" @click="setNewNoteCoordinates({ x: $event.offsetX, y: $event.offsetY })"
></button> ></button>
<design-note-pin <design-note-pin
v-for="(note, index) in notes" v-for="(note, index) in notes"
:key="note.id" :key="note.id"
:label="`${index + 1}`" :label="`${index + 1}`"
:position="getNotePosition(note.position)" :position="getNotePositionStyle(note.position)"
/>
<design-note-pin
v-if="currentCommentForm"
:position="currentCommentPositionStyle"
:repositioning="isMovingCurrentComment"
@mousedown="onNewNoteMousedown"
@mouseup="onNewNoteMouseup"
/> />
<design-note-pin v-if="currentCommentForm" :position="getNotePosition(currentCommentForm)" />
</div> </div>
</template> </template>
...@@ -38,7 +38,7 @@ export default { ...@@ -38,7 +38,7 @@ export default {
return { return {
overlayDimensions: null, overlayDimensions: null,
overlayPosition: null, overlayPosition: null,
currentAnnotationCoordinates: null, currentAnnotationPosition: null,
zoomFocalPoint: { zoomFocalPoint: {
x: 0, x: 0,
y: 0, y: 0,
...@@ -53,7 +53,7 @@ export default { ...@@ -53,7 +53,7 @@ export default {
return this.discussions.map(discussion => discussion.notes[0]); return this.discussions.map(discussion => discussion.notes[0]);
}, },
currentCommentForm() { currentCommentForm() {
return (this.isAnnotating && this.currentAnnotationCoordinates) || null; return (this.isAnnotating && this.currentAnnotationPosition) || null;
}, },
}, },
beforeDestroy() { beforeDestroy() {
...@@ -73,8 +73,22 @@ export default { ...@@ -73,8 +73,22 @@ export default {
presentationViewport.addEventListener('scroll', this.scrollThrottled, false); presentationViewport.addEventListener('scroll', this.scrollThrottled, false);
}, },
methods: { methods: {
syncCurrentAnnotationPosition() {
if (!this.currentAnnotationPosition) return;
const widthRatio = this.overlayDimensions.width / this.currentAnnotationPosition.width;
const heightRatio = this.overlayDimensions.height / this.currentAnnotationPosition.height;
const x = this.currentAnnotationPosition.x * widthRatio;
const y = this.currentAnnotationPosition.y * heightRatio;
this.currentAnnotationPosition = this.getAnnotationPositon({ x, y });
},
setOverlayDimensions(overlayDimensions) { setOverlayDimensions(overlayDimensions) {
this.overlayDimensions = overlayDimensions; this.overlayDimensions = overlayDimensions;
// every time we set overlay dimensions, we need to
// update the current annotation as well
this.syncCurrentAnnotationPosition();
}, },
setOverlayPosition() { setOverlayPosition() {
if (!this.overlayDimensions) { if (!this.overlayDimensions) {
...@@ -174,16 +188,19 @@ export default { ...@@ -174,16 +188,19 @@ export default {
} }
}); });
}, },
openCommentForm(position) { getAnnotationPositon(coordinates) {
const { x, y } = position; const { x, y } = coordinates;
const { width, height } = this.overlayDimensions; const { width, height } = this.overlayDimensions;
this.currentAnnotationCoordinates = { return {
x, x,
y, y,
width, width,
height, height,
}; };
this.$emit('openCommentForm', this.currentAnnotationCoordinates); },
openCommentForm(coordinates) {
this.currentAnnotationPosition = this.getAnnotationPositon(coordinates);
this.$emit('openCommentForm', this.currentAnnotationPosition);
}, },
}, },
}; };
......
...@@ -240,7 +240,8 @@ export default { ...@@ -240,7 +240,8 @@ export default {
:scale="scale" :scale="scale"
@openCommentForm="openCommentForm" @openCommentForm="openCommentForm"
/> />
<div class="design-scaler-wrapper position-absolute w-100 mb-4 d-flex-center">
<div class="design-scaler-wrapper position-absolute mb-4 d-flex-center">
<design-scaler @scale="scale = $event" /> <design-scaler @scale="scale = $event" />
</div> </div>
</div> </div>
......
...@@ -9,7 +9,8 @@ ...@@ -9,7 +9,8 @@
.design-scaler-wrapper { .design-scaler-wrapper {
bottom: 0; bottom: 0;
left: 0; left: 50%;
transform: translateX(-50%);
} }
.design-list-item .design-event { .design-list-item .design-event {
......
---
title: 'Design view: moveable `new comment` pin'
merge_request: 24769
author:
type: added
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design presentation component currentCommentForm currentCommentForm is equal to current annotation coordinates when isAnnotating is true 1`] = ` exports[`Design management design presentation component currentCommentForm currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = `
<div <div
class="h-100 w-100 p-3 overflow-auto position-relative" class="h-100 w-100 p-3 overflow-auto position-relative"
> >
...@@ -45,7 +45,7 @@ exports[`Design management design presentation component currentCommentForm curr ...@@ -45,7 +45,7 @@ exports[`Design management design presentation component currentCommentForm curr
</div> </div>
`; `;
exports[`Design management design presentation component currentCommentForm currentCommentForm is null when isAnnotating is true but annotation coordinates are falsey 1`] = ` exports[`Design management design presentation component currentCommentForm currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = `
<div <div
class="h-100 w-100 p-3 overflow-auto position-relative" class="h-100 w-100 p-3 overflow-auto position-relative"
> >
......
...@@ -23,18 +23,26 @@ describe('Design overlay component', () => { ...@@ -23,18 +23,26 @@ describe('Design overlay component', () => {
}, },
]; ];
const mockDimensions = { width: 100, height: 100 };
const findOverlay = () => wrapper.find('.image-diff-overlay');
const findAllNotes = () => wrapper.findAll('.js-image-badge'); const findAllNotes = () => wrapper.findAll('.js-image-badge');
const findCommentBadge = () => wrapper.find('.comment-indicator'); const findCommentBadge = () => wrapper.find('.comment-indicator');
const findFirstBadge = () => findAllNotes().at(0); const findFirstBadge = () => findAllNotes().at(0);
const findSecondBadge = () => findAllNotes().at(1); const findSecondBadge = () => findAllNotes().at(1);
const clickAndDragBadge = (elem, fromPoint, toPoint) => {
elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y });
return wrapper.vm.$nextTick().then(() => {
elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y });
return wrapper.vm.$nextTick();
});
};
function createComponent(props = {}) { function createComponent(props = {}) {
wrapper = mount(DesignOverlay, { wrapper = mount(DesignOverlay, {
propsData: { propsData: {
dimensions: { dimensions: mockDimensions,
width: 100,
height: 100,
},
position: { position: {
top: '0', top: '0',
left: '0', left: '0',
...@@ -52,16 +60,24 @@ describe('Design overlay component', () => { ...@@ -52,16 +60,24 @@ describe('Design overlay component', () => {
); );
}); });
it('should emit a correct event when clicking on overlay', () => { it('should emit `openCommentForm` when clicking on overlay', () => {
createComponent(); createComponent();
wrapper.find('.image-diff-overlay-add-comment').trigger('click', { offsetX: 10, offsetY: 10 }); const newCoordinates = {
x: 10,
y: 10,
};
wrapper
.find('.image-diff-overlay-add-comment')
.trigger('click', { offsetX: newCoordinates.x, offsetY: newCoordinates.y });
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('openCommentForm')).toEqual([[{ x: 10, y: 10 }]]); expect(wrapper.emitted('openCommentForm')).toEqual([
[{ x: newCoordinates.x, y: newCoordinates.y }],
]);
}); });
}); });
describe('when has notes', () => { describe('with notes', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
notes, notes,
...@@ -74,45 +90,169 @@ describe('Design overlay component', () => { ...@@ -74,45 +90,169 @@ describe('Design overlay component', () => {
it('should have a correct style for each note badge', () => { it('should have a correct style for each note badge', () => {
expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;'); expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;');
expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;'); expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;');
}); });
it('should recalculate badges positions on window resize', () => {
createComponent({
notes,
dimensions: {
width: 400,
height: 400,
},
});
expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;');
wrapper.setProps({
dimensions: {
width: 200,
height: 200,
},
});
return wrapper.vm.$nextTick().then(() => {
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;');
});
});
}); });
it('should render a new comment badge when there is a new form', () => { describe('with a new form', () => {
createComponent({ it('should render a new comment badge', () => {
currentCommentForm: { createComponent({
height: 100, currentCommentForm: {
width: 100, ...notes[0].position,
x: 25, },
y: 25, });
},
expect(findCommentBadge().exists()).toBe(true);
expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;');
}); });
expect(findCommentBadge().exists()).toBe(true); describe('when moving the comment badge', () => {
expect(findCommentBadge().attributes().style).toBe('left: 25px; top: 25px;'); it('should update badge style to reflect new position', () => {
const { position } = notes[0];
createComponent({
currentCommentForm: {
...position,
},
});
return clickAndDragBadge(
findCommentBadge(),
{ x: position.x, y: position.y },
{ x: 20, y: 20 },
).then(() => {
expect(findCommentBadge().attributes().style).toBe(
'left: 20px; top: 20px; cursor: move;',
);
});
});
it('should update badge style when note-moving action ends', () => {
const { position } = notes[0];
createComponent({
currentCommentForm: {
...position,
},
});
const commentBadge = findCommentBadge();
const toPoint = { x: 20, y: 20 };
return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint)
.then(() => {
commentBadge.trigger('mouseup');
// simulates the currentCommentForm being updated in index.vue component, and
// propagated back down to this prop
wrapper.setProps({
currentCommentForm: { height: position.height, width: position.width, ...toPoint },
});
return wrapper.vm.$nextTick();
})
.then(() => {
expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;');
});
});
it.each`
element | getElementFunc | event
${'overlay'} | ${findOverlay} | ${'mouseleave'}
${'comment badge'} | ${findCommentBadge} | ${'mouseup'}
`(
'should emit `openCommentForm` event when $event fired on $element element',
({ getElementFunc, event }) => {
createComponent({
notes,
currentCommentForm: {
...notes[0].position,
},
});
const newCoordinates = { x: 20, y: 20 };
wrapper.setData({
movingNoteStartPosition: {
...notes[0].position,
},
movingNoteNewPosition: {
...notes[0].position,
...newCoordinates,
},
});
getElementFunc().trigger(event);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]);
});
},
);
});
}); });
it('should recalculate badges positions on window resize', () => { describe('getMovingNotePositionDelta', () => {
createComponent({ it('should calculate delta correctly from state', () => {
notes, createComponent();
dimensions: {
width: 400, wrapper.setData({
height: 400, movingNoteStartPosition: {
}, clientX: 10,
clientY: 20,
},
});
const mockMouseEvent = {
clientX: 30,
clientY: 10,
};
expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({
deltaX: 20,
deltaY: -10,
});
}); });
});
expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;'); describe('isPositionInOverlay', () => {
createComponent({ dimensions: mockDimensions });
wrapper.setProps({ it.each`
dimensions: { test | coordinates | expectedResult
width: 200, ${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true}
height: 200, ${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false}
}, `('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => {
const position = { ...mockDimensions, ...coordinates };
expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult);
}); });
});
return wrapper.vm.$nextTick().then(() => { describe('getNoteRelativePosition', () => {
expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;'); it('calculates position correctly', () => {
createComponent({ dimensions: mockDimensions });
const position = { x: 50, y: 50, width: 200, height: 200 };
expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 });
}); });
}); });
}); });
...@@ -91,7 +91,7 @@ describe('Design management design presentation component', () => { ...@@ -91,7 +91,7 @@ describe('Design management design presentation component', () => {
}); });
}); });
it('currentCommentForm is null when isAnnotating is true but annotation coordinates are falsey', () => { it('currentCommentForm is null when isAnnotating is true but annotation position is falsey', () => {
createComponent( createComponent(
{ {
image: 'test.jpg', image: 'test.jpg',
...@@ -107,7 +107,7 @@ describe('Design management design presentation component', () => { ...@@ -107,7 +107,7 @@ describe('Design management design presentation component', () => {
}); });
}); });
it('currentCommentForm is equal to current annotation coordinates when isAnnotating is true', () => { it('currentCommentForm is equal to current annotation position when isAnnotating is true', () => {
createComponent( createComponent(
{ {
image: 'test.jpg', image: 'test.jpg',
...@@ -116,7 +116,7 @@ describe('Design management design presentation component', () => { ...@@ -116,7 +116,7 @@ describe('Design management design presentation component', () => {
}, },
{ {
...mockOverlayData, ...mockOverlayData,
currentAnnotationCoordinates: { currentAnnotationPosition: {
x: 1, x: 1,
y: 1, y: 1,
width: 100, width: 100,
......
...@@ -23,7 +23,7 @@ exports[`Design management design index page renders design index 1`] = ` ...@@ -23,7 +23,7 @@ exports[`Design management design index page renders design index 1`] = `
/> />
<div <div
class="design-scaler-wrapper position-absolute w-100 mb-4 d-flex-center" class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
> >
<design-scaler-stub /> <design-scaler-stub />
</div> </div>
...@@ -117,7 +117,7 @@ exports[`Design management design index page with error GlAlert is rendered in c ...@@ -117,7 +117,7 @@ exports[`Design management design index page with error GlAlert is rendered in c
/> />
<div <div
class="design-scaler-wrapper position-absolute w-100 mb-4 d-flex-center" class="design-scaler-wrapper position-absolute mb-4 d-flex-center"
> >
<design-scaler-stub /> <design-scaler-stub />
</div> </div>
......
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