diff --git a/backend/src/schema/resolvers/notifications.js b/backend/src/schema/resolvers/notifications.js index 4cab1ffc4..9e6f5c91a 100644 --- a/backend/src/schema/resolvers/notifications.js +++ b/backend/src/schema/resolvers/notifications.js @@ -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}` + }, + }, } diff --git a/backend/src/schema/types/type/NOTIFIED.gql b/backend/src/schema/types/type/NOTIFIED.gql index 5082b5f7f..5557cbd54 100644 --- a/backend/src/schema/types/type/NOTIFIED.gql +++ b/backend/src/schema/types/type/NOTIFIED.gql @@ -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 { diff --git a/webapp/components/AvatarMenu/AvatarMenu.spec.js b/webapp/components/AvatarMenu/AvatarMenu.spec.js new file mode 100644 index 000000000..6327ded0a --- /dev/null +++ b/webapp/components/AvatarMenu/AvatarMenu.spec.js @@ -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'] = '' +config.stubs['router-link'] = '' + +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) + }) + }) + }) + }) +}) diff --git a/webapp/components/AvatarMenu/AvatarMenu.story.js b/webapp/components/AvatarMenu/AvatarMenu.story.js new file mode 100644 index 000000000..9146075cd --- /dev/null +++ b/webapp/components/AvatarMenu/AvatarMenu.story.js @@ -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: '', + })) diff --git a/webapp/components/AvatarMenu/AvatarMenu.vue b/webapp/components/AvatarMenu/AvatarMenu.vue new file mode 100644 index 000000000..393963997 --- /dev/null +++ b/webapp/components/AvatarMenu/AvatarMenu.vue @@ -0,0 +1,146 @@ + + + diff --git a/webapp/components/DropdownFilter/DropdownFilter.spec.js b/webapp/components/DropdownFilter/DropdownFilter.spec.js new file mode 100644 index 000000000..9566fa6ef --- /dev/null +++ b/webapp/components/DropdownFilter/DropdownFilter.spec.js @@ -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'), + ) + }) + }) + }) +}) diff --git a/webapp/components/DropdownFilter/DropdownFilter.story.js b/webapp/components/DropdownFilter/DropdownFilter.story.js new file mode 100644 index 000000000..0703c5c47 --- /dev/null +++ b/webapp/components/DropdownFilter/DropdownFilter.story.js @@ -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: ``, + })) diff --git a/webapp/components/DropdownFilter/DropdownFilter.vue b/webapp/components/DropdownFilter/DropdownFilter.vue new file mode 100644 index 000000000..c24cf18bc --- /dev/null +++ b/webapp/components/DropdownFilter/DropdownFilter.vue @@ -0,0 +1,78 @@ + + + diff --git a/webapp/components/Empty/Empty.spec.js b/webapp/components/Empty/Empty.spec.js new file mode 100644 index 000000000..a4220fa63 --- /dev/null +++ b/webapp/components/Empty/Empty.spec.js @@ -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) + }) + }) + }) +}) diff --git a/webapp/components/Empty/Empty.story.js b/webapp/components/Empty/Empty.story.js new file mode 100644 index 000000000..44d241df7 --- /dev/null +++ b/webapp/components/Empty/Empty.story.js @@ -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: '', + }), + { + notes: "Possible icons include 'messages', 'events', 'alert', 'tasks', 'docs', and 'file'", + }, + ) + .add('default icon, no message', () => ({ + components: { HcEmpty }, + template: '', + })) diff --git a/webapp/components/Empty.vue b/webapp/components/Empty/Empty.vue similarity index 97% rename from webapp/components/Empty.vue rename to webapp/components/Empty/Empty.vue index 8760a6e6f..ea99702b5 100644 --- a/webapp/components/Empty.vue +++ b/webapp/components/Empty/Empty.vue @@ -26,7 +26,7 @@ export default { */ icon: { type: String, - required: true, + default: 'alert', validator: value => { return value.match(/(messages|events|alert|tasks|docs|file)/) }, diff --git a/webapp/components/notifications/Notification/Notification.spec.js b/webapp/components/Notification/Notification.spec.js similarity index 100% rename from webapp/components/notifications/Notification/Notification.spec.js rename to webapp/components/Notification/Notification.spec.js diff --git a/webapp/components/notifications/Notification/Notification.vue b/webapp/components/Notification/Notification.vue similarity index 95% rename from webapp/components/notifications/Notification/Notification.vue rename to webapp/components/Notification/Notification.vue index dc9383c85..446c5321a 100644 --- a/webapp/components/notifications/Notification/Notification.vue +++ b/webapp/components/Notification/Notification.vue @@ -69,10 +69,9 @@ export default { } - diff --git a/webapp/components/NotificationsTable/NotificationsTable.spec.js b/webapp/components/NotificationsTable/NotificationsTable.spec.js new file mode 100644 index 000000000..59b8953e9 --- /dev/null +++ b/webapp/components/NotificationsTable/NotificationsTable.spec.js @@ -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'] = '' + +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) + }) + }) + }) + }) +}) diff --git a/webapp/components/NotificationsTable/NotificationsTable.story.js b/webapp/components/NotificationsTable/NotificationsTable.story.js new file mode 100644 index 000000000..d58f4ff73 --- /dev/null +++ b/webapp/components/NotificationsTable/NotificationsTable.story.js @@ -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: + '

@peter-lustig

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.

', + contentExcerpt: + '

@peter-lustig

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra …

', + ...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: + '

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.

@peter-lustig

', + contentExcerpt: + '

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 …

', + ...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: ``, + })) + .add('without notifications', () => ({ + components: { NotificationsTable }, + store: helpers.store, + template: ``, + })) diff --git a/webapp/components/NotificationsTable/NotificationsTable.vue b/webapp/components/NotificationsTable/NotificationsTable.vue new file mode 100644 index 000000000..6a9d9b033 --- /dev/null +++ b/webapp/components/NotificationsTable/NotificationsTable.vue @@ -0,0 +1,110 @@ + + + diff --git a/webapp/components/Paginate/Paginate.spec.js b/webapp/components/Paginate/Paginate.spec.js new file mode 100644 index 000000000..034d33301 --- /dev/null +++ b/webapp/components/Paginate/Paginate.spec.js @@ -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) + }) + }) + }) +}) diff --git a/webapp/components/Paginate/Paginate.story.js b/webapp/components/Paginate/Paginate.story.js new file mode 100644 index 000000000..6efc9353f --- /dev/null +++ b/webapp/components/Paginate/Paginate.story.js @@ -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: ``, + })) diff --git a/webapp/components/Paginate/Paginate.vue b/webapp/components/Paginate/Paginate.vue new file mode 100644 index 000000000..aa1455d19 --- /dev/null +++ b/webapp/components/Paginate/Paginate.vue @@ -0,0 +1,26 @@ + + diff --git a/webapp/components/PostCard/PostCard.story.js b/webapp/components/PostCard/PostCard.story.js index 1e470ce11..5857167f3 100644 --- a/webapp/components/PostCard/PostCard.story.js +++ b/webapp/components/PostCard/PostCard.story.js @@ -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: '

My post content

', diff --git a/webapp/components/User/User.story.js b/webapp/components/User/User.story.js index 931f40da6..6b3ebcdc8 100644 --- a/webapp/components/User/User.story.js +++ b/webapp/components/User/User.story.js @@ -5,7 +5,7 @@ import helpers from '~/storybook/helpers' helpers.init() -const user = { +export const user = { id: 'u6', slug: 'louie', name: 'Louie', diff --git a/webapp/components/utils/Notifications.js b/webapp/components/utils/Notifications.js new file mode 100644 index 000000000..dfbb1b817 --- /dev/null +++ b/webapp/components/utils/Notifications.js @@ -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', + }, + }, + }, +] diff --git a/webapp/graphql/User.js b/webapp/graphql/User.js index e82280689..fda023d06 100644 --- a/webapp/graphql/User.js +++ b/webapp/graphql/User.js @@ -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 diff --git a/webapp/layouts/default.vue b/webapp/layouts/default.vue index 35af4d25f..a39e0a148 100644 --- a/webapp/layouts/default.vue +++ b/webapp/layouts/default.vue @@ -71,52 +71,7 @@ - - - - + @@ -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; diff --git a/webapp/locales/de.json b/webapp/locales/de.json index 34cd948ed..f5100ad49 100644 --- a/webapp/locales/de.json +++ b/webapp/locales/de.json @@ -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: https://human-connection.org/impressum " - }, + }, "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": " https://human-connection.org/veranstaltungen/ " - } + } } } diff --git a/webapp/locales/en.json b/webapp/locales/en.json index d3b4e8edc..e591b3b68 100644 --- a/webapp/locales/en.json +++ b/webapp/locales/en.json @@ -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: https://human-connection.org/imprint " - }, + }, "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": " https://human-connection.org/events/ " - } + } } } - - - + + + diff --git a/webapp/locales/pt.json b/webapp/locales/pt.json index 0765ff465..d2d753527 100644 --- a/webapp/locales/pt.json +++ b/webapp/locales/pt.json @@ -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": { diff --git a/webapp/package.json b/webapp/package.json index 99ffadb68..613e9f9d0 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -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", diff --git a/webapp/pages/admin/notifications.vue b/webapp/pages/admin/notifications.vue index 0d348633f..faad87a46 100644 --- a/webapp/pages/admin/notifications.vue +++ b/webapp/pages/admin/notifications.vue @@ -5,7 +5,7 @@ + diff --git a/webapp/pages/post/_id/_slug/more-info.vue b/webapp/pages/post/_id/_slug/more-info.vue index 07b4969d3..dae3021aa 100644 --- a/webapp/pages/post/_id/_slug/more-info.vue +++ b/webapp/pages/post/_id/_slug/more-info.vue @@ -36,7 +36,7 @@