Activity sidebar (#327)

* Show activity sidebar in detail page

* Add localized format distance util

* Update use-time-from-now composition to use localized dates

* Install marked

* Add activity delta strings

* Show all activity records in sidebar

* Add correct permutations of users

* Make avatar rounded square

* Finish posting comments

* Remove empty test file

* Fix tests
This commit is contained in:
Rijk van Zanten
2020-04-06 15:25:58 -04:00
committed by GitHub
parent 281d2865c7
commit bba4dae46d
23 changed files with 400 additions and 120 deletions

View File

@@ -24,6 +24,7 @@
"axios": "^0.19.2",
"date-fns": "^2.11.1",
"lodash": "^4.17.15",
"marked": "^0.8.2",
"nanoid": "^3.0.2",
"pinia": "0.0.5",
"portal-vue": "^2.1.7",
@@ -47,6 +48,7 @@
"@storybook/core": "^5.3.18",
"@storybook/vue": "^5.3.18",
"@types/jest": "^25.2.1",
"@types/marked": "^0.7.3",
"@typescript-eslint/eslint-plugin": "^2.27.0",
"@typescript-eslint/parser": "^2.27.0",
"@typescript-eslint/typescript-estree": "^2.26.0",

View File

@@ -0,0 +1,271 @@
<template>
<div class="activity-drawer-detail">
<drawer-detail title="Comments" icon="mode_comment">
<form @submit.prevent="postComment">
<v-textarea
:placeholder="$t('leave_comment')"
v-model="newCommentContent"
expand-on-focus
>
<template #append>
<v-button
:disabled="!newCommentContent || newCommentContent.length === 0"
:loading="saving"
class="post-comment"
@click="postComment"
x-small
>
{{ $t('submit') }}
</v-button>
</template>
</v-textarea>
</form>
<transition-group name="slide" tag="div">
<div class="activity-record" v-for="act in activity" :key="act.id">
<div class="activity-header">
<v-avatar small>
<v-icon name="person_outline" />
</v-avatar>
<div class="name">
<template v-if="act.action_by && act.action_by">
{{ act.action_by.first_name }} {{ act.action_by.last_name }}
</template>
<template v-else-if="act.action.by && action.action_by">
{{ $t('private_user') }}
</template>
<template v-else>
{{ $t('external') }}
</template>
</div>
<div class="date" v-tooltip.start="new Date(act.action_on)">
{{ act.date_relative }}
</div>
</div>
<div class="content">
<template v-if="act.comment">
<span v-html="marked(act.comment)" />
</template>
<template v-else-if="act.action === 'create'">
{{ $t('activity_delta_created') }}
</template>
<template v-else-if="act.action === 'update'">
{{ $t('activity_delta_updated') }}
</template>
<template v-else-if="act.action === 'delete'">
{{ $t('activity_delta_deleted') }}
</template>
</div>
</div>
</transition-group>
<div class="activity-record">
<div class="content">{{ $t('activity_delta_created_externally') }}</div>
</div>
</drawer-detail>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
import localizedFormatDistance from '@/utils/localized-format-distance';
import marked from 'marked';
import { Avatar } from '@/stores/user/types';
import notify from '@/utils/notify';
import i18n from '@/lang';
type Activity = {
action: 'create' | 'update' | 'delete' | 'comment';
action_by: null | {
id: number;
first_name: string;
last_name: string;
avatar: null | Avatar;
};
action_on: string;
edited_on: null | string;
comment: null | string;
};
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
primaryKey: {
type: [String, Number],
required: true,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const { activity, loading, error, refresh } = useActivity(
props.collection,
props.primaryKey
);
const { newCommentContent, postComment, saving } = useComment();
return { activity, loading, error, marked, newCommentContent, postComment, saving };
function useActivity(collection: string, primaryKey: string | number) {
const activity = ref<Activity[]>(null);
const error = ref(null);
const loading = ref(false);
getActivity();
return { activity, error, loading, refresh };
async function getActivity() {
error.value = null;
loading.value = true;
try {
const response = await api.get(
`/${projectsStore.state.currentProjectKey}/activity`,
{
params: {
'filter[collection][eq]': collection,
'filter[item][eq]': primaryKey,
sort: '-id', // directus_activity has auto increment and is therefore in chronological order
fields: [
'id',
'action',
'action_on',
'action_by.id',
'action_by.first_name',
'action_by.last_name',
'action_by.avatar.data',
'comment',
],
},
}
);
const records = [];
for (const record of response.data.data) {
records.push({
...record,
date_relative: await localizedFormatDistance(
new Date(record.action_on),
new Date()
),
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
activity.value = records;
} catch (error) {
error.value = error;
} finally {
loading.value = false;
}
}
async function refresh() {
await getActivity();
}
}
function useComment() {
const newCommentContent = ref(null);
const saving = ref(false);
return { newCommentContent, postComment, saving };
async function postComment() {
saving.value = true;
try {
await api.post(`/${projectsStore.state.currentProjectKey}/activity/comment`, {
collection: props.collection,
item: props.primaryKey,
comment: newCommentContent.value,
});
await refresh();
newCommentContent.value = null;
notify({
title: i18n.t('post_comment_success'),
type: 'success',
});
} catch {
notify({
title: i18n.t('post_comment_failed'),
type: 'error',
});
} finally {
saving.value = false;
}
}
}
},
});
</script>
<style lang="scss" scoped>
.post-comment {
position: absolute;
right: 8px;
bottom: 8px;
}
.activity-record {
margin-top: 12px;
padding-top: 8px;
border-top: 2px solid var(--border-normal);
}
.activity-header {
display: flex;
align-items: center;
margin-bottom: 8px;
.v-avatar {
--v-avatar-color: var(--background-normal-alt);
margin-right: 8px;
.v-icon {
--v-icon-color: var(--foreground-subdued);
}
}
.name {
margin-right: 8px;
}
.date {
color: var(--foreground-subdued);
}
}
.content {
font-style: italic;
}
.slide-enter-active,
.slide-leave-active {
transition: all var(--slow) var(--transition);
}
.slide-leave-active {
position: absolute;
}
.slide-move {
transition: all 500ms var(--transition);
}
.slide-enter,
.slide-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,4 @@
import ActivityDrawerDetail from './activity-drawer-detail.vue'
export { ActivityDrawerDetail };
export default ActivityDrawerDetail;

View File

@@ -0,0 +1,9 @@
# Activity Drawer
Renders an activity timeline in a drawer section meant to be used in the drawer sidebar.
## Usage
```html
<activity-drawer-detail collection="authors" primary-key="15" />
```

View File

@@ -43,7 +43,7 @@ export default defineComponent({
white-space: nowrap;
text-overflow: ellipsis;
background-color: var(--v-avatar-color);
border-radius: 50%;
border-radius: var(--border-radius);
&.tile {
border-radius: 0;

View File

@@ -2,10 +2,11 @@
<div class="v-button-group" :class="{ rounded, tile }">
<v-item-group
:value="value"
@input="update"
:mandatory="mandatory"
:max="max"
:multiple="multiple"
scope="button-group"
@input="update"
>
<slot />
</v-item-group>

View File

@@ -106,7 +106,7 @@ export default defineComponent({
const sizeClass = useSizeClass(props);
const component = computed<string>(() => (props.to ? 'router-link' : 'button'));
const { active, toggle } = useGroupable(props.value);
const { active, toggle } = useGroupable(props.value, 'button-group');
return { sizeClass, onClick, component, active, toggle };

View File

@@ -26,6 +26,10 @@ export default defineComponent({
type: Array as PropType<(string | number)[]>,
default: undefined,
},
scope: {
type: String,
default: 'item-group',
},
},
setup(props, { emit }) {
const { value: selection, multiple, max, mandatory } = toRefs(props);
@@ -38,7 +42,8 @@ export default defineComponent({
multiple: multiple,
max: max,
mandatory: mandatory,
}
},
props.scope
);
return {};
},

View File

@@ -5,22 +5,18 @@
disabled,
'expand-on-focus': expandOnFocus,
'full-width': fullWidth,
'has-content': hasContent,
}"
>
<div class="prepend" v-if="$scopedSlots.prepend"><slot name="prepend" /></div>
<textarea
v-bind="$attrs"
v-focus="autofocus"
v-on="_listeners"
:class="{
monospace,
'allow-resize-x': !allowResizeY && allowResizeX,
'allow-resize-y': !allowResizeX && allowResizeY,
'allow-resize-both': allowResizeX && allowResizeY,
}"
:class="{ monospace }"
:disabled="disabled"
:value="value"
/>
<div class="prepend" v-if="$scopedSlots.prepend"><slot name="prepend" /></div>
<div class="append" v-if="$scopedSlots.append"><slot name="append" /></div>
</div>
</template>
@@ -47,7 +43,7 @@ export default defineComponent({
default: false,
},
value: {
type: [String, Number],
type: String,
default: null,
},
expandOnFocus: {
@@ -61,7 +57,9 @@ export default defineComponent({
input: emitValue,
}));
return { _listeners };
const hasContent = computed(() => props.value && props.value.length > 0);
return { _listeners, hasContent };
function emitValue(event: InputEvent) {
emit('input', (event.target as HTMLInputElement).value);
@@ -97,9 +95,21 @@ export default defineComponent({
height: var(--input-height);
transition: height var(--medium) var(--transition);
.append,
.prepend {
opacity: 0;
transition: opacity var(--medium) var(--transition);
}
&:focus,
&:focus-within {
&:focus-within,
&.has-content {
height: var(--v-textarea-max-height);
.append,
.prepend {
opacity: 1;
}
}
}

View File

@@ -0,0 +1,4 @@
import { useTimeFromNow } from './use-time-from-now';
export { useTimeFromNow };
export default useTimeFromNow;

View File

@@ -1,90 +0,0 @@
import { Ref } from '@vue/composition-api';
import useTimeFromNow from './use-time-from-now';
import mountComposition from '../../../.jest/mount-composition';
import mockdate from 'mockdate';
describe('Compositions / Event Listener', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
mockdate.reset();
});
it('Formats the date relative', () => {
const now = new Date();
let timeAgo: Ref<string>;
let timeAhead: Ref<string>;
mountComposition(() => {
timeAgo = useTimeFromNow(new Date(now.getTime() - 5 * 60 * 1000));
timeAhead = useTimeFromNow(new Date(now.getTime() + 5 * 60 * 1000));
});
expect(timeAgo!.value).toBe('5 minutes ago');
expect(timeAhead!.value).toBe('in 5 minutes');
});
it('Updates the ref every minute by default', () => {
mockdate.set('2020-01-01T12:00:00');
const now = new Date();
const component = mountComposition(() => {
const timeAgo = useTimeFromNow(new Date(now.getTime() - 5 * 60 * 1000));
return { timeAgo };
});
expect((component.vm as any).timeAgo).toBe('5 minutes ago');
mockdate.set('2020-01-01T12:01:00');
jest.runTimersToTime(60000);
expect(setInterval).toHaveBeenCalledTimes(1);
expect((component.vm as any).timeAgo).toBe('6 minutes ago');
});
it('Does not automatically update if 0 is passed for autoUpdate param', () => {
mockdate.set('2020-01-01T12:00:00');
const now = new Date();
const component = mountComposition(() => {
const timeAgo = useTimeFromNow(new Date(now.getTime() - 5 * 60 * 1000), 0);
return { timeAgo };
});
expect((component.vm as any).timeAgo).toBe('5 minutes ago');
mockdate.set('2020-01-01T12:01:00');
jest.runTimersToTime(60000);
expect(setInterval).toHaveBeenCalledTimes(0);
expect((component.vm as any).timeAgo).toBe('5 minutes ago');
});
it('Clears the interval when the component is unmounted', () => {
mockdate.set('2020-01-01T12:00:00');
const now = new Date();
const component = mountComposition(() => {
const timeAgo = useTimeFromNow(new Date(now.getTime() - 5 * 60 * 1000));
return { timeAgo };
});
expect((component.vm as any).timeAgo).toBe('5 minutes ago');
mockdate.set('2020-01-01T12:01:00');
jest.runTimersToTime(60000);
expect(setInterval).toHaveBeenCalledTimes(1);
component.destroy();
mockdate.set('2020-01-01T12:01:00');
jest.runTimersToTime(60000);
expect(setInterval).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,19 +1,23 @@
import { onMounted, onUnmounted, ref } from '@vue/composition-api';
import formatDistance from 'date-fns/formatDistance';
import localizedFormatDistance from '@/utils/localized-format-distance/';
export default function useTimeFromNow(date: Date | number, autoUpdate = 60000) {
export async function useTimeFromNow(date: Date | number, autoUpdate = 60000) {
let interval: number;
const formatOptions = {
addSuffix: true,
};
const formattedDate = ref(formatDistance(date, new Date(), formatOptions));
const formattedDate = ref(await localizedFormatDistance(date, new Date(), formatOptions));
if (autoUpdate !== 0) {
onMounted(() => {
interval = setInterval(() => {
formattedDate.value = formatDistance(date, new Date(), formatOptions);
interval = setInterval(async () => {
formattedDate.value = await localizedFormatDistance(
date,
new Date(),
formatOptions
);
}, autoUpdate);
});

View File

@@ -72,10 +72,21 @@
"settings_update_success": "Settings updated",
"settings_update_failed": "Updating settings failed",
"activity_delta_created": "Created this item",
"activity_delta_created_externally": "Created externally",
"activity_delta_updated": "Updated this item",
"activity_delta_deleted": "Deleted this item",
"private_user": "Private User",
"leave_comment": "Leave a comment...",
"post_comment_success": "Comment posted",
"post_comment_failed": "Couldn't post comment",
"submit": "Submit",
"about_directus": "About Directus",
"activity": "Activity",
"activity_log": "Activity Log",
"activity_outside_directus": "Item created outside of Directus",
"add_field_filter": "Add a field filter",
"add_new": "Add New",
"add_note": "Add a helpful note for users...",
@@ -385,7 +396,6 @@
"keep_editing": "Keep Editing",
"latency": "Latency",
"learn_more": "Learn More",
"leave_comment": "Leave a comment...",
"length_disabled_placeholder": "Length is determined by the datatype",
"less_than": "Less than",
"less_than_equal": "Less than or equal to",
@@ -561,7 +571,6 @@
"spacing": "Spacing",
"status": "Status",
"statuses": "Statuses",
"submit": "Submit",
"text": "Text",
"translation": "Translation",
"translated_field_name": "Translated field name...",

View File

@@ -60,6 +60,10 @@
:collection="collection"
v-model="edits"
/>
<template #drawer>
<activity-drawer-detail :collection="collection" :primary-key="primaryKey" />
</template>
</private-view>
</template>
@@ -72,6 +76,7 @@ import { i18n } from '@/lang';
import router from '@/router';
import CollectionsNotFound from '../not-found/';
import useCollection from '@/compositions/use-collection';
import ActivityDrawerDetail from '@/components/activity-drawer-detail';
type Values = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -80,7 +85,7 @@ type Values = {
export default defineComponent({
name: 'collections-detail',
components: { CollectionsNavigation, CollectionsNotFound },
components: { CollectionsNavigation, CollectionsNotFound, ActivityDrawerDetail },
props: {
collection: {
type: String,
@@ -169,12 +174,12 @@ export default defineComponent({
try {
if (isNew.value === true) {
await api.post(
`/${currentProjectKey}/items/${props.collection}`,
`/${currentProjectKey.value}/items/${props.collection}`,
edits.value
);
} else {
await api.patch(
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`,
`/${currentProjectKey.value}/items/${props.collection}/${props.primaryKey}`,
edits.value
);
}
@@ -183,7 +188,7 @@ export default defineComponent({
alert(error);
} finally {
saving.value = true;
router.push(`/${currentProjectKey}/collections/${props.collection}`);
router.push(`/${currentProjectKey.value}/collections/${props.collection}`);
}
}
}

View File

@@ -1,4 +1,4 @@
type Avatar = {
export type Avatar = {
data: {
thumbnails: Thumbnail[];
};

View File

@@ -87,6 +87,10 @@ a {
text-decoration: none;
}
strong {
font-weight: 600;
}
::selection {
background: #e1f0fa;
}

View File

@@ -1,9 +1,13 @@
#tooltip {
$arrow-alignment: 5px;
--tooltip-foreground-color: var(--foreground-inverted);
--tooltip-background-color: var(--background-inverted);
position: absolute;
top: 0;
left: 0;
z-index: 50;
display: none;
max-width: 260px;
padding: 4px 8px;

View File

@@ -0,0 +1,4 @@
import { localizedFormatDistance } from './localized-format-distance';
export { localizedFormatDistance };
export default localizedFormatDistance;

View File

@@ -0,0 +1,24 @@
import formatDistanceOriginal from 'date-fns/formatDistance';
import { i18n } from '@/lang';
type LocalizedFormatDistance = (...a: Parameters<typeof formatDistanceOriginal>) => Promise<string>;
export const localizedFormatDistance: LocalizedFormatDistance = async (
date,
baseDate,
options
): Promise<string> => {
const lang = i18n.locale;
const locale = (
await import(
/* webpackMode: 'lazy', webpackChunkName: 'df-[index]' */
`date-fns/locale/${lang}/index.js`
)
).default;
return formatDistanceOriginal(date, baseDate, {
...options,
locale,
});
};

View File

@@ -1,5 +1,5 @@
<template>
<v-item-group class="drawer-detail-group" v-model="openDetail">
<v-item-group class="drawer-detail-group" v-model="openDetail" scope="drawer-detail">
<slot />
</v-item-group>
</template>

View File

@@ -50,6 +50,6 @@ describe('Drawer Detail', () => {
},
},
});
expect(GroupableComposition.useGroupable).toHaveBeenCalledWith('Users');
expect(GroupableComposition.useGroupable).toHaveBeenCalledWith('Users', 'drawer-detail');
});
});

View File

@@ -34,7 +34,7 @@ export default defineComponent({
},
},
setup(props) {
const { active, toggle } = useGroupable(props.title);
const { active, toggle } = useGroupable(props.title, 'drawer-detail');
const drawerOpen = inject('drawer-open', ref(false));
return { active, toggle, drawerOpen };
},

View File

@@ -1938,6 +1938,11 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.149.tgz#1342d63d948c6062838fbf961012f74d4e638440"
integrity sha512-ijGqzZt/b7BfzcK9vTrS6MFljQRPn5BFWOx8oE0GYxribu6uV+aA9zZuXI1zc/etK9E8nrgdoF2+LgUw7+9tJQ==
"@types/marked@^0.7.3":
version "0.7.3"
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-0.7.3.tgz#3859f6fea52a2b73f42283018bd34b03f3c4fb3f"
integrity sha512-WXdEKuT3azHxLTThd5dwnpLt2Q9QiC8iKj09KZRtVqro3pX8hhY+GbD8FZOae6SBBEJ22yKJn3c7ejL0aucAcA==
"@types/minimatch@*":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
@@ -9541,6 +9546,11 @@ markdown-to-jsx@^6.10.3, markdown-to-jsx@^6.9.1, markdown-to-jsx@^6.9.3:
prop-types "^15.6.2"
unquote "^1.1.0"
marked@^0.8.2:
version "0.8.2"
resolved "https://registry.yarnpkg.com/marked/-/marked-0.8.2.tgz#4faad28d26ede351a7a1aaa5fec67915c869e355"
integrity sha512-EGwzEeCcLniFX51DhTpmTom+dSA/MG/OBUDjnWtHbEnjAH180VzUeAw+oE4+Zv+CoYBWyRlYOTR0N8SO9R1PVw==
material-colors@^1.2.1:
version "1.2.6"
resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.6.tgz#6d1958871126992ceecc72f4bcc4d8f010865f46"