mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
Rework save options to be re-usable across modules (#346)
* Add translateShortcut util * Add prepend/append slots to v-button * Reduce default list item height + listen to parent dense * Refactor save/delete logic into composition * Tweak popper positioning * Add v-list-item-hint component * Reset state on primary key change to + * Tweak save-and-x translations * Add and use save-options component * Move activity drawer detail to views folder * Prevent unnecessary overflow when popper is inactive * Revert spacing change in popper * Move comments translation up * Use translated title for section * Dont grow full height by default * Only show comments when you're not creating a new item * Add notifications to use-item composition * Add saveAsCopy function to useItem composition * Use ref for parameter in useCollection * Fix tests * Fix codesmells
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<drawer-detail :title="$t('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>
|
||||
</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>
|
||||
@@ -0,0 +1,4 @@
|
||||
import ActivityDrawerDetail from './activity-drawer-detail.vue';
|
||||
|
||||
export { ActivityDrawerDetail };
|
||||
export default ActivityDrawerDetail;
|
||||
@@ -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" />
|
||||
```
|
||||
@@ -80,7 +80,6 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
flex-grow: 1;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -30,12 +30,12 @@ describe('Views / Private / Header Bar', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const navToggle = component.find('.nav-toggle');
|
||||
const navToggle = component.find('.nav-toggle > .button');
|
||||
navToggle.trigger('click');
|
||||
|
||||
expect(component.emitted('toggle:nav')?.[0]).toBeTruthy();
|
||||
|
||||
const drawerToggle = component.find('.drawer-toggle');
|
||||
const drawerToggle = component.find('.drawer-toggle > .button');
|
||||
drawerToggle.trigger('click');
|
||||
|
||||
expect(component.emitted('toggle:drawer')?.[0]).toBeTruthy();
|
||||
|
||||
4
src/views/private/components/save-options/index.ts
Normal file
4
src/views/private/components/save-options/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import SaveOptions from './save-options.vue';
|
||||
|
||||
export { SaveOptions };
|
||||
export default SaveOptions;
|
||||
2
src/views/private/components/save-options/readme.md
Normal file
2
src/views/private/components/save-options/readme.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Save Options
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import withPadding from '../../../../../.storybook/decorators/with-padding';
|
||||
import readme from './readme.md';
|
||||
|
||||
export default {
|
||||
title: 'Views / Private / Components / Save Options',
|
||||
parameters: {
|
||||
notes: readme,
|
||||
},
|
||||
decorators: [withPadding],
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import SaveOptions from './save-options.vue';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import { i18n } from '@/lang';
|
||||
|
||||
import VList, {
|
||||
VListItem,
|
||||
VListItemIcon,
|
||||
VListItemContent,
|
||||
VListItemHint,
|
||||
} from '@/components/v-list/';
|
||||
import VIcon from '@/components/v-icon';
|
||||
import VMenu from '@/components/v-menu';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-list', VList);
|
||||
localVue.component('v-list-item', VListItem);
|
||||
localVue.component('v-list-item-icon', VListItemIcon);
|
||||
localVue.component('v-list-item-content', VListItemContent);
|
||||
localVue.component('v-list-item-hint', VListItemHint);
|
||||
localVue.component('v-icon', VIcon);
|
||||
localVue.component('v-menu', VMenu);
|
||||
|
||||
describe('Views / Private / Components / Save Options', () => {
|
||||
it('Renders', () => {
|
||||
const component = shallowMount(SaveOptions, {
|
||||
localVue,
|
||||
i18n,
|
||||
});
|
||||
|
||||
expect(component.isVueInstance()).toBe(true);
|
||||
});
|
||||
});
|
||||
52
src/views/private/components/save-options/save-options.vue
Normal file
52
src/views/private/components/save-options/save-options.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<v-menu show-arrow :disabled="disabled">
|
||||
<template #activator="{ toggle }">
|
||||
<v-icon :class="{ disabled }" name="more_vert" @click="toggle" />
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<v-list-item :disabled="disabled" @click="$emit('save-and-stay')">
|
||||
<v-list-item-icon><v-icon name="check" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ $t('save_and_stay') }}</v-list-item-content>
|
||||
<v-list-item-hint>{{ translateShortcut(['meta', 's']) }}</v-list-item-hint>
|
||||
</v-list-item>
|
||||
<v-list-item :disabled="disabled" @click="$emit('save-and-add-new')">
|
||||
<v-list-item-icon><v-icon name="add" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ $t('save_and_add_new') }}</v-list-item-content>
|
||||
<v-list-item-hint>{{ translateShortcut(['meta', 'shift', 's']) }}</v-list-item-hint>
|
||||
</v-list-item>
|
||||
<v-list-item :disabled="disabled" @click="$emit('save-as-copy')">
|
||||
<v-list-item-icon><v-icon name="done_all" /></v-list-item-icon>
|
||||
<v-list-item-content>{{ $t('save_as_copy') }}</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from '@vue/composition-api';
|
||||
import translateShortcut from '@/utils/translate-shortcut';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { translateShortcut };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-icon.disabled {
|
||||
color: var(--foreground-subdued);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.v-list-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user