Add notifications system and support user mentions in comments (#9861)

* v-menu de/activated onKeyDown. No List yet.

* v-list

* add user suggestion

* uuids replaced

* user-popover working

* avatars flex row with usernames in suggestions

* added space to end of uuid insert

* autofocus + move caret to end of last insert

* removed unnecessary setTimeout()

* fixed filter 500 with ids

* better fix

* New translations en-US.yaml (French) (#9907)

* New translations en-US.yaml (French) (#9912)

* New translations en-US.yaml (French) (#9916)

* New translations en-US.yaml (Russian) (#9918)

* New translations en-US.yaml (Swedish) (#9920)

* Email updates (#9921)

* add from name for emails

* updatd email template style

* reset password email copy

* updated logo to newest version

* update invite email copy

* decouple field template logic

* push up styling

* Start on new v-template-input

* Add notifications API endpoints

Squashed commit of the following:

commit 9d86721ef795d03bc55693c0f99bde8e269d60e9
Merge: b4458c19f 34131d06e
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Mon Nov 22 09:27:43 2021 -0500

    Merge branch 'mentions' into mentions-api

commit b4458c19f7c54f18fa415fc04c63642c2f5a17b0
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Nov 18 18:34:04 2021 -0500

    Remove unused import

commit e6a9d36bbfdf95cb18d29336da61ecb14b677934
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Nov 18 18:28:31 2021 -0500

    Extract user mentions from comments

commit b3e571a2daa287e1740a050096913662a57e9861
Merge: c93b833d2 af2a6dd7f
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Nov 18 17:39:52 2021 -0500

    Merge branch 'mentions' into mentions-api

commit c93b833d2b848e306c434b370d4e4e11967e85d0
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Nov 18 17:35:45 2021 -0500

    Send emails w/ parsed MD

commit 64bbd6596f20a07028d2387d60e33dfe4f91c032
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Nov 18 16:18:16 2021 -0500

    Add notifications endpoint + permissions

commit fba55c02dc9c303a38b1b958350684cccd3dd82c
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Nov 18 15:33:28 2021 -0500

    Add system data for notifications

* push

* Make v-template-input work

* Add the two-way binding

* submit button posting, not clearing text area

* comment text area clearing on submit

* Replace insertion correctly

* Added scope support to LDAP group and user search (#9529)

* Added scope support LDAP group and user search

* Fixed linter screwing up my markdown

* Update docs/configuration/config-options.md

* Always return correct DN for user with sub scope

* Fix indeterminate meta and schema property in advanded field creation (#9924)

* Fix impossibility to save M2M (alterations not triggered) (#9992)

* Fix alterations refactor

* fix roles aggregate query (#9994)

* Update iis.md (#9998)

added the IIS URL Rewrite module as a requirement

* New translations en-US.yaml (English, United Kingdom) (#10001)

* Fix LDAP race condition (#9993)

* Fix input ui

* Revert changes to v-field-template

* Update mentions permissions

* Fix linter warnings

* Optimize sending flow

* Revert "Rename activity->notifications module (#9446)"

This reverts commit 428e5d4ea9.

* Add notifications drawer

* Update migrations

* Improve constraints

* Add email notifications toggle on users

* Add docs, fix graphql support

* Move caret-pos to devdeps

* Remove unused new triggerKeyPressed system

* Remove unused use-caret composable

Co-authored-by: Nitwel <nitwel@arcor.de>
Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: Aiden Foxx <aiden.foxx@sbab.se>
Co-authored-by: Oreille <33065839+Oreilles@users.noreply.github.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: Paul Boudewijn <paul@helderinternet.nl>
This commit is contained in:
Jay Cammarano
2021-11-24 16:11:26 -05:00
committed by GitHub
parent f19a549a1b
commit 25375cc481
49 changed files with 3162 additions and 2531 deletions

View File

@@ -0,0 +1,132 @@
<template>
<v-list nav>
<v-list-item clickable :active="!filterField" @click="clearNavFilter">
<v-list-item-icon>
<v-icon name="access_time" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('all_activity')" />
</v-list-item-content>
</v-list-item>
<v-list-item
clickable
:active="filterField === 'user' && filterValue === currentUserID"
@click="setNavFilter('user', currentUserID)"
>
<v-list-item-icon>
<v-icon name="face" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('my_activity')" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
clickable
:active="filterField === 'action' && filterValue === 'create'"
@click="setNavFilter('action', 'create')"
>
<v-list-item-icon>
<v-icon name="add" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('create')" />
</v-list-item-content>
</v-list-item>
<v-list-item
clickable
:active="filterField === 'action' && filterValue === 'update'"
@click="setNavFilter('action', 'update')"
>
<v-list-item-icon>
<v-icon name="check" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('update')" />
</v-list-item-content>
</v-list-item>
<v-list-item
clickable
:active="filterField === 'action' && filterValue === 'delete'"
@click="setNavFilter('action', 'delete')"
>
<v-list-item-icon>
<v-icon name="clear" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('delete_label')" />
</v-list-item-content>
</v-list-item>
<v-list-item
clickable
:active="filterField === 'action' && filterValue === 'comment'"
@click="setNavFilter('action', 'comment')"
>
<v-list-item-icon>
<v-icon name="chat_bubble_outline" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('comment')" />
</v-list-item-content>
</v-list-item>
<v-list-item
clickable
:active="filterField === 'action' && filterValue === 'login'"
@click="setNavFilter('action', 'login')"
>
<v-list-item-icon>
<v-icon name="login" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('login')" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, PropType } from 'vue';
import { useUserStore } from '@/stores/user';
import { Filter } from '@directus/shared/types';
export default defineComponent({
props: {
filter: {
type: Object as PropType<Filter>,
default: null,
},
},
emits: ['update:filter'],
setup(props, { emit }) {
const { t } = useI18n();
const userStore = useUserStore();
const currentUserID = computed(() => userStore.currentUser?.id);
const filterField = computed(() => Object.keys(props.filter ?? {})[0] ?? null);
const filterValue = computed(() => Object.values(props.filter ?? {})[0]?._eq ?? null);
return { t, currentUserID, setNavFilter, clearNavFilter, filterField, filterValue };
function setNavFilter(key: string, value: any) {
emit('update:filter', {
[key]: {
_eq: value,
},
});
}
function clearNavFilter() {
emit('update:filter', null);
}
},
});
</script>

View File

@@ -0,0 +1,27 @@
import { defineModule } from '@directus/shared/utils';
import ActivityCollection from './routes/collection.vue';
import ActivityItem from './routes/item.vue';
export default defineModule({
id: 'activity',
hidden: true,
name: '$t:activity',
icon: 'notifications',
routes: [
{
name: 'activity-collection',
path: '',
component: ActivityCollection,
props: true,
children: [
{
name: 'activity-item',
path: ':primaryKey',
components: {
detail: ActivityItem,
},
},
],
},
],
});

View File

@@ -0,0 +1,128 @@
<template>
<component
:is="layoutWrapper"
v-slot="{ layoutState }"
v-model:layout-options="layoutOptions"
v-model:layout-query="layoutQuery"
:filter="mergeFilters(filter, roleFilter)"
:filter-user="filter"
:filter-system="roleFilter"
:search="search"
collection="directus_activity"
>
<private-view :title="t('activity_feed')">
<template #title-outer:prepend>
<v-button class="header-icon" rounded disabled icon secondary>
<v-icon name="access_time" />
</v-button>
</template>
<template #actions:prepend>
<component :is="`layout-actions-${layout}`" v-bind="layoutState" />
</template>
<template #actions>
<search-input v-model="search" v-model:filter="filter" collection="directus_activity" />
</template>
<template #navigation>
<activity-navigation v-model:filter="roleFilter" />
</template>
<component :is="`layout-${layout}`" v-bind="layoutState" class="layout">
<template #no-results>
<v-info :title="t('no_results')" icon="search" center>
{{ t('no_results_copy') }}
</v-info>
</template>
<template #no-items>
<v-info :title="t('item_count', 0)" icon="access_time" center>
{{ t('no_items_copy') }}
</v-info>
</template>
</component>
<router-view name="detail" :primary-key="primaryKey" />
<template #sidebar>
<sidebar-detail icon="info_outline" :title="t('information')" close>
<div v-md="t('page_help_activity_collection')" class="page-description" />
</sidebar-detail>
<layout-sidebar-detail v-model="layout">
<component :is="`layout-options-${layout}`" v-bind="layoutState" />
</layout-sidebar-detail>
<component :is="`layout-sidebar-${layout}`" v-bind="layoutState" />
</template>
</private-view>
</component>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, ref } from 'vue';
import ActivityNavigation from '../components/navigation.vue';
import usePreset from '@/composables/use-preset';
import { useLayout } from '@/composables/use-layout';
import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detail';
import SearchInput from '@/views/private/components/search-input';
import { Filter } from '@directus/shared/types';
import { mergeFilters } from '@directus/shared/utils';
export default defineComponent({
name: 'ActivityCollection',
components: { ActivityNavigation, LayoutSidebarDetail, SearchInput },
props: {
primaryKey: {
type: String,
default: null,
},
},
setup() {
const { t } = useI18n();
const { layout, layoutOptions, layoutQuery, filter, search } = usePreset(ref('directus_activity'));
const { breadcrumb } = useBreadcrumb();
const { layoutWrapper } = useLayout(layout);
const roleFilter = ref<Filter | null>(null);
return {
t,
breadcrumb,
layout,
layoutWrapper,
layoutOptions,
layoutQuery,
search,
filter,
roleFilter,
mergeFilters,
};
function useBreadcrumb() {
const breadcrumb = computed(() => {
return [
{
name: t('collection', 2),
to: `/content`,
},
];
});
return { breadcrumb };
}
},
});
</script>
<style lang="scss" scoped>
.content {
padding: var(--content-padding);
}
.header-icon {
--v-button-color-disabled: var(--foreground-normal);
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<v-drawer :model-value="isOpen" :title="t('activity_item')" @update:model-value="close" @cancel="close">
<v-progress-circular v-if="loading" indeterminate />
<div v-else-if="error" class="content">
<v-notice type="danger">
{{ error }}
</v-notice>
</div>
<div v-else class="content">
<!-- @TODO add final design -->
<p class="type-label">{{ t('user') }}:</p>
<user-popover v-if="item.user" :user="item.user.id">
{{ userName(item.user) }}
</user-popover>
<p class="type-label">{{ t('action') }}:</p>
<p>{{ item.action }}</p>
<p class="type-label">{{ t('date') }}:</p>
<p>{{ item.timestamp }}</p>
<p class="type-label">{{ t('ip_address') }}:</p>
<p>{{ item.ip }}</p>
<p class="type-label">{{ t('user_agent') }}:</p>
<p>{{ item.user_agent }}</p>
<p class="type-label">{{ t('collection') }}:</p>
<p>{{ item.collection }}</p>
<p class="type-label">{{ t('item') }}:</p>
<p>{{ item.item }}</p>
</div>
<template #actions>
<v-button v-if="openItemLink" v-tooltip.bottom="t('open')" :to="openItemLink" icon rounded>
<v-icon name="launch" />
</v-button>
<v-button v-tooltip.bottom="t('done')" to="/activity" icon rounded>
<v-icon name="check" />
</v-button>
</template>
</v-drawer>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { i18n } from '@/lang';
import { defineComponent, computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import api from '@/api';
import { userName } from '@/utils/user-name';
import { useDialogRoute } from '@/composables/use-dialog-route';
type ActivityRecord = {
user: {
email: string;
first_name: string;
last_name: string;
} | null;
action: string;
timestamp: string;
ip: string;
user_agent: string;
collection: string;
item: string;
};
export default defineComponent({
name: 'ActivityDetail',
props: {
primaryKey: {
type: String,
required: true,
},
},
setup(props) {
const { t, te } = useI18n();
const router = useRouter();
const isOpen = useDialogRoute();
const item = ref<ActivityRecord>();
const loading = ref(false);
const error = ref<any>(null);
const openItemLink = computed(() => {
if (!item.value || item.value.collection.startsWith('directus_')) return;
return `/content/${item.value.collection}/${encodeURIComponent(item.value.item)}`;
});
watch(() => props.primaryKey, loadActivity, { immediate: true });
return { t, isOpen, item, loading, error, close, openItemLink, userName };
async function loadActivity() {
loading.value = true;
try {
const response = await api.get(`/activity/${props.primaryKey}`, {
params: {
fields: [
'user.id',
'user.email',
'user.first_name',
'user.last_name',
'action',
'timestamp',
'ip',
'user_agent',
'collection',
'item',
],
},
});
item.value = response.data.data;
if (item.value) {
if (te(`field_options.directus_activity.${item.value.action}`))
item.value.action = t(`field_options.directus_activity.${item.value.action}`);
item.value.timestamp = new Date(item.value.timestamp).toLocaleString(i18n.global.locale.value);
}
} catch (err: any) {
error.value = err;
} finally {
loading.value = false;
}
}
function close() {
router.push('/activity');
}
},
});
</script>
<style lang="scss" scoped>
.type-label:not(:first-child) {
margin-top: 24px;
}
.content {
padding: var(--content-padding);
padding-top: 0;
padding-bottom: var(--content-padding);
}
</style>