Commit 3b46a0fb authored by Daniel Tian's avatar Daniel Tian

Use note header component in event item

Use note header component in event item rather than duplicate what it
already does
parent 5d0c545e
...@@ -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