diff --git a/src/app.vue b/src/app.vue index e7695f0897..949c22e30b 100644 --- a/src/app.vue +++ b/src/app.vue @@ -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; } diff --git a/src/stores/notifications/index.ts b/src/stores/notifications/index.ts new file mode 100644 index 0000000000..53e42cfce9 --- /dev/null +++ b/src/stores/notifications/index.ts @@ -0,0 +1,4 @@ +import { useNotificationsStore } from './notifications'; + +export { useNotificationsStore }; +export default useNotificationsStore; diff --git a/src/stores/notifications/notifications.test.ts b/src/stores/notifications/notifications.test.ts new file mode 100644 index 0000000000..e03f41cff6 --- /dev/null +++ b/src/stores/notifications/notifications.test.ts @@ -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' + } + ]); + }); + }); +}); diff --git a/src/stores/notifications/notifications.ts b/src/stores/notifications/notifications.ts new file mode 100644 index 0000000000..18d052103c --- /dev/null +++ b/src/stores/notifications/notifications.ts @@ -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); + } + } +}); diff --git a/src/stores/notifications/types.ts b/src/stores/notifications/types.ts new file mode 100644 index 0000000000..a953d0fb07 --- /dev/null +++ b/src/stores/notifications/types.ts @@ -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; +} diff --git a/src/utils/notify/index.ts b/src/utils/notify/index.ts new file mode 100644 index 0000000000..c51bf4e5a9 --- /dev/null +++ b/src/utils/notify/index.ts @@ -0,0 +1,4 @@ +import notify from './notify'; + +export { notify }; +export default notify; diff --git a/src/utils/notify/notify.test.ts b/src/utils/notify/notify.test.ts new file mode 100644 index 0000000000..c4e3702504 --- /dev/null +++ b/src/utils/notify/notify.test.ts @@ -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); + }); +}); diff --git a/src/utils/notify/notify.ts b/src/utils/notify/notify.ts new file mode 100644 index 0000000000..4376c49f3e --- /dev/null +++ b/src/utils/notify/notify.ts @@ -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); +} diff --git a/src/views/private/components/notification-item/index.ts b/src/views/private/components/notification-item/index.ts new file mode 100644 index 0000000000..6934d385cf --- /dev/null +++ b/src/views/private/components/notification-item/index.ts @@ -0,0 +1,4 @@ +import NotificationItem from './notification-item.vue'; + +export { NotificationItem }; +export default NotificationItem; diff --git a/src/views/private/components/notification-item/notification-item.story.ts b/src/views/private/components/notification-item/notification-item.story.ts new file mode 100644 index 0000000000..a9e7e65256 --- /dev/null +++ b/src/views/private/components/notification-item/notification-item.story.ts @@ -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: ` +
+ + + + + + + +
+ ` + }); + +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: ` + + ` + }); diff --git a/src/views/private/components/notification-item/notification-item.test.ts b/src/views/private/components/notification-item/notification-item.test.ts new file mode 100644 index 0000000000..1a23c9495b --- /dev/null +++ b/src/views/private/components/notification-item/notification-item.test.ts @@ -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'); + }); +}); diff --git a/src/views/private/components/notification-item/notification-item.vue b/src/views/private/components/notification-item/notification-item.vue new file mode 100644 index 0000000000..b519ba9553 --- /dev/null +++ b/src/views/private/components/notification-item/notification-item.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/src/views/private/components/notification-item/readme.md b/src/views/private/components/notification-item/readme.md new file mode 100644 index 0000000000..f2a08e1359 --- /dev/null +++ b/src/views/private/components/notification-item/readme.md @@ -0,0 +1,30 @@ +# Notification Item + +An individual notification. Shouldn't be used outside of the notification group. + +## Usage + +```html + +``` + +## 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 diff --git a/src/views/private/components/notifications-group/index.ts b/src/views/private/components/notifications-group/index.ts new file mode 100644 index 0000000000..97b0894708 --- /dev/null +++ b/src/views/private/components/notifications-group/index.ts @@ -0,0 +1,4 @@ +import NotificationsGroup from './notifications-group.vue'; + +export { NotificationsGroup }; +export default NotificationsGroup; diff --git a/src/views/private/components/notifications-group/notifications-group.story.ts b/src/views/private/components/notifications-group/notifications-group.story.ts new file mode 100644 index 0000000000..72c8ad09b4 --- /dev/null +++ b/src/views/private/components/notifications-group/notifications-group.story.ts @@ -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: ` +
+ Add notification + +
+ ` + }); diff --git a/src/views/private/components/notifications-group/notifications-group.vue b/src/views/private/components/notifications-group/notifications-group.vue new file mode 100644 index 0000000000..1d5bb361d9 --- /dev/null +++ b/src/views/private/components/notifications-group/notifications-group.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/src/views/private/components/notifications-group/readme.md b/src/views/private/components/notifications-group/readme.md new file mode 100644 index 0000000000..dac58bfed0 --- /dev/null +++ b/src/views/private/components/notifications-group/readme.md @@ -0,0 +1,21 @@ +# Notifications Group + +Renders a notification item for every notification in the notificationsStore queue + +## Usage + +```html + +``` + +## Props +n/a + +## Events +n/a + +## Slots +n/a + +## CSS Variables +n/a diff --git a/src/views/private/private-view.vue b/src/views/private/private-view.vue index 6501072758..daad6d68a3 100644 --- a/src/views/private/private-view.vue +++ b/src/views/private/private-view.vue @@ -52,6 +52,8 @@ + + @@ -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: {