mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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",
|
||||
|
||||
271
src/components/activity-drawer-detail/activity-drawer-detail.vue
Normal file
271
src/components/activity-drawer-detail/activity-drawer-detail.vue
Normal 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>
|
||||
4
src/components/activity-drawer-detail/index.ts
Normal file
4
src/components/activity-drawer-detail/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import ActivityDrawerDetail from './activity-drawer-detail.vue'
|
||||
|
||||
export { ActivityDrawerDetail };
|
||||
export default ActivityDrawerDetail;
|
||||
9
src/components/activity-drawer-detail/readme.md
Normal file
9
src/components/activity-drawer-detail/readme.md
Normal 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" />
|
||||
```
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4
src/compositions/use-time-from-now/index.ts
Normal file
4
src/compositions/use-time-from-now/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { useTimeFromNow } from './use-time-from-now';
|
||||
|
||||
export { useTimeFromNow };
|
||||
export default useTimeFromNow;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
type Avatar = {
|
||||
export type Avatar = {
|
||||
data: {
|
||||
thumbnails: Thumbnail[];
|
||||
};
|
||||
|
||||
@@ -87,6 +87,10 @@ a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #e1f0fa;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
src/utils/localized-format-distance/index.ts
Normal file
4
src/utils/localized-format-distance/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { localizedFormatDistance } from './localized-format-distance';
|
||||
|
||||
export { localizedFormatDistance };
|
||||
export default localizedFormatDistance;
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -50,6 +50,6 @@ describe('Drawer Detail', () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(GroupableComposition.useGroupable).toHaveBeenCalledWith('Users');
|
||||
expect(GroupableComposition.useGroupable).toHaveBeenCalledWith('Users', 'drawer-detail');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user