Notifications (#240)

* Add notification item

* Add notifications group component

* Add notifications store

* Tweak notification item component

* Tweak styling of notifications / add dense mode

* Fix codestyle problem

* Update storybook

* Add notifications group to private view

* Add tests for notifications store

* Add notify util

* Fix import style

* Tweak positioning of notifications group

* Update readmes
This commit is contained in:
Rijk van Zanten
2020-03-23 18:51:31 -04:00
committed by GitHub
parent 847c7a5554
commit fca6bd34ab
18 changed files with 688 additions and 2 deletions

View File

@@ -44,7 +44,9 @@ export default defineComponent({
.fade-leave-active {
transition: opacity var(--medium) var(--transition);
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
.fade-enter,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,4 @@
import { useNotificationsStore } from './notifications';
export { useNotificationsStore };
export default useNotificationsStore;

View File

@@ -0,0 +1,54 @@
import { useNotificationsStore } from './notifications';
import mountComposition from '../../../.jest/mount-composition';
describe('Stores / Notifications', () => {
it('Returns the id of the created notification', () => {
mountComposition(() => {
const store = useNotificationsStore({});
const id = store.add({ title: 'test' });
expect(id).not.toBe(undefined);
});
});
it('Returns the id of the created notification', () => {
mountComposition(() => {
const store = useNotificationsStore({});
const id = store.add({ title: 'test' });
expect(store.state.queue[0]).toEqual({
id,
title: 'test'
});
});
});
it('Removes a notification by ID', () => {
mountComposition(() => {
const store = useNotificationsStore({});
store.state.queue = [
{
id: 'abc',
title: 'test'
},
{
id: 'def',
title: 'test'
},
{
id: 'ghi',
title: 'test'
}
];
store.remove('def');
expect(store.state.queue).toEqual([
{
id: 'abc',
title: 'test'
},
{
id: 'ghi',
title: 'test'
}
]);
});
});
});

View File

@@ -0,0 +1,26 @@
import { createStore } from 'pinia';
import { Notification, NotificationRaw } from './types';
import nanoid from 'nanoid';
export const useNotificationsStore = createStore({
id: 'useNotifications',
state: () => ({
queue: [] as Notification[]
}),
actions: {
add(notification: NotificationRaw) {
const id = nanoid();
this.state.queue = [
...this.state.queue,
{
...notification,
id
}
];
return id;
},
remove(id: string) {
this.state.queue = this.state.queue.filter(n => n.id !== id);
}
}
});

View File

@@ -0,0 +1,12 @@
export interface NotificationRaw {
id?: string;
title: string;
persist?: boolean;
text?: string;
type?: 'info' | 'success' | 'warning' | 'error';
icon?: string | null;
}
export interface Notification extends NotificationRaw {
readonly id: string;
}

View File

@@ -0,0 +1,4 @@
import notify from './notify';
export { notify };
export default notify;

View File

@@ -0,0 +1,23 @@
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import notify from './notify';
import useNotificationsStore from '@/stores/notifications/';
describe('Utils / Notify', () => {
beforeAll(() => {
Vue.use(VueCompositionAPI);
});
it('Calls notificationsStore.add with the passed notification', () => {
const notificationsStore = useNotificationsStore();
jest.spyOn(notificationsStore as any, 'add');
const notification = {
title: 'test'
};
notify(notification);
expect(notificationsStore.add).toHaveBeenCalledWith(notification);
});
});

View File

@@ -0,0 +1,7 @@
import useNotificationsStore from '@/stores/notifications/';
import { NotificationRaw } from '@/stores/notifications/types';
export default function notify(notification: NotificationRaw) {
const notificationsStore = useNotificationsStore();
notificationsStore.add(notification);
}

View File

@@ -0,0 +1,4 @@
import NotificationItem from './notification-item.vue';
export { NotificationItem };
export default NotificationItem;

View File

@@ -0,0 +1,99 @@
import withPadding from '../../../../../.storybook/decorators/with-padding';
import { withKnobs, text, select, boolean } from '@storybook/addon-knobs';
import readme from './readme.md';
import { defineComponent } from '@vue/composition-api';
import useNotificationStore from '@/stores/notifications/';
import NotificationItem from './notification-item.vue';
export default {
title: 'Views / Private / Components / Notification Item',
decorators: [withKnobs, withPadding],
parameters: {
notes: readme
}
};
export const basic = () =>
defineComponent({
components: { NotificationItem },
setup() {
useNotificationStore({});
},
template: `
<div>
<notification-item
id="abc"
type="info"
title="This is a notification item"
text="How much fun is that!"
icon="box"
/>
<notification-item
id="def"
type="success"
title="This is a notification item"
text="How much fun is that!"
icon="check"
/>
<notification-item
id="ghi"
type="warning"
title="This is a notification item"
text="This one is persistent!"
icon="warning"
persist
/>
<notification-item
id="jkl"
type="error"
title="This is a notification item"
text="How much fun is that!"
icon="error"
tail
/>
</div>
`
});
export const interactive = () =>
defineComponent({
components: { NotificationItem },
props: {
title: {
default: text('Title', 'This is a notification')
},
text: {
default: text('Body text', '')
},
icon: {
default: text('Icon', 'box')
},
type: {
default: select('Type', ['info', 'success', 'warning', 'error'], 'info')
},
persist: {
default: boolean('Persist', false)
},
dense: {
default: boolean('Dense', false)
},
tail: {
default: boolean('Tail', false)
}
},
template: `
<notification-item
id="not"
:type="type"
:title="title"
:text="text"
:icon="icon"
:persist="persist"
:dense="dense"
:tail="tail"
/>
`
});

View File

@@ -0,0 +1,60 @@
import { mount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import NotificationItem from './notification-item.vue';
import useNotificationsStore from '@/stores/notifications/';
import VIcon from '@/components/v-icon';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-icon', VIcon);
describe('Views / Private / Components / Notification Item', () => {
it('Calls remove with the notification ID automatically', () => {
jest.useFakeTimers();
const notificationsStore = useNotificationsStore();
jest.spyOn(notificationsStore as any, 'remove');
mount(NotificationItem, {
localVue,
propsData: {
id: '123',
title: 'Test'
}
});
jest.runAllTimers();
expect(notificationsStore.remove).toHaveBeenCalledWith('123');
});
it('Does not call remove with id after 1 second when persist is enabled', () => {
jest.useFakeTimers();
const notificationsStore = useNotificationsStore();
jest.spyOn(notificationsStore as any, 'remove');
mount(NotificationItem, {
localVue,
propsData: {
id: '123',
title: 'Test',
persist: true
}
});
jest.runAllTimers();
expect(notificationsStore.remove).not.toHaveBeenCalledWith('123');
});
it('Calls remove with id on close click if persist is enabled', () => {
const notificationsStore = useNotificationsStore();
jest.spyOn(notificationsStore as any, 'remove');
const component = mount(NotificationItem, {
localVue,
propsData: {
id: '123',
title: 'Test',
persist: true
}
});
component.find('.close').trigger('click');
expect(notificationsStore.remove).toHaveBeenCalledWith('123');
});
});

View File

@@ -0,0 +1,192 @@
<template>
<div class="notification-item" :class="[type, { tail, dense }]" @click="close">
<div class="icon" v-if="icon">
<v-icon :name="icon" />
</div>
<div class="content">
<p class="title selectable">{{ title }}</p>
<p v-if="text" class="text selectable">{{ text }}</p>
</div>
<v-icon v-if="persist" name="close" @click="close" class="close" />
</div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import useNotificationsStore from '@/stores/notifications';
export default defineComponent({
props: {
id: {
type: String,
required: true
},
title: {
type: String,
required: true
},
text: {
type: String,
default: null
},
icon: {
type: String,
default: null
},
type: {
type: String,
default: 'info',
validator: (val: string) => ['info', 'success', 'warning', 'error'].includes(val)
},
persist: {
type: Boolean,
default: false
},
tail: {
type: Boolean,
default: false
},
dense: {
type: Boolean,
default: false
}
},
setup(props) {
const notificationsStore = useNotificationsStore();
if (props.persist !== true) {
setTimeout(() => {
close();
}, 3000);
}
return { close };
function close() {
notificationsStore.remove(props.id);
}
}
});
</script>
<style lang="scss" scoped>
.notification-item {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
min-height: 64px;
margin-bottom: 4px;
padding: 12px;
color: var(--white);
border-radius: var(--button-border-radius);
.icon {
display: block;
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-right: 12px;
background-color: rgba(255, 255, 255, 0.25);
border-radius: 50%;
}
.content {
flex-grow: 1;
}
.title {
font-weight: 500;
}
&::after {
position: absolute;
right: 12px;
bottom: -5px;
z-index: -1;
display: block;
width: 20px;
height: 20px;
border-radius: 2px;
transform: rotate(45deg) translate(-5px, -5px);
transition: transform var(--slow) var(--transition);
content: '';
pointer-events: none;
}
&.tail::after {
transform: rotate(45deg) translate(0px, 0px);
}
&.dense {
width: max-content;
max-width: 100%;
min-height: 44px;
.icon {
width: auto;
height: auto;
margin-right: 8px;
background-color: transparent;
}
.text {
display: none;
}
}
&.info {
background-color: var(--accent);
&.tail::after {
background-color: var(--accent);
}
.text {
color: var(--accent-light);
}
}
&.success {
background-color: var(--success);
&.tail::after {
background-color: var(--success);
}
.text {
color: var(--success-light);
}
}
&.warning {
background-color: var(--warning);
&.tail::after {
background-color: var(--warning);
}
.text {
color: var(--warning-light);
}
}
&.error {
background-color: var(--danger);
&.tail::after {
background-color: var(--danger);
}
.text {
color: var(--danger-light);
}
}
}
</style>

View File

@@ -0,0 +1,30 @@
# Notification Item
An individual notification. Shouldn't be used outside of the notification group.
## Usage
```html
<notification-item id="123" title="Hello world!" />
```
## Props
| Prop | Description | Default |
|-----------|--------------------------------------------------------------|---------|
| `id`* | Unique identifier for the notification | -- |
| `title`* | What title to display in the notification | -- |
| `text` | What body text to display in the notification | -- |
| `icon` | Icon to render on the left of the notification | -- |
| `type` | One of `info`, `success`, `warning`, `error` | `info` |
| `persist` | Prevents notification from dissappearing | `false` |
| `tail` | Show a little tail on the bottom right of the bubble | `false` |
| `dense` | Render the notification densely (no body text, less spacing) | `false` |
## Events
n/a
## Slots
n/a
## CSS Variables
n/a

View File

@@ -0,0 +1,4 @@
import NotificationsGroup from './notifications-group.vue';
export { NotificationsGroup };
export default NotificationsGroup;

View File

@@ -0,0 +1,61 @@
import readme from './readme.md';
import { defineComponent } from '@vue/composition-api';
import useNotificationsStore from '@/stores/notifications';
import { NotificationRaw } from '@/stores/notifications/types';
import NotificationsGroup from './notifications-group.vue';
import withPadding from '../../../../../.storybook/decorators/with-padding';
export default {
title: 'Views / Private / Components / Notifications Group',
decorators: [withPadding],
parameters: {
notes: readme
}
};
const demoNotifications: NotificationRaw[] = [
{
title: 'Saved successfully',
icon: 'check',
type: 'success'
},
{
title: 'Whoops...',
text: 'Something went wrong. Please try again later.',
persist: true,
icon: 'error',
type: 'error'
},
{
title: 'Multiple users are editing this item',
text: 'Ben Haynes is currently editing this item.',
type: 'warning',
icon: 'warning'
},
{
title: 'Update available',
text: 'Directus v9 has been released.',
type: 'info'
}
];
export const basic = () =>
defineComponent({
components: { NotificationsGroup },
setup() {
const notificationsStore = useNotificationsStore();
return { add };
function add() {
const randomIndex = Math.floor(Math.random() * demoNotifications.length);
notificationsStore.add(demoNotifications[randomIndex]);
}
},
template: `
<div>
<v-button @click="add">Add notification</v-button>
<notifications-group />
</div>
`
});

View File

@@ -0,0 +1,79 @@
<template>
<transition-group class="notifications-group" name="slide-fade" tag="div">
<notification-item
v-for="(notification, index) in queue"
:key="notification.id"
v-bind="notification"
:tail="index === queue.length - 1"
dense
/>
</transition-group>
</template>
<script lang="ts">
import { defineComponent, toRefs } from '@vue/composition-api';
import useNotificationsStore from '@/stores/notifications';
import NotificationItem from '../notification-item';
export default defineComponent({
components: { NotificationItem },
setup() {
const notificationsStore = useNotificationsStore();
const queue = toRefs(notificationsStore.state).queue;
return { queue };
}
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/breakpoint';
.notifications-group {
position: fixed;
top: 0;
right: 8px;
left: 8px;
z-index: 999;
width: 280px;
direction: rtl;
> * {
direction: ltr;
}
@include breakpoint(medium) {
top: auto;
right: 12px;
bottom: 76px;
left: auto;
}
}
.notification-item {
transition: all 400ms cubic-bezier(0, 0, 0.2, 1.25);
}
.slide-fade-enter-active {
transform: translateX(0px) scaleY(1) scaleX(1);
opacity: 1;
}
.slide-fade-leave-active {
position: absolute;
}
.slide-fade-enter {
transform: translateX(50px) scaleY(0) scaleX(0);
transform-origin: right bottom;
opacity: 0;
}
.slide-fade-leave-to {
transform: translateX(50px) scaleX(0);
transform-origin: right;
opacity: 0;
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
transition-duration: 200ms;
}
</style>

View File

@@ -0,0 +1,21 @@
# Notifications Group
Renders a notification item for every notification in the notificationsStore queue
## Usage
```html
<notifications-group />
```
## Props
n/a
## Events
n/a
## Slots
n/a
## CSS Variables
n/a

View File

@@ -52,6 +52,8 @@
<v-overlay class="nav-overlay" :active="navOpen" @click="navOpen = false" />
<v-overlay class="drawer-overlay" :active="drawerOpen" @click="drawerOpen = false" />
<notifications-group />
</div>
</template>
@@ -62,6 +64,7 @@ import DrawerDetailGroup from './components/drawer-detail-group/';
import HeaderBar from './components/header-bar';
import ProjectChooser from './components/project-chooser';
import DrawerButton from './components/drawer-button/';
import NotificationsGroup from './components/notifications-group/';
export default defineComponent({
components: {
@@ -69,7 +72,8 @@ export default defineComponent({
DrawerDetailGroup,
HeaderBar,
ProjectChooser,
DrawerButton
DrawerButton,
NotificationsGroup
},
props: {
title: {