mirror of
https://github.com/directus/directus.git
synced 2026-01-27 12:37:59 -05:00
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:
@@ -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>
|
||||
|
||||
4
src/stores/notifications/index.ts
Normal file
4
src/stores/notifications/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useNotificationsStore } from './notifications';
|
||||
|
||||
export { useNotificationsStore };
|
||||
export default useNotificationsStore;
|
||||
54
src/stores/notifications/notifications.test.ts
Normal file
54
src/stores/notifications/notifications.test.ts
Normal 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'
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
26
src/stores/notifications/notifications.ts
Normal file
26
src/stores/notifications/notifications.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
12
src/stores/notifications/types.ts
Normal file
12
src/stores/notifications/types.ts
Normal 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;
|
||||
}
|
||||
4
src/utils/notify/index.ts
Normal file
4
src/utils/notify/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import notify from './notify';
|
||||
|
||||
export { notify };
|
||||
export default notify;
|
||||
23
src/utils/notify/notify.test.ts
Normal file
23
src/utils/notify/notify.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
7
src/utils/notify/notify.ts
Normal file
7
src/utils/notify/notify.ts
Normal 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);
|
||||
}
|
||||
4
src/views/private/components/notification-item/index.ts
Normal file
4
src/views/private/components/notification-item/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import NotificationItem from './notification-item.vue';
|
||||
|
||||
export { NotificationItem };
|
||||
export default NotificationItem;
|
||||
@@ -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"
|
||||
/>
|
||||
`
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
30
src/views/private/components/notification-item/readme.md
Normal file
30
src/views/private/components/notification-item/readme.md
Normal 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
|
||||
@@ -0,0 +1,4 @@
|
||||
import NotificationsGroup from './notifications-group.vue';
|
||||
|
||||
export { NotificationsGroup };
|
||||
export default NotificationsGroup;
|
||||
@@ -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>
|
||||
`
|
||||
});
|
||||
@@ -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>
|
||||
21
src/views/private/components/notifications-group/readme.md
Normal file
21
src/views/private/components/notifications-group/readme.md
Normal 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
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user