mirror of
https://github.com/IT4Change/Ocelot-Social.git
synced 2026-01-13 08:24:48 +00:00
Merge pull request #1975 from Human-Connection/1974-notifications-page
Add notifications page with All Notifications
This commit is contained in:
commit
36722962e0
@ -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}`
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
168
webapp/components/AvatarMenu/AvatarMenu.spec.js
Normal file
168
webapp/components/AvatarMenu/AvatarMenu.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
17
webapp/components/AvatarMenu/AvatarMenu.story.js
Normal file
17
webapp/components/AvatarMenu/AvatarMenu.story.js
Normal 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" />',
|
||||
}))
|
||||
146
webapp/components/AvatarMenu/AvatarMenu.vue
Normal file
146
webapp/components/AvatarMenu/AvatarMenu.vue
Normal 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>
|
||||
78
webapp/components/DropdownFilter/DropdownFilter.spec.js
Normal file
78
webapp/components/DropdownFilter/DropdownFilter.spec.js
Normal 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'),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
30
webapp/components/DropdownFilter/DropdownFilter.story.js
Normal file
30
webapp/components/DropdownFilter/DropdownFilter.story.js
Normal 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"
|
||||
/>`,
|
||||
}))
|
||||
78
webapp/components/DropdownFilter/DropdownFilter.vue
Normal file
78
webapp/components/DropdownFilter/DropdownFilter.vue
Normal 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>
|
||||
54
webapp/components/Empty/Empty.spec.js
Normal file
54
webapp/components/Empty/Empty.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
24
webapp/components/Empty/Empty.story.js
Normal file
24
webapp/components/Empty/Empty.story.js
Normal 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 />',
|
||||
}))
|
||||
@ -26,7 +26,7 @@ export default {
|
||||
*/
|
||||
icon: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: 'alert',
|
||||
validator: value => {
|
||||
return value.match(/(messages|events|alert|tasks|docs|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;
|
||||
@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -20,7 +20,7 @@ export default {
|
||||
props: {
|
||||
notifications: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@ -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,
|
||||
@ -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>
|
||||
174
webapp/components/NotificationsTable/NotificationsTable.spec.js
Normal file
174
webapp/components/NotificationsTable/NotificationsTable.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 />`,
|
||||
}))
|
||||
110
webapp/components/NotificationsTable/NotificationsTable.vue
Normal file
110
webapp/components/NotificationsTable/NotificationsTable.vue
Normal 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>
|
||||
72
webapp/components/Paginate/Paginate.spec.js
Normal file
72
webapp/components/Paginate/Paginate.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
28
webapp/components/Paginate/Paginate.story.js
Normal file
28
webapp/components/Paginate/Paginate.story.js
Normal 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"
|
||||
/>`,
|
||||
}))
|
||||
26
webapp/components/Paginate/Paginate.vue
Normal file
26
webapp/components/Paginate/Paginate.vue
Normal 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>
|
||||
@ -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>',
|
||||
|
||||
@ -5,7 +5,7 @@ import helpers from '~/storybook/helpers'
|
||||
|
||||
helpers.init()
|
||||
|
||||
const user = {
|
||||
export const user = {
|
||||
id: 'u6',
|
||||
slug: 'louie',
|
||||
name: 'Louie',
|
||||
|
||||
43
webapp/components/utils/Notifications.js
Normal file
43
webapp/components/utils/Notifications.js
Normal 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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
|
||||
150
webapp/pages/notifications/index.spec.js
Normal file
150
webapp/pages/notifications/index.spec.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
119
webapp/pages/notifications/index.vue
Normal file
119
webapp/pages/notifications/index.vue
Normal 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>
|
||||
@ -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'
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import HcEmpty from '~/components/Empty.vue'
|
||||
import HcEmpty from '~/components/Empty/Empty'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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' }
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
145
webapp/yarn.lock
145
webapp/yarn.lock
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user