Merge pull request #1975 from Human-Connection/1974-notifications-page

Add notifications page with All Notifications
This commit is contained in:
mattwr18 2019-11-11 14:29:40 +01:00 committed by GitHub
commit 36722962e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1691 additions and 255 deletions

View File

@ -18,9 +18,8 @@ export default {
notifications: async (_parent, args, context, _resolveInfo) => {
const { user: currentUser } = context
const session = context.driver.session()
let notifications
let whereClause
let orderByClause
let notifications, whereClause, orderByClause
switch (args.read) {
case true:
whereClause = 'WHERE notification.read = TRUE'
@ -41,13 +40,15 @@ export default {
default:
orderByClause = ''
}
const offset = args.offset && typeof args.offset === 'number' ? `SKIP ${args.offset}` : ''
const limit = args.first && typeof args.first === 'number' ? `LIMIT ${args.first}` : ''
try {
const cypher = `
MATCH (resource {deleted: false, disabled: false})-[notification:NOTIFIED]->(user:User {id:$id})
${whereClause}
RETURN resource, notification, user
${orderByClause}
${offset} ${limit}
`
const result = await session.run(cypher, { id: currentUser.id })
notifications = await result.records.map(transformReturnType)
@ -77,4 +78,10 @@ export default {
return notification
},
},
NOTIFIED: {
id: async parent => {
// serialize an ID to help the client update the cache
return `${parent.reason}/${parent.from.id}/${parent.to.id}`
},
},
}

View File

@ -1,8 +1,9 @@
type NOTIFIED {
id: ID!
from: NotificationSource
to: User
createdAt: String
updatedAt: String
createdAt: String!
updatedAt: String!
read: Boolean
reason: NotificationReason
}
@ -23,7 +24,7 @@ enum NotificationReason {
}
type Query {
notifications(read: Boolean, orderBy: NotificationOrdering): [NOTIFIED]
notifications(read: Boolean, orderBy: NotificationOrdering, first: Int, offset: Int): [NOTIFIED]
}
type Mutation {

View File

@ -0,0 +1,168 @@
import { config, mount, createLocalVue } from '@vue/test-utils'
import Vuex from 'vuex'
import VTooltip from 'v-tooltip'
import Styleguide from '@human-connection/styleguide'
import AvatarMenu from './AvatarMenu.vue'
import Filters from '~/plugins/vue-filters'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Vuex)
localVue.use(Filters)
localVue.use(VTooltip)
config.stubs['nuxt-link'] = '<span><slot /></span>'
config.stubs['router-link'] = '<span><slot /></span>'
describe('AvatarMenu.vue', () => {
let propsData, getters, wrapper, mocks
beforeEach(() => {
propsData = {}
mocks = {
$route: {
path: '',
},
$router: {
resolve: jest.fn(() => {
return { href: '/profile/u343/matt' }
}),
},
$t: jest.fn(a => a),
}
getters = {
'auth/user': () => {
return { id: 'u343', name: 'Matt' }
},
}
})
const Wrapper = () => {
const store = new Vuex.Store({
getters,
})
return mount(AvatarMenu, { propsData, localVue, store, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders the HcAvatar component', () => {
wrapper.find('.avatar-menu-trigger').trigger('click')
expect(wrapper.find('.ds-avatar').exists()).toBe(true)
})
describe('given a userName', () => {
it('displays the userName', () => {
expect(wrapper.find('b').text()).toEqual('Matt')
})
})
describe('no userName', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343' }
},
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
it('displays anonymous user', () => {
expect(wrapper.find('b').text()).toEqual('profile.userAnonym')
})
})
describe('menu items', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343', slug: 'matt' }
},
'auth/isModerator': () => false,
'auth/isAdmin': () => false,
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
describe('role user', () => {
it('displays a link to user profile', () => {
const profileLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/profile/u343/matt'))
expect(profileLink.exists()).toBe(true)
})
it('displays a link to the notifications page', () => {
const notificationsLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/notifications'))
expect(notificationsLink.exists()).toBe(true)
})
it('displays a link to the settings page', () => {
const settingsLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/settings'))
expect(settingsLink.exists()).toBe(true)
})
})
describe('role moderator', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343', slug: 'matt' }
},
'auth/isModerator': () => true,
'auth/isAdmin': () => false,
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
it('displays a link to moderation page', () => {
const moderationLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/moderation'))
expect(moderationLink.exists()).toBe(true)
})
it('displays a total of 4 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(4)
})
})
describe('role admin', () => {
beforeEach(() => {
getters = {
'auth/user': () => {
return { id: 'u343', slug: 'matt' }
},
'auth/isModerator': () => true,
'auth/isAdmin': () => true,
}
wrapper = Wrapper()
wrapper.find('.avatar-menu-trigger').trigger('click')
})
it('displays a link to admin page', () => {
const adminLink = wrapper
.findAll('.ds-menu-item span')
.at(wrapper.vm.routes.findIndex(route => route.path === '/admin'))
expect(adminLink.exists()).toBe(true)
})
it('displays a total of 5 links', () => {
const allLinks = wrapper.findAll('.ds-menu-item')
expect(allLinks).toHaveLength(5)
})
})
})
})
})

View File

@ -0,0 +1,17 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import StoryRouter from 'storybook-vue-router'
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
import helpers from '~/storybook/helpers'
helpers.init()
storiesOf('AvatarMenu', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.addDecorator(StoryRouter())
.add('dropdown', () => ({
components: { AvatarMenu },
store: helpers.store,
template: '<avatar-menu placement="top" />',
}))

View File

@ -0,0 +1,146 @@
<template>
<dropdown class="avatar-menu" offset="8" :placement="placement">
<template #default="{ toggleMenu }">
<a
class="avatar-menu-trigger"
:href="
$router.resolve({
name: 'profile-id-slug',
params: { id: user.id, slug: user.slug },
}).href
"
@click.prevent="toggleMenu"
>
<hc-avatar :user="user" />
<ds-icon size="xx-small" name="angle-down" />
</a>
</template>
<template #popover="{ closeMenu }">
<div class="avatar-menu-popover">
{{ $t('login.hello') }}
<b>{{ userName }}</b>
<template v-if="user.role !== 'user'">
<ds-text color="softer" size="small" style="margin-bottom: 0">
{{ user.role | camelCase }}
</ds-text>
</template>
<hr />
<ds-menu :routes="routes" :matcher="matcher">
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.native="closeMenu(false)"
>
<ds-icon :name="item.route.icon" />
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
<hr />
<nuxt-link class="logout-link" :to="{ name: 'logout' }">
<ds-icon name="sign-out" />
{{ $t('login.logout') }}
</nuxt-link>
</div>
</template>
</dropdown>
</template>
<script>
import { mapGetters } from 'vuex'
import Dropdown from '~/components/Dropdown'
import HcAvatar from '~/components/Avatar/Avatar.vue'
export default {
components: {
Dropdown,
HcAvatar,
},
props: {
placement: { type: String, default: 'top-end' },
},
computed: {
...mapGetters({
user: 'auth/user',
isModerator: 'auth/isModerator',
isAdmin: 'auth/isAdmin',
}),
routes() {
if (!this.user.slug) {
return []
}
let routes = [
{
name: this.$t('profile.name'),
path: `/profile/${this.user.id}/${this.user.slug}`,
icon: 'user',
},
{
name: this.$t('notifications.pageLink'),
path: '/notifications',
icon: 'bell',
},
{
name: this.$t('settings.name'),
path: `/settings`,
icon: 'cogs',
},
]
if (this.isModerator) {
routes.push({
name: this.$t('moderation.name'),
path: `/moderation`,
icon: 'balance-scale',
})
}
if (this.isAdmin) {
routes.push({
name: this.$t('admin.name'),
path: `/admin`,
icon: 'shield',
})
}
return routes
},
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
},
methods: {
matcher(url, route) {
if (url.indexOf('/profile') === 0) {
// do only match own profile
return this.$route.path === url
}
return this.$route.path.indexOf(url) === 0
},
},
}
</script>
<style lang="scss">
.avatar-menu {
margin: $space-xxx-small 0px 0px $space-xx-small;
}
.avatar-menu-trigger {
user-select: none;
display: flex;
align-items: center;
padding-left: $space-xx-small;
}
.avatar-menu-popover {
padding-top: $space-x-small;
padding-bottom: $space-x-small;
hr {
color: $color-neutral-90;
background-color: $color-neutral-90;
}
.logout-link {
color: $text-color-base;
padding-top: $space-xx-small;
&:hover {
color: $text-color-link-active;
}
}
}
</style>

View File

@ -0,0 +1,78 @@
import { mount, createLocalVue } from '@vue/test-utils'
import VTooltip from 'v-tooltip'
import Styleguide from '@human-connection/styleguide'
import DropdownFilter from './DropdownFilter.vue'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(VTooltip)
describe('DropdownFilter.vue', () => {
let propsData, wrapper, mocks
beforeEach(() => {
propsData = {}
mocks = {
$t: jest.fn(a => a),
}
})
const Wrapper = () => {
return mount(DropdownFilter, { propsData, localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('selected', () => {
it('displays selected filter', () => {
propsData.selected = 'Read'
wrapper = Wrapper()
expect(wrapper.find('.dropdown-filter label').text()).toEqual(propsData.selected)
})
})
describe('menu items', () => {
let allLink
beforeEach(() => {
propsData.filterOptions = [
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
wrapper = Wrapper()
wrapper.find('.dropdown-filter').trigger('click')
allLink = wrapper
.findAll('.dropdown-menu-item')
.at(propsData.filterOptions.findIndex(option => option.label === 'All'))
})
it('displays a link for All', () => {
expect(allLink.text()).toEqual('All')
})
it('displays a link for Read', () => {
const readLink = wrapper
.findAll('.dropdown-menu-item')
.at(propsData.filterOptions.findIndex(option => option.label === 'Read'))
expect(readLink.text()).toEqual('Read')
})
it('displays a link for Unread', () => {
const unreadLink = wrapper
.findAll('.dropdown-menu-item')
.at(propsData.filterOptions.findIndex(option => option.label === 'Unread'))
expect(unreadLink.text()).toEqual('Unread')
})
it('clicking on menu item emits filterNotifications', () => {
allLink.trigger('click')
expect(wrapper.emitted().filterNotifications[0]).toEqual(
propsData.filterOptions.filter(option => option.label === 'All'),
)
})
})
})
})

View File

@ -0,0 +1,30 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import helpers from '~/storybook/helpers'
helpers.init()
const filterOptions = [
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
storiesOf('DropdownFilter', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('filter dropdown', () => ({
components: { DropdownFilter },
data: () => ({
filterOptions,
selected: filterOptions[0].label,
}),
methods: {
filterNotifications: action('filterNotifications'),
},
template: `<dropdown-filter
@filterNotifications="filterNotifications"
:filterOptions="filterOptions"
:selected="selected"
/>`,
}))

View File

@ -0,0 +1,78 @@
<template>
<dropdown offset="8">
<a
:v-model="selected"
slot="default"
slot-scope="{ toggleMenu }"
name="dropdown"
class="dropdown-filter"
href="#"
@click.prevent="toggleMenu()"
>
<ds-icon style="margin-right: 2px;" name="filter" />
<label for="dropdown">{{ selected }}</label>
<ds-icon style="margin-left: 2px" size="xx-small" name="angle-down" />
</a>
<ds-menu
slot="popover"
slot-scope="{ toggleMenu }"
class="dropdown-menu-popover"
:routes="filterOptions"
>
<ds-menu-item
slot="menuitem"
slot-scope="item"
class="dropdown-menu-item"
:route="item.route"
:parents="item.parents"
@click.stop.prevent="filterNotifications(item.route, toggleMenu)"
>
{{ item.route.label }}
</ds-menu-item>
</ds-menu>
</dropdown>
</template>
<script>
import Dropdown from '~/components/Dropdown'
export default {
components: {
Dropdown,
},
props: {
selected: { type: String, default: '' },
filterOptions: { type: Array, default: () => [] },
},
methods: {
filterNotifications(option, toggleMenu) {
this.$emit('filterNotifications', option)
toggleMenu()
},
},
}
</script>
<style lang="scss">
.dropdown-filter {
user-select: none;
display: flex;
align-items: center;
height: 100%;
padding: $space-xx-small;
color: $text-color-soft;
}
.dropdown-menu {
user-select: none;
display: flex;
align-items: center;
height: 100%;
padding: $space-xx-small;
color: $text-color-soft;
}
.dropdown-menu-popover {
a {
padding: $space-x-small $space-small;
padding-right: $space-base;
}
}
</style>

View File

@ -0,0 +1,54 @@
import { shallowMount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import Empty from './Empty.vue'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('Empty.vue', () => {
let propsData, wrapper
beforeEach(() => {
propsData = {}
})
const Wrapper = () => {
return shallowMount(Empty, { propsData, localVue })
}
describe('shallowMount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('renders an image with an alert icon as default', () => {
expect(wrapper.find('img[alt="Empty"]').attributes().src).toBe('/img/empty/alert.svg')
})
describe('receives icon prop', () => {
it('renders an image with that icon', () => {
propsData.icon = 'messages'
wrapper = Wrapper()
expect(wrapper.find('img[alt="Empty"]').attributes().src).toBe(
`/img/empty/${propsData.icon}.svg`,
)
})
})
describe('receives message prop', () => {
it('renders that message', () => {
propsData.message = 'this is a custom message for Empty component'
wrapper = Wrapper()
expect(wrapper.find('.hc-empty-message').text()).toEqual(propsData.message)
})
})
describe('receives margin prop', () => {
it('sets margin to that margin', () => {
propsData.margin = 'xxx-small'
wrapper = Wrapper()
expect(wrapper.find('.hc-empty').attributes().margin).toEqual(propsData.margin)
})
})
})
})

View File

@ -0,0 +1,24 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import HcEmpty from '~/components/Empty/Empty'
import helpers from '~/storybook/helpers'
helpers.init()
storiesOf('Empty', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add(
'tasks icon with message',
() => ({
components: { HcEmpty },
template: '<hc-empty icon="tasks" message="Sorry, there are no ... available." />',
}),
{
notes: "Possible icons include 'messages', 'events', 'alert', 'tasks', 'docs', and 'file'",
},
)
.add('default icon, no message', () => ({
components: { HcEmpty },
template: '<hc-empty />',
}))

View File

@ -26,7 +26,7 @@ export default {
*/
icon: {
type: String,
required: true,
default: 'alert',
validator: value => {
return value.match(/(messages|events|alert|tasks|docs|file)/)
},

View File

@ -69,10 +69,9 @@ export default {
}
</script>
<style>
<style lang="scss">
.notification.read {
opacity: 0.6; /* Real browsers */
filter: alpha(opacity = 60); /* MSIE */
opacity: $opacity-soft;
}
.notifications-card {
min-width: 500px;

View File

@ -3,8 +3,8 @@ import NotificationList from './NotificationList'
import Notification from '../Notification/Notification'
import Vuex from 'vuex'
import Filters from '~/plugins/vue-filters'
import Styleguide from '@human-connection/styleguide'
import { notifications } from '~/components/utils/Notifications'
const localVue = createLocalVue()
@ -38,40 +38,7 @@ describe('NotificationList.vue', () => {
stubs = {
NuxtLink: RouterLinkStub,
}
propsData = {
notifications: [
{
read: false,
from: {
__typename: 'Post',
id: 'post-1',
title: 'some post title',
slug: 'some-post-title',
contentExcerpt: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe',
},
},
},
{
read: false,
from: {
__typename: 'Post',
id: 'post-2',
title: 'another post title',
slug: 'another-post-title',
contentExcerpt: 'this is yet another post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe',
},
},
},
],
}
propsData = { notifications }
})
describe('shallowMount', () => {
@ -110,15 +77,11 @@ describe('NotificationList.vue', () => {
describe('click on a notification', () => {
beforeEach(() => {
wrapper
.findAll('.notification-mention-post')
.at(1)
.trigger('click')
wrapper.find('.notification-mention-post').trigger('click')
})
it("emits 'markAsRead' with the id of the notification source", () => {
expect(wrapper.emitted('markAsRead')).toBeTruthy()
expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-2'])
expect(wrapper.emitted('markAsRead')[0]).toEqual(['post-1'])
})
})
})

View File

@ -20,7 +20,7 @@ export default {
props: {
notifications: {
type: Array,
required: true,
default: () => [],
},
},
methods: {

View File

@ -50,7 +50,7 @@ describe('NotificationMenu.vue', () => {
beforeEach(() => {
data = () => {
return {
displayedNotifications: [
notifications: [
{
id: 'notification-41',
read: true,
@ -85,7 +85,7 @@ describe('NotificationMenu.vue', () => {
beforeEach(() => {
data = () => {
return {
displayedNotifications: [
notifications: [
{
id: 'notification-41',
read: false,

View File

@ -1,8 +1,8 @@
<template>
<ds-button v-if="!notificationsCount" class="notifications-menu" disabled icon="bell">
<ds-button v-if="!notifications.length" class="notifications-menu" disabled icon="bell">
{{ unreadNotificationsCount }}
</ds-button>
<dropdown v-else class="notifications-menu" :placement="placement">
<dropdown v-else class="notifications-menu" offset="8" :placement="placement">
<template slot="default" slot-scope="{ toggleMenu }">
<ds-button :primary="!!unreadNotificationsCount" icon="bell" @click.prevent="toggleMenu">
{{ unreadNotificationsCount }}
@ -10,7 +10,12 @@
</template>
<template slot="popover">
<div class="notifications-menu-popover">
<notification-list :notifications="displayedNotifications" @markAsRead="markAsRead" />
<notification-list :notifications="notifications" @markAsRead="markAsRead" />
</div>
<div class="notifications-link-container">
<nuxt-link :to="{ name: 'notifications' }">
{{ $t('notifications.pageLink') }}
</nuxt-link>
</div>
</template>
</dropdown>
@ -21,6 +26,7 @@ import Dropdown from '~/components/Dropdown'
import { NOTIFICATIONS_POLL_INTERVAL } from '~/constants/notifications'
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
import NotificationList from '../NotificationList/NotificationList'
import unionBy from 'lodash/unionBy'
export default {
name: 'NotificationMenu',
@ -30,7 +36,6 @@ export default {
},
data() {
return {
displayedNotifications: [],
notifications: [],
}
},
@ -41,36 +46,21 @@ export default {
async markAsRead(notificationSourceId) {
const variables = { id: notificationSourceId }
try {
const {
data: { markAsRead },
} = await this.$apollo.mutate({
await this.$apollo.mutate({
mutation: markAsReadMutation(this.$i18n),
variables,
})
if (!(markAsRead && markAsRead.read === true)) return
this.displayedNotifications = this.displayedNotifications.map(n => {
return this.equalNotification(n, markAsRead) ? markAsRead : n
})
} catch (err) {
this.$toast.error(err.message)
}
},
equalNotification(a, b) {
return a.from.id === b.from.id && a.createdAt === b.createdAt && a.reason === b.reason
},
},
computed: {
notificationsCount() {
return (this.displayedNotifications || []).length
},
unreadNotificationsCount() {
let countUnread = 0
if (this.displayedNotifications) {
this.displayedNotifications.forEach(notification => {
if (!notification.read) countUnread++
})
}
return countUnread
const result = this.notifications.reduce((count, notification) => {
return notification.read ? count : count + 1
}, 0)
return result
},
},
apollo: {
@ -78,17 +68,17 @@ export default {
query() {
return notificationQuery(this.$i18n)
},
variables() {
return {
read: false,
orderBy: 'updatedAt_desc',
}
},
pollInterval: NOTIFICATIONS_POLL_INTERVAL,
update(data) {
const newNotifications = data.notifications.filter(newN => {
return !this.displayedNotifications.find(oldN => this.equalNotification(newN, oldN))
})
this.displayedNotifications = newNotifications
.concat(this.displayedNotifications)
.sort((a, b) => {
return new Date(b.createdAt) - new Date(a.createdAt)
})
return data.notifications
update({ notifications }) {
return unionBy(notifications, this.notifications, notification => notification.id).sort(
(a, b) => new Date(b.createdAt) - new Date(a.createdAt),
)
},
error(error) {
this.$toast.error(error.message)
@ -98,7 +88,7 @@ export default {
}
</script>
<style>
<style lang="scss">
.notifications-menu {
display: flex;
align-items: center;
@ -106,5 +96,16 @@ export default {
.notifications-menu-popover {
max-width: 500px;
margin-bottom: $size-height-base;
}
.notifications-link-container {
background-color: $background-color-softer-active;
text-align: center;
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: $size-height-base;
padding: $space-x-small;
}
</style>

View File

@ -0,0 +1,174 @@
import { config, mount, createLocalVue, RouterLinkStub } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import VTooltip from 'v-tooltip'
import Vuex from 'vuex'
import NotificationsTable from './NotificationsTable'
import Filters from '~/plugins/vue-filters'
import { notifications } from '~/components/utils/Notifications'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(Filters)
localVue.use(VTooltip)
localVue.use(Vuex)
localVue.filter('truncate', string => string)
config.stubs['client-only'] = '<span><slot /></span>'
describe('NotificationsTable.vue', () => {
let wrapper, mocks, propsData, stubs
const postNotification = notifications[0]
const commentNotification = notifications[1]
beforeEach(() => {
mocks = {
$t: jest.fn(string => string),
}
stubs = {
NuxtLink: RouterLinkStub,
}
propsData = {}
})
describe('mount', () => {
const Wrapper = () => {
const store = new Vuex.Store({
getters: {
'auth/isModerator': () => false,
'auth/user': () => {
return {}
},
},
})
return mount(NotificationsTable, {
propsData,
mocks,
localVue,
store,
stubs,
})
}
beforeEach(() => {
wrapper = Wrapper()
})
describe('no notifications', () => {
it('renders HcEmpty component', () => {
expect(wrapper.find('.hc-empty').exists()).toBe(true)
})
})
describe('given notifications', () => {
beforeEach(() => {
propsData.notifications = notifications
wrapper = Wrapper()
})
it('renders a table', () => {
expect(wrapper.find('.ds-table').exists()).toBe(true)
})
describe('renders 4 columns', () => {
it('for icon', () => {
expect(wrapper.vm.fields.icon).toBeTruthy()
})
it('for user', () => {
expect(wrapper.vm.fields.user).toBeTruthy()
})
it('for post', () => {
expect(wrapper.vm.fields.post).toBeTruthy()
})
it('for content', () => {
expect(wrapper.vm.fields.content).toBeTruthy()
})
})
describe('Post', () => {
let firstRowNotification
beforeEach(() => {
firstRowNotification = wrapper.findAll('tbody tr').at(0)
})
it('renders the author', () => {
const username = firstRowNotification.find('.username')
expect(username.text()).toEqual(postNotification.from.author.name)
})
it('renders the reason for the notification', () => {
const dsTexts = firstRowNotification.findAll('.ds-text')
const reason = dsTexts.filter(
element => element.text() === 'notifications.reason.mentioned_in_post',
)
expect(reason.exists()).toBe(true)
})
it('renders a link to the Post', () => {
const postLink = firstRowNotification.find('a.notification-mention-post')
expect(postLink.text()).toEqual(postNotification.from.title)
})
it("renders the Post's content", () => {
const boldTags = firstRowNotification.findAll('b')
const content = boldTags.filter(
element => element.text() === postNotification.from.contentExcerpt,
)
expect(content.exists()).toBe(true)
})
})
describe('Comment', () => {
let secondRowNotification
beforeEach(() => {
secondRowNotification = wrapper.findAll('tbody tr').at(1)
})
it('renders the author', () => {
const username = secondRowNotification.find('.username')
expect(username.text()).toEqual(commentNotification.from.author.name)
})
it('renders the reason for the notification', () => {
const dsTexts = secondRowNotification.findAll('.ds-text')
const reason = dsTexts.filter(
element => element.text() === 'notifications.reason.mentioned_in_comment',
)
expect(reason.exists()).toBe(true)
})
it('renders a link to the Post', () => {
const postLink = secondRowNotification.find('a.notification-mention-post')
expect(postLink.text()).toEqual(commentNotification.from.post.title)
})
it("renders the Post's content", () => {
const boldTags = secondRowNotification.findAll('b')
const content = boldTags.filter(
element => element.text() === commentNotification.from.contentExcerpt,
)
expect(content.exists()).toBe(true)
})
})
describe('unread status', () => {
it('does not have class `notification-status`', () => {
expect(wrapper.find('.notification-status').exists()).toBe(false)
})
it('clicking on a Post link emits `markNotificationAsRead`', () => {
wrapper.find('a.notification-mention-post').trigger('click')
expect(wrapper.emitted().markNotificationAsRead[0][0]).toEqual(postNotification.from.id)
})
it('adds class `notification-status` when read is true', () => {
postNotification.read = true
wrapper = Wrapper()
expect(wrapper.find('.notification-status').exists()).toBe(true)
})
})
})
})
})

View File

@ -0,0 +1,86 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import helpers from '~/storybook/helpers'
import { post } from '~/components/PostCard/PostCard.story.js'
import { user } from '~/components/User/User.story.js'
helpers.init()
export const notifications = [
{
read: true,
reason: 'mentioned_in_post',
createdAt: '2019-10-29T15:36:02.106Z',
from: {
__typename: 'Post',
...post,
},
__typename: 'NOTIFIED',
index: 9,
},
{
read: false,
reason: 'commented_on_post',
createdAt: '2019-10-29T15:38:25.199Z',
from: {
__typename: 'Comment',
id: 'b6b38937-3efc-4d5e-b12c-549e4d6551a5',
createdAt: '2019-10-29T15:38:25.184Z',
updatedAt: '2019-10-29T15:38:25.184Z',
disabled: false,
deleted: false,
content:
'<p><a class="mention" href="/profile/u1" data-mention-id="u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac placerat. Tempor id eu nisl nunc mi ipsum faucibus vitae. Nibh praesent tristique magna sit amet purus gravida quis blandit. Magna eget est lorem ipsum dolor. In fermentum posuere urna nec. Eleifend donec pretium vulputate sapien nec sagittis aliquam. Augue interdum velit euismod in pellentesque. Id diam maecenas ultricies mi eget mauris pharetra. Donec pretium vulputate sapien nec. Dolor morbi non arcu risus quis varius quam quisque. Blandit turpis cursus in hac habitasse. Est ultricies integer quis auctor elit sed vulputate mi sit. Nunc consequat interdum varius sit amet mattis vulputate enim. Semper feugiat nibh sed pulvinar. Eget felis eget nunc lobortis mattis aliquam. Ultrices vitae auctor eu augue. Tellus molestie nunc non blandit massa enim nec dui. Pharetra massa massa ultricies mi quis hendrerit dolor. Nisl suscipit adipiscing bibendum est ultricies integer.</p>',
contentExcerpt:
'<p><a href="/profile/u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra …</p>',
...post,
author: user,
},
__typename: 'NOTIFIED',
index: 1,
},
{
read: false,
reason: 'mentioned_in_comment',
createdAt: '2019-10-29T15:38:13.422Z',
from: {
__typename: 'Comment',
id: 'b91f4d4d-b178-4e42-9764-7fbcbf097f4c',
createdAt: '2019-10-29T15:38:13.41Z',
updatedAt: '2019-10-29T15:38:13.41Z',
disabled: false,
deleted: false,
content:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac placerat. Tempor id eu nisl nunc mi ipsum faucibus vitae. Nibh praesent tristique magna sit amet purus gravida quis blandit. Magna eget est lorem ipsum dolor. In fermentum posuere urna nec. Eleifend donec pretium vulputate sapien nec sagittis aliquam. Augue interdum velit euismod in pellentesque. Id diam maecenas ultricies mi eget mauris pharetra. Donec pretium vulputate sapien nec. Dolor morbi non arcu risus quis varius quam quisque. Blandit turpis cursus in hac habitasse. Est ultricies integer quis auctor elit sed vulputate mi sit. Nunc consequat interdum varius sit amet mattis vulputate enim. Semper feugiat nibh sed pulvinar. Eget felis eget nunc lobortis mattis aliquam. Ultrices vitae auctor eu augue. Tellus molestie nunc non blandit massa enim nec dui. Pharetra massa massa ultricies mi quis hendrerit dolor. Nisl suscipit adipiscing bibendum est ultricies integer.</p><p><a class="mention" href="/profile/u1" data-mention-id="u1" target="_blank">@peter-lustig</a> </p><p></p>',
contentExcerpt:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac …</p>',
...post,
author: user,
},
__typename: 'NOTIFIED',
index: 2,
},
]
storiesOf('NotificationsTable', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('with notifications', () => ({
components: { NotificationsTable },
store: helpers.store,
data: () => ({
notifications,
}),
methods: {
markNotificationAsRead: action('markNotificationAsRead'),
},
template: `<notifications-table
@markNotificationAsRead="markNotificationAsRead"
:notifications="notifications"
/>`,
}))
.add('without notifications', () => ({
components: { NotificationsTable },
store: helpers.store,
template: `<notifications-table />`,
}))

View File

@ -0,0 +1,110 @@
<template>
<ds-table v-if="notifications && notifications.length" :data="notifications" :fields="fields">
<template #icon="scope">
<ds-icon
v-if="scope.row.from.post"
name="comment"
v-tooltip="{ content: $t('notifications.comment'), placement: 'right' }"
/>
<ds-icon
v-else
name="bookmark"
v-tooltip="{ content: $t('notifications.post'), placement: 'right' }"
/>
</template>
<template #user="scope">
<ds-space margin-bottom="base">
<client-only>
<hc-user
:user="scope.row.from.author"
:date-time="scope.row.from.createdAt"
:trunc="35"
:class="{ 'notification-status': scope.row.read }"
/>
</client-only>
</ds-space>
<ds-text :class="{ 'notification-status': scope.row.read, reason: true }">
{{ $t(`notifications.reason.${scope.row.reason}`) }}
</ds-text>
</template>
<template #post="scope">
<nuxt-link
class="notification-mention-post"
:class="{ 'notification-status': scope.row.read }"
:to="{
name: 'post-id-slug',
params: params(scope.row.from),
hash: hashParam(scope.row.from),
}"
@click.native="markNotificationAsRead(scope.row.from.id)"
>
<b>{{ scope.row.from.title || scope.row.from.post.title | truncate(50) }}</b>
</nuxt-link>
</template>
<template #content="scope">
<b :class="{ 'notification-status': scope.row.read }">
{{ scope.row.from.contentExcerpt | removeHtml }}
</b>
</template>
</ds-table>
<hc-empty v-else icon="alert" :message="$t('notifications.empty')" />
</template>
<script>
import HcUser from '~/components/User/User'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {
HcUser,
HcEmpty,
},
props: {
notifications: { type: Array, default: () => [] },
},
computed: {
fields() {
return {
icon: {
label: ' ',
width: '5',
},
user: {
label: this.$t('notifications.user'),
width: '45%',
},
post: {
label: this.$t('notifications.post'),
width: '25%',
},
content: {
label: this.$t('notifications.content'),
width: '25%',
},
}
},
},
methods: {
isComment(notificationSource) {
return notificationSource.__typename === 'Comment'
},
params(notificationSource) {
const post = this.isComment(notificationSource) ? notificationSource.post : notificationSource
return {
id: post.id,
slug: post.slug,
}
},
hashParam(notificationSource) {
return this.isComment(notificationSource) ? `#commentId-${notificationSource.id}` : ''
},
markNotificationAsRead(notificationSourceId) {
this.$emit('markNotificationAsRead', notificationSourceId)
},
},
}
</script>
<style lang="scss">
.notification-status {
opacity: $opacity-soft;
}
</style>

View File

@ -0,0 +1,72 @@
import { mount, createLocalVue } from '@vue/test-utils'
import Styleguide from '@human-connection/styleguide'
import Paginate from './Paginate'
const localVue = createLocalVue()
localVue.use(Styleguide)
describe('Paginate.vue', () => {
let propsData, wrapper, Wrapper, nextButton, backButton
beforeEach(() => {
propsData = {}
})
Wrapper = () => {
return mount(Paginate, { propsData, localVue })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('next button', () => {
beforeEach(() => {
propsData.hasNext = true
wrapper = Wrapper()
nextButton = wrapper.findAll('.ds-button').at(0)
})
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
nextButton = wrapper.findAll('.ds-button').at(0)
expect(nextButton.attributes().disabled).toEqual('disabled')
})
it('is not disabled if hasNext is true', () => {
expect(nextButton.attributes().disabled).toBeUndefined()
})
it('emits next when clicked', async () => {
await nextButton.trigger('click')
expect(wrapper.emitted().next).toHaveLength(1)
})
})
describe('back button', () => {
beforeEach(() => {
propsData.hasPrevious = true
wrapper = Wrapper()
backButton = wrapper.findAll('.ds-button').at(1)
})
it('is disabled by default', () => {
propsData = {}
wrapper = Wrapper()
backButton = wrapper.findAll('.ds-button').at(1)
expect(backButton.attributes().disabled).toEqual('disabled')
})
it('is not disabled if hasPrevious is true', () => {
expect(backButton.attributes().disabled).toBeUndefined()
})
it('emits back when clicked', async () => {
await backButton.trigger('click')
expect(wrapper.emitted().back).toHaveLength(1)
})
})
})
})

View File

@ -0,0 +1,28 @@
import { storiesOf } from '@storybook/vue'
import { withA11y } from '@storybook/addon-a11y'
import { action } from '@storybook/addon-actions'
import Paginate from '~/components/Paginate/Paginate'
import helpers from '~/storybook/helpers'
helpers.init()
storiesOf('Paginate', module)
.addDecorator(withA11y)
.addDecorator(helpers.layout)
.add('basic pagination', () => ({
components: { Paginate },
data: () => ({
hasNext: true,
hasPrevious: false,
}),
methods: {
back: action('back'),
next: action('next'),
},
template: `<paginate
:hasNext="hasNext"
:hasPrevious="hasPrevious"
@back="back"
@next="next"
/>`,
}))

View File

@ -0,0 +1,26 @@
<template>
<ds-flex direction="row-reverse">
<ds-flex-item width="50px">
<ds-button @click="next" :disabled="!hasNext" icon="arrow-right" primary />
</ds-flex-item>
<ds-flex-item width="50px">
<ds-button @click="back" :disabled="!hasPrevious" icon="arrow-left" primary />
</ds-flex-item>
</ds-flex>
</template>
<script>
export default {
props: {
hasNext: { type: Boolean, default: false },
hasPrevious: { type: Boolean, default: false },
},
methods: {
back() {
this.$emit('back')
},
next() {
this.$emit('next')
},
},
}
</script>

View File

@ -5,7 +5,7 @@ import helpers from '~/storybook/helpers'
helpers.init()
const post = {
export const post = {
id: 'd23a4265-f5f7-4e17-9f86-85f714b4b9f8',
title: 'Very nice Post Title',
contentExcerpt: '<p>My post content</p>',

View File

@ -5,7 +5,7 @@ import helpers from '~/storybook/helpers'
helpers.init()
const user = {
export const user = {
id: 'u6',
slug: 'louie',
name: 'Louie',

View File

@ -0,0 +1,43 @@
export const notifications = [
{
read: false,
reason: 'mentioned_in_post',
from: {
__typename: 'Post',
id: 'post-1',
title: 'some post title',
slug: 'some-post-title',
contentExcerpt: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe',
},
},
},
{
read: false,
reason: 'mentioned_in_comment',
from: {
__typename: 'Comment',
id: 'comment-2',
contentExcerpt: 'this is yet another post content',
post: {
id: 'post-1',
title: 'some post on a comment',
slug: 'some-post-on-a-comment',
contentExcerpt: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',
name: 'John Doe',
},
},
author: {
id: 'jane-1',
slug: 'jane-doe',
name: 'Jane Doe',
},
},
},
]

View File

@ -51,8 +51,9 @@ export const notificationQuery = i18n => {
${commentFragment(lang)}
${postFragment(lang)}
query {
notifications(read: false, orderBy: updatedAt_desc) {
query($read: Boolean, $orderBy: NotificationOrdering, $first: Int, $offset: Int) {
notifications(read: $read, orderBy: $orderBy, first: $first, offset: $offset) {
id
read
reason
createdAt
@ -81,6 +82,7 @@ export const markAsReadMutation = i18n => {
mutation($id: ID!) {
markAsRead(id: $id) {
id
read
reason
createdAt

View File

@ -71,52 +71,7 @@
<notification-menu placement="top" />
</client-only>
<client-only>
<dropdown class="avatar-menu" offset="8">
<template slot="default" slot-scope="{ toggleMenu }">
<a
class="avatar-menu-trigger"
:href="
$router.resolve({
name: 'profile-id-slug',
params: { id: user.id, slug: user.slug },
}).href
"
@click.prevent="toggleMenu"
>
<hc-avatar :user="user" />
<ds-icon size="xx-small" name="angle-down" />
</a>
</template>
<template slot="popover" slot-scope="{ closeMenu }">
<div class="avatar-menu-popover">
{{ $t('login.hello') }}
<b>{{ userName }}</b>
<template v-if="user.role !== 'user'">
<ds-text color="softer" size="small" style="margin-bottom: 0">
{{ user.role | camelCase }}
</ds-text>
</template>
<hr />
<ds-menu :routes="routes" :matcher="matcher">
<ds-menu-item
slot="menuitem"
slot-scope="item"
:route="item.route"
:parents="item.parents"
@click.native="closeMenu(false)"
>
<ds-icon :name="item.route.icon" />
{{ item.route.name }}
</ds-menu-item>
</ds-menu>
<hr />
<nuxt-link class="logout-link" :to="{ name: 'logout' }">
<ds-icon name="sign-out" />
{{ $t('login.logout') }}
</nuxt-link>
</div>
</template>
</dropdown>
<avatar-menu placement="top" />
</client-only>
</template>
</div>
@ -143,22 +98,20 @@ import { mapGetters, mapActions } from 'vuex'
import LocaleSwitch from '~/components/LocaleSwitch/LocaleSwitch'
import SearchInput from '~/components/SearchInput.vue'
import Modal from '~/components/Modal'
import NotificationMenu from '~/components/notifications/NotificationMenu/NotificationMenu'
import Dropdown from '~/components/Dropdown'
import HcAvatar from '~/components/Avatar/Avatar.vue'
import NotificationMenu from '~/components/NotificationMenu/NotificationMenu'
import seo from '~/mixins/seo'
import FilterPosts from '~/components/FilterPosts/FilterPosts.vue'
import CategoryQuery from '~/graphql/CategoryQuery.js'
import PageFooter from '~/components/PageFooter/PageFooter'
import AvatarMenu from '~/components/AvatarMenu/AvatarMenu'
export default {
components: {
Dropdown,
LocaleSwitch,
SearchInput,
Modal,
NotificationMenu,
HcAvatar,
AvatarMenu,
FilterPosts,
PageFooter,
},
@ -172,49 +125,10 @@ export default {
},
computed: {
...mapGetters({
user: 'auth/user',
isLoggedIn: 'auth/isLoggedIn',
isModerator: 'auth/isModerator',
isAdmin: 'auth/isAdmin',
quickSearchResults: 'search/quickResults',
quickSearchPending: 'search/quickPending',
}),
userName() {
const { name } = this.user || {}
return name || this.$t('profile.userAnonym')
},
routes() {
if (!this.user.slug) {
return []
}
let routes = [
{
name: this.$t('profile.name'),
path: `/profile/${this.user.slug}`,
icon: 'user',
},
{
name: this.$t('settings.name'),
path: `/settings`,
icon: 'cogs',
},
]
if (this.isModerator) {
routes.push({
name: this.$t('moderation.name'),
path: `/moderation`,
icon: 'balance-scale',
})
}
if (this.isAdmin) {
routes.push({
name: this.$t('admin.name'),
path: `/admin`,
icon: 'shield',
})
}
return routes
},
showFilterPostsDropdown() {
const [firstRoute] = this.$route.matched
return firstRoute && firstRoute.name === 'index'
@ -239,13 +153,6 @@ export default {
})
})
},
matcher(url, route) {
if (url.indexOf('/profile') === 0) {
// do only match own profile
return this.$route.path === url
}
return this.$route.path.indexOf(url) === 0
},
toggleMobileMenuView() {
this.toggleMobileMenu = !this.toggleMobileMenu
},
@ -289,45 +196,6 @@ export default {
.main-navigation-right .desktop-view {
float: right;
}
.avatar-menu {
margin: 2px 0px 0px 5px;
}
.avatar-menu-trigger {
user-select: none;
display: flex;
align-items: center;
padding-left: $space-xx-small;
}
.avatar-menu-popover {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
hr {
color: $color-neutral-90;
background-color: $color-neutral-90;
}
.logout-link {
margin-left: -$space-small;
margin-right: -$space-small;
margin-top: -$space-xxx-small;
margin-bottom: -$space-x-small;
padding: $space-x-small $space-small;
// subtract menu border with from padding
padding-left: $space-small - 2;
color: $text-color-base;
&:hover {
color: $text-color-link-active;
}
}
nav {
margin-left: -$space-small;
margin-right: -$space-small;
margin-top: -$space-xx-small;
margin-bottom: -$space-xx-small;
a {
padding-left: 12px;
}
}
}
@media only screen and (min-width: 960px) {
.mobile-hamburger-menu {
display: none;

View File

@ -175,7 +175,18 @@
"mentioned_in_comment": "Hat dich in einem Kommentar erwähnt …",
"commented_on_post": "Hat deinen Beitrag kommentiert …"
},
"comment": "Kommentar"
"comment": "Kommentar",
"title": "Benachrichtigungen",
"pageLink": "Alle Benachrichtigungen",
"post": "Beitrag",
"user": "Benutzer",
"content": "Inhalt",
"filterLabel": {
"all": "Alle",
"read": "Gelesen ",
"unread": "Ungelesen"
},
"empty": "Sorry, du hast im Moment keine Benachrichtigungen."
},
"search": {
"placeholder": "Suchen",
@ -674,7 +685,7 @@
"terms-of-service": {
"title": "Nutzungsbedingungen",
"description": "Die folgenden Nutzungsbedingungen sind Basis für die Nutzung unseres Netzwerkes. Beim Registrieren musst Du sie anerkennen und wir werden Dich auch später über ggf. stattfindende Änderungen informieren. Das Human Connection Netzwerk wird in Deutschland betrieben und unterliegt daher deutschem Recht. Gerichtsstand ist Kirchheim / Teck. Zu Details schau in unser Impressum: <a href=\"https://human-connection.org/impressum\" target=\"_blank\" >https://human-connection.org/impressum</a> "
},
},
"use-and-license" : {
"title": "Nutzung und Lizenz",
"description": "Sind Inhalte, die Du bei uns einstellst, durch Rechte am geistigen Eigentum geschützt, erteilst Du uns eine nicht-exklusive, übertragbare, unterlizenzierbare und weltweite Lizenz für die Nutzung dieser Inhalte für die Bereitstellung in unserem Netzwerk. Diese Lizenz endet, sobald Du Deine Inhalte oder Deinen ganzen Account löscht. Bedenke, dass andere Deine Inhalte weiter teilen können und wir diese nicht löschen können."
@ -702,6 +713,6 @@
"addition" : {
"title": "Zusätzliche machen wir regelmäßig Veranstaltungen, wo Du auch Eindrücke wiedergeben und Fragen stellen kannst. Du findest eine aktuelle Übersicht hier:",
"description": "<a href=\"https://human-connection.org/veranstaltungen/\" target=\"_blank\" > https://human-connection.org/veranstaltungen/ </a>"
}
}
}
}

View File

@ -176,7 +176,18 @@
"mentioned_in_comment": "Mentioned you in a comment …",
"commented_on_post": "Commented on your post …"
},
"comment": "Comment"
"comment": "Comment",
"title": "Notifications",
"pageLink": "All notifications",
"post": "Post",
"user": "User",
"content": "Content",
"filterLabel": {
"all": "All",
"read": "Read",
"unread": "Unread"
},
"empty": "Sorry, you don't have any notifications at the moment."
},
"search": {
"placeholder": "Search",
@ -675,7 +686,7 @@
"terms-of-service": {
"title": "Terms of Service",
"description": "The following terms of use form the basis for the use of our network. When you register, you must accept them and we will inform you later about any changes that may take place. The Human Connection Network is operated in Germany and is therefore subject to German law. Place of jurisdiction is Kirchheim / Teck. For details see our imprint: <a href=\"https://human-connection.org/imprint\" target=\"_blank\" >https://human-connection.org/imprint</a> "
},
},
"use-and-license" : {
"title": "Use and License",
"description": "If any content you post to us is protected by intellectual property rights, you grant us a non-exclusive, transferable, sublicensable, worldwide license to use such content for posting to our network. This license expires when you delete your content or your entire account. Remember that others may share your content and we cannot delete it."
@ -703,10 +714,10 @@
"addition" : {
"title": "In addition, we regularly hold events where you can also share your impressions and ask questions. You can find a current overview here:",
"description": "<a href=\"https://human-connection.org/events/\" target=\"_blank\" > https://human-connection.org/events/ </a>"
}
}
}
}

View File

@ -182,6 +182,25 @@
"hint": "O que você está pesquisando??",
"failed": "Nada foi encontrado"
},
"notifications": {
"reason": {
"mentioned_in_post": "Mencinou você em um post …",
"mentioned_in_comment": "Mentionou você em um comentário …",
"commented_on_post": "Comentou no seu post …"
},
"comment": "Comentário",
"title": "Notificações",
"pageLink": "Todas as notificações",
"post": "Post",
"user": "Usuário",
"content": "Conteúdo",
"filterLabel": {
"all": "Todos",
"read": "Lido",
"unread": "Não lido"
},
"empty": "Desculpe, não tem nenhuma notificação neste momento."
},
"settings": {
"name": "Configurações",
"data": {

View File

@ -97,6 +97,7 @@
"@babel/preset-env": "~7.7.1",
"@storybook/addon-a11y": "^5.2.5",
"@storybook/addon-actions": "^5.2.5",
"@storybook/addon-notes": "^5.2.5",
"@storybook/vue": "~5.2.5",
"@vue/cli-shared-utils": "~4.0.5",
"@vue/eslint-config-prettier": "~5.0.0",
@ -131,6 +132,7 @@
"prettier": "~1.18.2",
"sass-loader": "~8.0.0",
"storybook-design-token": "^0.4.1",
"storybook-vue-router": "^1.0.7",
"style-loader": "~0.23.1",
"style-resources-loader": "~1.2.1",
"vue-jest": "~3.0.5",

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -58,7 +58,7 @@
<script>
import FilterMenu from '~/components/FilterMenu/FilterMenu.vue'
import HcEmpty from '~/components/Empty'
import HcEmpty from '~/components/Empty/Empty'
import HcPostCard from '~/components/PostCard/PostCard.vue'
import HcLoadMore from '~/components/LoadMore.vue'
import MasonryGrid from '~/components/MasonryGrid/MasonryGrid.vue'

View File

@ -130,7 +130,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
import HcRelativeDateTime from '~/components/RelativeDateTime'
import { reportListQuery } from '~/graphql/Moderation.js'

View File

@ -0,0 +1,150 @@
import { config, shallowMount, mount, createLocalVue } from '@vue/test-utils'
import NotificationsPage from './index.vue'
import Styleguide from '@human-connection/styleguide'
import VTooltip from 'v-tooltip'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import Paginate from '~/components/Paginate/Paginate'
const localVue = createLocalVue()
localVue.use(Styleguide)
localVue.use(VTooltip)
config.stubs['client-only'] = '<span><slot /></span>'
describe('PostIndex', () => {
let wrapper, Wrapper, mocks, propsData
beforeEach(() => {
propsData = {}
mocks = {
$t: string => string,
$toast: {
error: jest.fn(string => string),
},
$i18n: {
locale: () => 'en',
},
$apollo: {
mutate: jest.fn().mockResolvedValueOnce({
data: { markAsRead: { id: 'notificationSourceId', read: true } },
}),
queries: {
notifications: {
refresh: jest.fn().mockResolvedValueOnce(),
},
},
},
}
})
describe('shallowMount', () => {
beforeEach(() => {
Wrapper = () => {
return shallowMount(NotificationsPage, {
mocks,
localVue,
propsData,
})
}
wrapper = Wrapper()
})
it('renders a Notications header', () => {
expect(wrapper.find('ds-heading-stub').exists()).toBe(true)
})
it('renders a `dropdown-filter` component', () => {
expect(wrapper.find('dropdown-filter-stub').exists()).toBe(true)
})
it('renders a `notifications-table` component', () => {
expect(wrapper.find('notifications-table-stub').exists()).toBe(true)
})
})
describe('mount', () => {
beforeEach(() => {
Wrapper = () => {
return mount(NotificationsPage, {
mocks,
localVue,
propsData,
})
}
})
describe('filterNotifications', () => {
beforeEach(() => {
propsData.filterOptions = [
{ label: 'All', value: null },
{ label: 'Read', value: true },
{ label: 'Unread', value: false },
]
wrapper = Wrapper()
wrapper.find(DropdownFilter).vm.$emit('filterNotifications', propsData.filterOptions[1])
})
it('sets `notificationRead` to value of received option', () => {
expect(wrapper.vm.notificationRead).toEqual(propsData.filterOptions[1].value)
})
it('set label to the label of the received option', () => {
expect(wrapper.vm.selected).toEqual(propsData.filterOptions[1].label)
})
it('refreshes the notificaitons', () => {
expect(mocks.$apollo.queries.notifications.refresh).toHaveBeenCalledTimes(1)
})
})
describe('markNotificationAsRead', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper.find(NotificationsTable).vm.$emit('markNotificationAsRead', 'notificationSourceId')
})
it('calls markNotificationAsRead mutation', () => {
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({ variables: { id: 'notificationSourceId' } }),
)
})
describe('error handling', () => {
beforeEach(() => {
mocks.$apollo.mutate = jest.fn().mockRejectedValueOnce({ message: 'Some error message' })
wrapper = Wrapper()
wrapper
.find(NotificationsTable)
.vm.$emit('markNotificationAsRead', 'notificationSourceId')
})
it('shows an error message if there is an error', () => {
expect(mocks.$toast.error).toHaveBeenCalledWith('Some error message')
})
})
})
describe('Paginate', () => {
beforeEach(() => {
wrapper = Wrapper()
})
describe('next: given a user is on the first page', () => {
it('adds offset to pageSize to skip first x notifications and display next page', () => {
wrapper.find(Paginate).vm.$emit('next')
expect(wrapper.vm.offset).toEqual(12)
})
})
describe('back: given a user is on the third page', () => {
it('sets offset when back is emitted', () => {
wrapper.setData({ offset: 24 })
wrapper.find(Paginate).vm.$emit('back')
expect(wrapper.vm.offset).toEqual(12)
})
})
})
})
})

View File

@ -0,0 +1,119 @@
<template>
<ds-card space="small">
<ds-flex class="notifications-page-flex">
<ds-flex-item :width="{ lg: '85%' }">
<ds-heading tag="h3">{{ $t('notifications.title') }}</ds-heading>
</ds-flex-item>
<ds-flex-item width="110px">
<client-only>
<dropdown-filter
@filterNotifications="filterNotifications"
:filterOptions="filterOptions"
:selected="selected"
/>
</client-only>
</ds-flex-item>
</ds-flex>
<ds-space />
<notifications-table
@markNotificationAsRead="markNotificationAsRead"
:notifications="notifications"
/>
<paginate :hasNext="hasNext" :hasPrevious="hasPrevious" @back="back" @next="next" />
</ds-card>
</template>
<script>
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import Paginate from '~/components/Paginate/Paginate'
import { notificationQuery, markAsReadMutation } from '~/graphql/User'
export default {
components: {
DropdownFilter,
NotificationsTable,
Paginate,
},
data() {
const pageSize = 12
return {
offset: 0,
notifications: [],
nofiticationRead: null,
pageSize,
first: pageSize,
hasNext: false,
selected: this.$t('notifications.filterLabel.all'),
}
},
computed: {
hasPrevious() {
return this.offset > 0
},
filterOptions() {
return [
{ label: this.$t('notifications.filterLabel.all'), value: null },
{ label: this.$t('notifications.filterLabel.read'), value: true },
{ label: this.$t('notifications.filterLabel.unread'), value: false },
]
},
},
methods: {
filterNotifications(option) {
this.notificationRead = option.value
this.selected = option.label
this.$apollo.queries.notifications.refresh()
},
async markNotificationAsRead(notificationSourceId) {
try {
await this.$apollo.mutate({
mutation: markAsReadMutation(this.$i18n),
variables: { id: notificationSourceId },
})
} catch (error) {
this.$toast.error(error.message)
}
},
back() {
this.offset = Math.max(this.offset - this.pageSize, 0)
},
next() {
this.offset += this.pageSize
},
},
apollo: {
notifications: {
query() {
return notificationQuery(this.$i18n)
},
variables() {
const { first, offset } = this
return {
read: this.notificationRead,
orderBy: 'updatedAt_desc',
first,
offset,
}
},
update({ notifications }) {
if (!notifications) return []
this.hasNext = notifications.length >= this.pageSize
if (notifications.length <= 0 && this.offset > 0) return this.notifications // edge case, avoid a blank page
return notifications.map((notification, index) =>
Object.assign({}, notification, { index: this.offset + index }),
)
},
fetchPolicy: 'cache-and-network',
error(error) {
this.$toast.error(error.message)
},
},
},
}
</script>
<style lang="scss">
.notifications-page-flex {
justify-content: space-between;
}
</style>

View File

@ -36,7 +36,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
import HcPostCard from '~/components/PostCard/PostCard.vue'
import HcCategory from '~/components/Category'
import HcHashtag from '~/components/Hashtag/Hashtag'

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -278,7 +278,7 @@ import HcFollowButton from '~/components/FollowButton.vue'
import HcCountTo from '~/components/CountTo.vue'
import HcBadges from '~/components/Badges.vue'
import HcLoadMore from '~/components/LoadMore.vue'
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
import ContentMenu from '~/components/ContentMenu'
import HcUpload from '~/components/Upload'
import HcAvatar from '~/components/Avatar/Avatar.vue'

View File

@ -12,7 +12,7 @@
<script>
import Signup from '~/components/Registration/Signup'
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
layout: 'no-header',

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -5,7 +5,7 @@
</template>
<script>
import HcEmpty from '~/components/Empty.vue'
import HcEmpty from '~/components/Empty/Empty'
export default {
components: {

View File

@ -1,4 +1,5 @@
import '@storybook/addon-actions/register'
import '@storybook/addon-a11y/register'
import 'storybook-design-token/register'
import '@storybook/addon-notes/register-panel'
// import '@storybook/addon-links/register'

View File

@ -33,10 +33,13 @@ const helpers = {
namespaced: true,
getters: {
isModerator() {
return false
return true
},
isAdmin() {
return true
},
user(state) {
return { id: '1', name: 'admin' }
return { id: '1', name: 'admin', slug: 'admin' }
},
},
},

View File

@ -2113,6 +2113,25 @@
react-inspector "^3.0.2"
uuid "^3.3.2"
"@storybook/addon-notes@^5.2.5":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/addon-notes/-/addon-notes-5.2.6.tgz#bf74ff4f8018e315a4c07c3d5e90cd9154ce6e8e"
integrity sha512-CfWOkoPFI1ZAWQYnwFVqGmeCeXnVQGoFyDSVc3NcIFF1lsk2aagGV+ifJMJuDTXIKu0FClKpvMcENWt+bBpA+w==
dependencies:
"@storybook/addons" "5.2.6"
"@storybook/api" "5.2.6"
"@storybook/client-logger" "5.2.6"
"@storybook/components" "5.2.6"
"@storybook/core-events" "5.2.6"
"@storybook/router" "5.2.6"
"@storybook/theming" "5.2.6"
core-js "^3.0.1"
global "^4.3.2"
markdown-to-jsx "^6.10.3"
memoizerific "^1.11.3"
prop-types "^15.7.2"
util-deprecate "^1.0.2"
"@storybook/addons@5.1.9":
version "5.1.9"
resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-5.1.9.tgz#ecf218d08508b97ca5e6e0f1ed361081385bd3ff"
@ -2138,6 +2157,19 @@
global "^4.3.2"
util-deprecate "^1.0.2"
"@storybook/addons@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/addons/-/addons-5.2.6.tgz#c1278137acb3502e068b0b0d07a8371c607e9c02"
integrity sha512-5MF64lsAhIEMxTbVpYROz5Wez595iwSw45yXyP8gWt12d+EmFO5tdy7cYJCxcMuVhDfaCI78tFqS9orr1atVyA==
dependencies:
"@storybook/api" "5.2.6"
"@storybook/channels" "5.2.6"
"@storybook/client-logger" "5.2.6"
"@storybook/core-events" "5.2.6"
core-js "^3.0.1"
global "^4.3.2"
util-deprecate "^1.0.2"
"@storybook/api@5.1.9":
version "5.1.9"
resolved "https://registry.yarnpkg.com/@storybook/api/-/api-5.1.9.tgz#eec5b2f775392ce0803930104c6ce14fa4931e8b"
@ -2184,6 +2216,29 @@
telejson "^3.0.2"
util-deprecate "^1.0.2"
"@storybook/api@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/api/-/api-5.2.6.tgz#43d3c20b90e585e6c94b36e29845d39704ae2135"
integrity sha512-X/di44/SAL68mD6RHTX2qdWwhjRW6BgcfPtu0dMd38ErB3AfsfP4BITXs6kFOeSM8kWiaQoyuw0pOBzA8vlYug==
dependencies:
"@storybook/channels" "5.2.6"
"@storybook/client-logger" "5.2.6"
"@storybook/core-events" "5.2.6"
"@storybook/router" "5.2.6"
"@storybook/theming" "5.2.6"
core-js "^3.0.1"
fast-deep-equal "^2.0.1"
global "^4.3.2"
lodash "^4.17.15"
memoizerific "^1.11.3"
prop-types "^15.6.2"
react "^16.8.3"
semver "^6.0.0"
shallow-equal "^1.1.0"
store2 "^2.7.1"
telejson "^3.0.2"
util-deprecate "^1.0.2"
"@storybook/channel-postmessage@5.2.5":
version "5.2.5"
resolved "https://registry.yarnpkg.com/@storybook/channel-postmessage/-/channel-postmessage-5.2.5.tgz#47397e543a87ea525cbe93f7d85bd8533edc9127"
@ -2209,6 +2264,13 @@
dependencies:
core-js "^3.0.1"
"@storybook/channels@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/channels/-/channels-5.2.6.tgz#e2837508864dc4d5b5e03f078886f0ce113762ea"
integrity sha512-/UsktYsXuvb1efjVPCEivhh5ywRhm7hl73pQnpJLJHRqyLMM2I5nGPFELTTNuU9yWy7sP9QL5gRqBBPe1sqjZQ==
dependencies:
core-js "^3.0.1"
"@storybook/client-api@5.2.5":
version "5.2.5"
resolved "https://registry.yarnpkg.com/@storybook/client-api/-/client-api-5.2.5.tgz#53151a236b6ffc2088acc4535a08e010013e3278"
@ -2244,6 +2306,13 @@
dependencies:
core-js "^3.0.1"
"@storybook/client-logger@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/client-logger/-/client-logger-5.2.6.tgz#cfc4536e9b724b086f7509c2bb34c221016713c9"
integrity sha512-hJvPD267cCwLIRMOISjDH8h9wbwOcXIJip29UlJbU9iMtZtgE+YelmlpmZJvqcDfUiXWWrOh7tP76mj8EAfwIQ==
dependencies:
core-js "^3.0.1"
"@storybook/components@5.1.9":
version "5.1.9"
resolved "https://registry.yarnpkg.com/@storybook/components/-/components-5.1.9.tgz#2a5258780fff07172d103287759946dbb4b13e2d"
@ -2293,6 +2362,31 @@
react-textarea-autosize "^7.1.0"
simplebar-react "^1.0.0-alpha.6"
"@storybook/components@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/components/-/components-5.2.6.tgz#cddb60227720aea7cae34fe782d0370bcdbd4005"
integrity sha512-C7OS90bZ1ZvxlWUZ3B2MPFFggqAtUo7X8DqqS3IwsuDUiK9dD/KS0MwPgOuFDnOTW1R5XqmQd/ylt53w3s/U5g==
dependencies:
"@storybook/client-logger" "5.2.6"
"@storybook/theming" "5.2.6"
"@types/react-syntax-highlighter" "10.1.0"
"@types/react-textarea-autosize" "^4.3.3"
core-js "^3.0.1"
global "^4.3.2"
markdown-to-jsx "^6.9.1"
memoizerific "^1.11.3"
polished "^3.3.1"
popper.js "^1.14.7"
prop-types "^15.7.2"
react "^16.8.3"
react-dom "^16.8.3"
react-focus-lock "^1.18.3"
react-helmet-async "^1.0.2"
react-popper-tooltip "^2.8.3"
react-syntax-highlighter "^8.0.1"
react-textarea-autosize "^7.1.0"
simplebar-react "^1.0.0-alpha.6"
"@storybook/core-events@5.1.9":
version "5.1.9"
resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-5.1.9.tgz#441a6297e2ccfa743e15d1db1f4ac445b91f40d8"
@ -2307,6 +2401,13 @@
dependencies:
core-js "^3.0.1"
"@storybook/core-events@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/core-events/-/core-events-5.2.6.tgz#34c9aae256e7e5f4a565b81f1e77dda8bccc6752"
integrity sha512-W8kLJ7tc0aAxs11CPUxUOCReocKL4MYGyjTg8qwk0USLzPUb/FUQWmhcm2ilFz6Nz8dXLcKrXdRVYTmiMsgAeg==
dependencies:
core-js "^3.0.1"
"@storybook/core@5.2.5":
version "5.2.5"
resolved "https://registry.yarnpkg.com/@storybook/core/-/core-5.2.5.tgz#cc04313480a1847aa6881420c675517cc400dc2e"
@ -2416,6 +2517,19 @@
memoizerific "^1.11.3"
qs "^6.6.0"
"@storybook/router@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/router/-/router-5.2.6.tgz#5180d3785501699283c6c3717986c877f84fead5"
integrity sha512-/FZd3fYg5s2QzOqSIP8UMOSnCIFFIlli/jKlOxvm3WpcpxgwQOY4lfHsLO+r9ThCLs2UvVg2R/HqGrOHqDFU7A==
dependencies:
"@reach/router" "^1.2.1"
"@types/reach__router" "^1.2.3"
core-js "^3.0.1"
global "^4.3.2"
lodash "^4.17.15"
memoizerific "^1.11.3"
qs "^6.6.0"
"@storybook/theming@5.1.9":
version "5.1.9"
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-5.1.9.tgz#c425f5867fae0db79e01112853b1808332a5f1a2"
@ -2452,6 +2566,24 @@
prop-types "^15.7.2"
resolve-from "^5.0.0"
"@storybook/theming@5.2.6":
version "5.2.6"
resolved "https://registry.yarnpkg.com/@storybook/theming/-/theming-5.2.6.tgz#e04170b3e53dcfc791b2381c8a39192ae88cd291"
integrity sha512-Xa9R/H8DDgmvxsCHloJUJ2d9ZQl80AeqHrL+c/AKNpx05s9lV74DcinusCf0kz72YGUO/Xt1bAjuOvLnAaS8Gw==
dependencies:
"@emotion/core" "^10.0.14"
"@emotion/styled" "^10.0.14"
"@storybook/client-logger" "5.2.6"
common-tags "^1.8.0"
core-js "^3.0.1"
deep-object-diff "^1.1.0"
emotion-theming "^10.0.14"
global "^4.3.2"
memoizerific "^1.11.3"
polished "^3.3.1"
prop-types "^15.7.2"
resolve-from "^5.0.0"
"@storybook/ui@5.2.5":
version "5.2.5"
resolved "https://registry.yarnpkg.com/@storybook/ui/-/ui-5.2.5.tgz#0c2c67216e4c808e39cdb48301cafde81b77d074"
@ -10704,6 +10836,14 @@ map-visit@^1.0.0:
dependencies:
object-visit "^1.0.0"
markdown-to-jsx@^6.10.3:
version "6.10.3"
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-6.10.3.tgz#7f0946684acd321125ff2de7fd258a9b9c7c40b7"
integrity sha512-PSoUyLnW/xoW6RsxZrquSSz5eGEOTwa15H5eqp3enmrp8esmgDJmhzd6zmQ9tgAA9TxJzx1Hmf3incYU/IamoQ==
dependencies:
prop-types "^15.6.2"
unquote "^1.1.0"
markdown-to-jsx@^6.9.1, markdown-to-jsx@^6.9.3:
version "6.10.2"
resolved "https://registry.yarnpkg.com/markdown-to-jsx/-/markdown-to-jsx-6.10.2.tgz#644f602b81d088f10aef1c3674874876146cf38b"
@ -14955,6 +15095,11 @@ storybook-design-token@^0.4.1:
raw-loader "3.1.0"
react-use-clipboard "0.1.4"
storybook-vue-router@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/storybook-vue-router/-/storybook-vue-router-1.0.7.tgz#366451212149d9d0a32557545b244667bb01768e"
integrity sha512-R+DYARQ40YVbMbV5moLDmQvodJX5FQPVy5cULb782P1gD5rAkulWtgt8yrM7pmjYru+LTPdLS4blrFPnWlb0sQ==
stream-browserify@^2.0.1:
version "2.0.2"
resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"