mirror of
https://github.com/directus/directus.git
synced 2026-02-17 23:01:19 -05:00
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: b4458c19f34131d06eAuthor: 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: c93b833d2af2a6dd7fAuthor: 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 commit428e5d4ea9. * 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:
132
app/src/modules/activity/components/navigation.vue
Normal file
132
app/src/modules/activity/components/navigation.vue
Normal 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>
|
||||
27
app/src/modules/activity/index.ts
Normal file
27
app/src/modules/activity/index.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
128
app/src/modules/activity/routes/collection.vue
Normal file
128
app/src/modules/activity/routes/collection.vue
Normal 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>
|
||||
151
app/src/modules/activity/routes/item.vue
Normal file
151
app/src/modules/activity/routes/item.vue
Normal 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>
|
||||
Reference in New Issue
Block a user