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:
Rijk van Zanten
2020-04-07 11:33:55 -04:00
committed by GitHub
parent 8d2ea98715
commit 5d633936c0
34 changed files with 822 additions and 291 deletions

View File

@@ -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>

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

@@ -80,7 +80,6 @@ export default defineComponent({
}
.scroll-container {
flex-grow: 1;
overflow-x: hidden;
overflow-y: auto;
}

View File

@@ -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();

View File

@@ -0,0 +1,4 @@
import SaveOptions from './save-options.vue';
export { SaveOptions };
export default SaveOptions;

View File

@@ -0,0 +1,2 @@
# Save Options

View File

@@ -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],
};

View File

@@ -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);
});
});

View 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>