Commit fcb67f6b authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '209993-use-note-header' into 'master'

Use note header component in event item component

See merge request gitlab-org/gitlab!29437
parents d9dd0e5b 3b46a0fb
...@@ -39,13 +39,18 @@ export default { ...@@ -39,13 +39,18 @@ export default {
required: false, required: false,
default: true, default: true,
}, },
showSpinner: {
type: Boolean,
required: false,
default: true,
},
}, },
computed: { computed: {
toggleChevronClass() { toggleChevronClass() {
return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down'; return this.expanded ? 'fa-chevron-up' : 'fa-chevron-down';
}, },
noteTimestampLink() { noteTimestampLink() {
return `#note_${this.noteId}`; return this.noteId ? `#note_${this.noteId}` : undefined;
}, },
hasAuthor() { hasAuthor() {
return this.author && Object.keys(this.author).length; return this.author && Object.keys(this.author).length;
...@@ -60,7 +65,9 @@ export default { ...@@ -60,7 +65,9 @@ export default {
this.$emit('toggleHandler'); this.$emit('toggleHandler');
}, },
updateTargetNoteHash() { updateTargetNoteHash() {
this.setTargetNoteHash(this.noteTimestampLink); if (this.$store) {
this.setTargetNoteHash(this.noteTimestampLink);
}
}, },
}, },
}; };
...@@ -101,16 +108,20 @@ export default { ...@@ -101,16 +108,20 @@ export default {
<template v-if="actionText">{{ actionText }}</template> <template v-if="actionText">{{ actionText }}</template>
</span> </span>
<a <a
ref="noteTimestamp" v-if="noteTimestampLink"
ref="noteTimestampLink"
:href="noteTimestampLink" :href="noteTimestampLink"
class="note-timestamp system-note-separator" class="note-timestamp system-note-separator"
@click="updateTargetNoteHash" @click="updateTargetNoteHash"
> >
<time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
</a> </a>
<time-ago-tooltip v-else ref="noteTimestamp" :time="createdAt" tooltip-placement="bottom" />
</template> </template>
<slot name="extra-controls"></slot> <slot name="extra-controls"></slot>
<i <i
v-if="showSpinner"
ref="spinner"
class="fa fa-spinner fa-spin editing-spinner" class="fa fa-spinner fa-spin editing-spinner"
:aria-label="__('Comment is being updated')" :aria-label="__('Comment is being updated')"
aria-hidden="true" aria-hidden="true"
......
<script> <script>
import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import NoteHeader from '~/notes/components/note_header.vue';
export default { export default {
name: 'EventItem', name: 'EventItem',
components: { components: {
Icon, Icon,
TimeAgoTooltip, NoteHeader,
GlDeprecatedButton, GlDeprecatedButton,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
props: { props: {
id: {
type: [String, Number],
required: false,
default: undefined,
},
author: { author: {
type: Object, type: Object,
required: true, required: true,
...@@ -49,39 +54,36 @@ export default { ...@@ -49,39 +54,36 @@ export default {
default: true, default: true,
}, },
}, },
computed: {
noteId() {
return this.id ? `note_${this.id}` : undefined;
},
},
}; };
</script> </script>
<template> <template>
<div class="d-flex align-items-center"> <div :id="noteId" class="d-flex align-items-center">
<div class="circle-icon-container" :class="iconClass"> <div class="circle-icon-container" :class="iconClass">
<icon :size="16" :name="iconName" /> <icon :size="16" :name="iconName" />
</div> </div>
<div class="ml-3 flex-grow-1" data-qa-selector="event_item_content"> <div class="ml-3 flex-grow-1" data-qa-selector="event_item_content">
<div class="note-header-info pb-0"> <note-header
<a :note-id="id"
:href="author.path" :author="author"
:data-user-id="author.id" :created-at="createdAt"
:data-username="author.username" :show-spinner="false"
class="js-author js-user-link" class="pb-0"
> >
<strong class="note-header-author-name">{{ author.name }}</strong> <slot name="header-message">&middot;</slot>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span> </note-header>
<span class="note-headline-light">@{{ author.username }}</span>
</a>
<span class="note-headline-light note-headline-meta">
<template v-if="createdAt">
<span class="system-note-separator">·</span>
<time-ago-tooltip :time="createdAt" tooltip-placement="bottom" />
</template>
</span>
</div>
<slot></slot> <slot></slot>
</div> </div>
<slot v-if="showRightSlot" name="right-content"></slot> <slot v-if="showRightSlot" name="right-content"></slot>
<div v-else-if="showActionButtons" class="align-self-start"> <div v-else-if="showActionButtons">
<gl-deprecated-button <gl-deprecated-button
v-for="button in actionButtons" v-for="button in actionButtons"
:key="button.title" :key="button.title"
......
...@@ -40,8 +40,10 @@ export default { ...@@ -40,8 +40,10 @@ export default {
}, },
created() { created() {
// window.location.pathname is the URL without the protocol or hash/querystring
// i.e. http://server/url?query=string#note_123 -> /server/url
axios axios
.get(joinPaths(window.location.href, 'discussions')) .get(joinPaths(window.location.pathname, 'discussions'))
.then(({ data }) => { .then(({ data }) => {
this.discussions = data; this.discussions = data;
}) })
......
<script> <script>
import Icon from '~/vue_shared/components/icon.vue'; import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default { export default {
components: { Icon, TimeAgoTooltip }, components: { EventItem },
props: { props: {
discussion: { discussion: {
type: Object, type: Object,
...@@ -20,32 +19,15 @@ export default { ...@@ -20,32 +19,15 @@ export default {
<template> <template>
<li v-if="systemNote" class="card border-bottom system-note p-0"> <li v-if="systemNote" class="card border-bottom system-note p-0">
<div class="note-header-info mx-3 my-4"> <event-item
<div class="timeline-icon mr-0"> :id="systemNote.id"
<icon ref="icon" :name="systemNote.system_note_icon_name" /> :author="systemNote.author"
</div> :created-at="systemNote.created_at"
:icon-name="systemNote.system_note_icon_name"
<a icon-class="timeline-icon m-0"
:href="systemNote.author.path" class="m-3"
class="js-user-link ml-3" >
:data-user-id="systemNote.author.id" <template #header-message>{{ systemNote.note }}</template>
> </event-item>
<strong ref="authorName" class="note-header-author-name">
{{ systemNote.author.name }}
</strong>
<span
v-if="systemNote.author.status_tooltip_html"
ref="authorStatus"
v-html="systemNote.author.status_tooltip_html"
></span>
<span ref="authorUsername" class="note-headline-light">
@{{ systemNote.author.username }}
</span>
</a>
<span ref="stateChangeMessage" class="note-headline-light">
{{ systemNote.note }}
<time-ago-tooltip :time="systemNote.created_at" />
</span>
</div>
</li> </li>
</template> </template>
import { GlDeprecatedButton } from '@gitlab/ui'; import { GlDeprecatedButton } from '@gitlab/ui';
import Component from 'ee/vue_shared/security_reports/components/event_item.vue'; import Component from 'ee/vue_shared/security_reports/components/event_item.vue';
import { shallowMount, mount } from '@vue/test-utils'; import { shallowMount, mount } from '@vue/test-utils';
import NoteHeader from '~/notes/components/note_header.vue';
describe('Event Item', () => { describe('Event Item', () => {
let wrapper; let wrapper;
...@@ -9,8 +10,13 @@ describe('Event Item', () => { ...@@ -9,8 +10,13 @@ describe('Event Item', () => {
wrapper = mountFn(Component, options); wrapper = mountFn(Component, options);
}; };
const noteHeader = () => wrapper.find(NoteHeader);
describe('initial state', () => { describe('initial state', () => {
const propsData = { const propsData = {
id: 123,
createdAt: 'createdAt',
headerMessage: 'header message',
author: { author: {
name: 'Tanuki', name: 'Tanuki',
username: 'gitlab', username: 'gitlab',
...@@ -25,12 +31,13 @@ describe('Event Item', () => { ...@@ -25,12 +31,13 @@ describe('Event Item', () => {
mountComponent({ propsData }); mountComponent({ propsData });
}); });
it('uses the author name', () => { it('passes the expected values to the note header component', () => {
expect(wrapper.find('.js-author').text()).toContain(propsData.author.name); expect(noteHeader().props()).toMatchObject({
}); noteId: propsData.id,
author: propsData.author,
it('uses the author username', () => { createdAt: propsData.createdAt,
expect(wrapper.find('.js-author').text()).toContain(`@${propsData.author.username}`); showSpinner: false,
});
}); });
it('uses the fallback icon', () => { it('uses the fallback icon', () => {
......
...@@ -96,7 +96,7 @@ describe('Vulnerability Footer', () => { ...@@ -96,7 +96,7 @@ describe('Vulnerability Footer', () => {
}); });
describe('state history', () => { describe('state history', () => {
const discussionUrl = 'http://localhost/discussions'; const discussionUrl = '/discussions';
const historyList = () => wrapper.find({ ref: 'historyList' }); const historyList = () => wrapper.find({ ref: 'historyList' });
const historyEntries = () => wrapper.findAll(HistoryEntry); const historyEntries = () => wrapper.findAll(HistoryEntry);
...@@ -107,7 +107,7 @@ describe('Vulnerability Footer', () => { ...@@ -107,7 +107,7 @@ describe('Vulnerability Footer', () => {
expect(historyList().exists()).toBe(false); expect(historyList().exists()).toBe(false);
}); });
it('does render the history list if there are history items', () => { it('renders the history list if there are history items', () => {
// The shape of this object doesn't matter for this test, we just need to verify that it's passed to the history // The shape of this object doesn't matter for this test, we just need to verify that it's passed to the history
// entry. // entry.
const historyItems = [{ id: 1, note: 'some note' }, { id: 2, note: 'another note' }]; const historyItems = [{ id: 1, note: 'some note' }, { id: 2, note: 'another note' }];
......
import { shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue'; import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
describe('History Entry', () => { describe('History Entry', () => {
let wrapper; let wrapper;
const note = { const note = {
system: true, system: true,
id: 123,
note: 'changed vulnerability status to dismissed', note: 'changed vulnerability status to dismissed',
system_note_icon_name: 'cancel', system_note_icon_name: 'cancel',
created_at: 'created_at_timestamp', created_at: new Date().toISOString(),
author: { author: {
name: 'author name', name: 'author name',
username: 'author username', username: 'author username',
...@@ -19,7 +19,7 @@ describe('History Entry', () => { ...@@ -19,7 +19,7 @@ describe('History Entry', () => {
}; };
const createWrapper = options => { const createWrapper = options => {
wrapper = shallowMount(HistoryEntry, { wrapper = mount(HistoryEntry, {
propsData: { propsData: {
discussion: { discussion: {
notes: [{ ...note, ...options }], notes: [{ ...note, ...options }],
...@@ -28,52 +28,24 @@ describe('History Entry', () => { ...@@ -28,52 +28,24 @@ describe('History Entry', () => {
}); });
}; };
const icon = () => wrapper.find(Icon); const eventItem = () => wrapper.find(EventItem);
const authorName = () => wrapper.find({ ref: 'authorName' });
const authorUsername = () => wrapper.find({ ref: 'authorUsername' });
const authorStatus = () => wrapper.find({ ref: 'authorStatus' });
const stateChangeMessage = () => wrapper.find({ ref: 'stateChangeMessage' });
const timeAgoTooltip = () => wrapper.find(TimeAgoTooltip);
afterEach(() => wrapper.destroy()); afterEach(() => wrapper.destroy());
describe('default wrapper tests', () => { it('passes the expected values to the event item component', () => {
beforeEach(() => createWrapper()); createWrapper();
it('shows the correct icon', () => { expect(eventItem().text()).toContain(note.note);
expect(icon().exists()).toBe(true); expect(eventItem().props()).toMatchObject({
expect(icon().attributes('name')).toBe(note.system_note_icon_name); id: note.id,
}); author: note.author,
createdAt: note.created_at,
it('shows the correct user', () => { iconName: note.system_note_icon_name,
expect(authorName().text()).toBe(note.author.name);
expect(authorUsername().text()).toBe(`@${note.author.username}`);
});
it('shows the correct status if the user has a status set', () => {
expect(authorStatus().exists()).toBe(true);
expect(authorStatus().element.innerHTML).toBe(note.author.status_tooltip_html);
});
it('shows the state change message', () => {
expect(stateChangeMessage().text()).toBe(note.note);
});
it('shows the time ago tooltip', () => {
expect(timeAgoTooltip().exists()).toBe(true);
expect(timeAgoTooltip().attributes('time')).toBe(note.created_at);
}); });
}); });
describe('custom wrapper tests', () => { it('does not render anything if there is no system note', () => {
it('does not show the user status if user has no status set', () => { createWrapper({ system: false });
createWrapper({ author: { status_tooltip_html: undefined } }); expect(wrapper.html()).toBeFalsy();
expect(authorStatus().exists()).toBe(false);
});
it('does not render anything if there is no system note', () => {
createWrapper({ system: false });
expect(wrapper.html()).toBeFalsy();
});
}); });
}); });
...@@ -16,7 +16,9 @@ describe('NoteHeader component', () => { ...@@ -16,7 +16,9 @@ describe('NoteHeader component', () => {
const findActionsWrapper = () => wrapper.find({ ref: 'discussionActions' }); const findActionsWrapper = () => wrapper.find({ ref: 'discussionActions' });
const findChevronIcon = () => wrapper.find({ ref: 'chevronIcon' }); const findChevronIcon = () => wrapper.find({ ref: 'chevronIcon' });
const findActionText = () => wrapper.find({ ref: 'actionText' }); const findActionText = () => wrapper.find({ ref: 'actionText' });
const findTimestampLink = () => wrapper.find({ ref: 'noteTimestampLink' });
const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' }); const findTimestamp = () => wrapper.find({ ref: 'noteTimestamp' });
const findSpinner = () => wrapper.find({ ref: 'spinner' });
const author = { const author = {
avatar_url: null, avatar_url: null,
...@@ -33,11 +35,7 @@ describe('NoteHeader component', () => { ...@@ -33,11 +35,7 @@ describe('NoteHeader component', () => {
store: new Vuex.Store({ store: new Vuex.Store({
actions, actions,
}), }),
propsData: { propsData: { ...props },
...props,
actionTextHtml: '',
noteId: '1394',
},
}); });
}; };
...@@ -108,17 +106,18 @@ describe('NoteHeader component', () => { ...@@ -108,17 +106,18 @@ describe('NoteHeader component', () => {
createComponent(); createComponent();
expect(findActionText().exists()).toBe(false); expect(findActionText().exists()).toBe(false);
expect(findTimestamp().exists()).toBe(false); expect(findTimestampLink().exists()).toBe(false);
}); });
describe('when createdAt is passed as a prop', () => { describe('when createdAt is passed as a prop', () => {
it('renders action text and a timestamp', () => { it('renders action text and a timestamp', () => {
createComponent({ createComponent({
createdAt: '2017-08-02T10:51:58.559Z', createdAt: '2017-08-02T10:51:58.559Z',
noteId: 123,
}); });
expect(findActionText().exists()).toBe(true); expect(findActionText().exists()).toBe(true);
expect(findTimestamp().exists()).toBe(true); expect(findTimestampLink().exists()).toBe(true);
}); });
it('renders correct actionText if passed', () => { it('renders correct actionText if passed', () => {
...@@ -133,8 +132,9 @@ describe('NoteHeader component', () => { ...@@ -133,8 +132,9 @@ describe('NoteHeader component', () => {
it('calls an action when timestamp is clicked', () => { it('calls an action when timestamp is clicked', () => {
createComponent({ createComponent({
createdAt: '2017-08-02T10:51:58.559Z', createdAt: '2017-08-02T10:51:58.559Z',
noteId: 123,
}); });
findTimestamp().trigger('click'); findTimestampLink().trigger('click');
expect(actions.setTargetNoteHash).toHaveBeenCalled(); expect(actions.setTargetNoteHash).toHaveBeenCalled();
}); });
...@@ -153,4 +153,30 @@ describe('NoteHeader component', () => { ...@@ -153,4 +153,30 @@ describe('NoteHeader component', () => {
expect(wrapper.find(GitlabTeamMemberBadge).exists()).toBe(expected); expect(wrapper.find(GitlabTeamMemberBadge).exists()).toBe(expected);
}, },
); );
describe('loading spinner', () => {
it('shows spinner when showSpinner is true', () => {
createComponent();
expect(findSpinner().exists()).toBe(true);
});
it('does not show spinner when showSpinner is false', () => {
createComponent({ showSpinner: false });
expect(findSpinner().exists()).toBe(false);
});
});
describe('timestamp', () => {
it('shows timestamp as a link if a noteId was provided', () => {
createComponent({ createdAt: new Date().toISOString(), noteId: 123 });
expect(findTimestampLink().exists()).toBe(true);
expect(findTimestamp().exists()).toBe(false);
});
it('shows timestamp as plain text if a noteId was not provided', () => {
createComponent({ createdAt: new Date().toISOString() });
expect(findTimestampLink().exists()).toBe(false);
expect(findTimestamp().exists()).toBe(true);
});
});
}); });
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