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 @@
+
+
+
+
+
+
+
+
{{ title }}
+
{{ text }}
+
+
+
+
+
+
+
+
+
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: {