Bookmark improvements (#12031)

* add icon & color to bookmarks

* update current bookmark title on edit

* clean up edit bookmark dialog on cancel

* remove unused bookmark-edit component

* interaction improvements

* sort based on scope and alphabetically

* prevent hover when locked & use tooltip

* Reduce size of right hand icon in bookmark

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Azri Kahar
2022-03-28 23:23:00 +08:00
committed by GitHub
parent a58f09114d
commit 31cfb8266f
12 changed files with 222 additions and 144 deletions

View File

@@ -0,0 +1,15 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_presets', (table) => {
table.string('icon', 30).notNullable().defaultTo('bookmark_outline');
table.string('color').nullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_presets', (table) => {
table.dropColumn('icon');
table.dropColumn('color');
});
}

View File

@@ -33,6 +33,12 @@ fields:
- field: bookmark
width: half
- field: icon
width: half
- field: color
width: half
- field: search
width: half

View File

@@ -90,6 +90,11 @@ export function usePreset(
initLocalPreset();
});
// update current bookmark title when it is edited in navigation-bookmark
presetsStore.$subscribe(() => {
initLocalPreset();
});
const layoutOptions = computed<Record<string, any>>({
get() {
return localPreset.value.layout_options?.[layout.value] || null;

View File

@@ -92,11 +92,13 @@ delete_folder: Delete Folder
prefix: Prefix
suffix: Suffix
reset_bookmark: Reset Bookmark
rename_bookmark: Rename Bookmark
update_bookmark: Update Bookmark
delete_bookmark: Delete Bookmark
delete_bookmark_copy: >-
Are you sure you want to delete the "{bookmark}" bookmark? This action cannot be undone.
delete_personal_bookmark: Delete Personal Bookmark
delete_role_bookmark: Delete Role Bookmark
delete_global_bookmark: Delete Global Bookmark
logoutReason:
SIGN_OUT: Signed out
SESSION_EXPIRED: Session expired
@@ -569,6 +571,11 @@ value_hashed: Value Securely Hashed
bookmark_name: Bookmark name...
create_bookmark: Create Bookmark
edit_bookmark: Edit Bookmark
edit_personal_bookmark: Edit Personal Bookmark
edit_role_bookmark: Edit Role Bookmark
edit_global_bookmark: Edit Global Bookmark
cannot_edit_role_bookmarks: Can't Edit Role Bookmarks
cannot_edit_global_bookmarks: Can't Edit Global Bookmarks
bookmarks: Bookmarks
presets: Presets
unexpected_error: Unexpected Error

View File

@@ -1,46 +1,64 @@
<template>
<v-list-item
v-context-menu="'contextMenu'"
:to="`/content/${bookmark.collection}?bookmark=${bookmark.id}`"
query
class="bookmark"
clickable
@contextmenu.stop=""
>
<v-list-item-icon><v-icon name="bookmark_outline" /></v-list-item-icon>
<v-list-item-icon><v-icon :name="bookmark.icon" :color="bookmark.color" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="bookmark.bookmark" />
</v-list-item-content>
<v-menu ref="contextMenu" show-arrow placement="bottom-start">
<v-menu placement="bottom-start" show-arrow>
<template #activator="{ toggle }">
<v-icon
v-tooltip.bottom="!hasPermission && t(`cannot_edit_${scope}_bookmarks`)"
:name="hasPermission ? 'more_vert' : 'lock'"
:clickable="hasPermission"
small
class="ctx-toggle"
@click.prevent="hasPermission ? toggle() : null"
/>
</template>
<v-list>
<v-list-item clickable :disabled="isMine === false" @click="renameActive = true">
<v-list-item
clickable
:to="scope !== 'personal' ? `/settings/presets/${bookmark.id}` : undefined"
@click="scope === 'personal' ? (editActive = true) : undefined"
>
<v-list-item-icon>
<v-icon name="edit" outline />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('rename_bookmark')" />
<v-text-overflow :text="t(`edit_${scope}_bookmark`)" />
</v-list-item-content>
</v-list-item>
<v-list-item clickable class="danger" :disabled="isMine === false" @click="deleteActive = true">
<v-list-item clickable class="danger" @click="deleteActive = true">
<v-list-item-icon>
<v-icon name="delete" outline />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('delete_bookmark')" />
<v-text-overflow :text="t(`delete_${scope}_bookmark`)" />
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="renameActive" persistent @esc="renameActive = false">
<v-dialog v-model="editActive" persistent @esc="editCancel">
<v-card>
<v-card-title>{{ t('rename_bookmark') }}</v-card-title>
<v-card-title>{{ t('edit_personal_bookmark') }}</v-card-title>
<v-card-text>
<v-input v-model="renameValue" autofocus @keyup.enter="renameSave" />
<div class="fields">
<v-input v-model="editValue.name" class="full" autofocus @keyup.enter="editSave" />
<interface-select-icon width="half" :value="editValue.icon" @input="editValue.icon = $event" />
<interface-select-color width="half" :value="editValue.color" @input="editValue.color = $event" />
</div>
</v-card-text>
<v-card-actions>
<v-button secondary @click="renameActive = false">{{ t('cancel') }}</v-button>
<v-button :disabled="renameValue === null" :loading="renameSaving" @click="renameSave">
<v-button secondary @click="editCancel">{{ t('cancel') }}</v-button>
<v-button :disabled="editValue.name === null" :loading="editSaving" @click="editSave">
{{ t('save') }}
</v-button>
</v-card-actions>
@@ -63,7 +81,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, ref, computed } from 'vue';
import { defineComponent, PropType, ref, computed, reactive } from 'vue';
import { Preset } from '@directus/shared/types';
import { useUserStore, usePresetsStore } from '@/stores';
import { unexpectedError } from '@/utils/unexpected-error';
@@ -82,58 +100,80 @@ export default defineComponent({
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const { currentUser, isAdmin } = useUserStore();
const presetsStore = usePresetsStore();
const isMine = computed(() => props.bookmark.user === userStore.currentUser!.id);
const isMine = computed(() => props.bookmark.user === currentUser!.id);
const { renameActive, renameValue, renameSave, renameSaving } = useRenameBookmark();
const { deleteActive, deleteValue, deleteSave, deleteSaving } = useDeleteBookmark();
const hasPermission = computed(() => isMine.value || isAdmin);
const scope = computed(() => {
if (props.bookmark.user && !props.bookmark.role) return 'personal';
if (!props.bookmark.user && props.bookmark.role) return 'role';
return 'global';
});
const { editActive, editValue, editSave, editSaving, editCancel } = useEditBookmark();
const { deleteActive, deleteSave, deleteSaving } = useDeleteBookmark();
return {
t,
isMine,
renameActive,
renameValue,
renameSave,
renameSaving,
hasPermission,
scope,
editActive,
editValue,
editSave,
editSaving,
editCancel,
deleteActive,
deleteValue,
deleteSave,
deleteSaving,
};
function useRenameBookmark() {
const renameActive = ref(false);
const renameValue = ref(props.bookmark.bookmark);
const renameSaving = ref(false);
function useEditBookmark() {
const editActive = ref(false);
const editValue = reactive({
name: props.bookmark.bookmark,
icon: props.bookmark?.icon ?? 'bookmark_outline',
color: props.bookmark?.color ?? null,
});
const editSaving = ref(false);
return { renameActive, renameValue, renameSave, renameSaving };
return { editActive, editValue, editSave, editSaving, editCancel };
async function renameSave() {
renameSaving.value = true;
async function editSave() {
editSaving.value = true;
try {
await presetsStore.savePreset({
...props.bookmark,
bookmark: renameValue.value,
bookmark: editValue.name,
icon: editValue.icon,
color: editValue.color,
});
renameActive.value = false;
editActive.value = false;
} catch (err: any) {
unexpectedError(err);
} finally {
renameSaving.value = false;
editSaving.value = false;
}
}
function editCancel() {
editActive.value = false;
editValue.name = props.bookmark.bookmark;
editValue.icon = props.bookmark?.icon ?? 'bookmark_outline';
editValue.color = props.bookmark?.color ?? null;
}
}
function useDeleteBookmark() {
const deleteActive = ref(false);
const deleteValue = ref(props.bookmark.bookmark);
const deleteSaving = ref(false);
return { deleteActive, deleteValue, deleteSave, deleteSaving };
return { deleteActive, deleteSave, deleteSaving };
async function deleteSave() {
deleteSaving.value = true;
@@ -141,7 +181,7 @@ export default defineComponent({
try {
let navigateTo: string | null = null;
if (+route.query?.bookmark === props.bookmark.id) {
if (route.query?.bookmark && +route.query.bookmark === props.bookmark.id) {
navigateTo = `/content/${props.bookmark.collection}`;
}
@@ -167,4 +207,31 @@ export default defineComponent({
--v-list-item-color: var(--danger);
--v-list-item-icon-color: var(--danger);
}
.v-list-item {
.ctx-toggle {
--v-icon-color: var(--foreground-subdued);
opacity: 0;
user-select: none;
transition: opacity var(--fast) var(--transition);
}
&:hover {
.ctx-toggle {
opacity: 1;
user-select: auto;
}
}
}
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.full {
grid-column: 1 / span 2;
}
}
</style>

View File

@@ -282,7 +282,6 @@ import RefreshSidebarDetail from '@/views/private/components/refresh-sidebar-det
import ExportSidebarDetail from '@/views/private/components/export-sidebar-detail.vue';
import SearchInput from '@/views/private/components/search-input';
import BookmarkAdd from '@/views/private/components/bookmark-add';
import BookmarkEdit from '@/views/private/components/bookmark-edit';
import { useRouter } from 'vue-router';
import { usePermissionsStore, useUserStore } from '@/stores';
import DrawerBatch from '@/views/private/components/drawer-batch';
@@ -303,7 +302,6 @@ export default defineComponent({
LayoutSidebarDetail,
SearchInput,
BookmarkAdd,
BookmarkEdit,
DrawerBatch,
ArchiveSidebarDetail,
RefreshSidebarDetail,
@@ -372,7 +370,7 @@ export default defineComponent({
batchEditActive,
} = useBatch();
const { bookmarkDialogActive, creatingBookmark, createBookmark, editingBookmark, editBookmark } = useBookmarks();
const { bookmarkDialogActive, creatingBookmark, createBookmark } = useBookmarks();
const currentLayout = computed(() => layouts.value.find((l) => l.id === layout.value));
@@ -447,8 +445,6 @@ export default defineComponent({
creatingBookmark,
createBookmark,
bookmarkTitle,
editingBookmark,
editBookmark,
breadcrumb,
clearFilters,
confirmArchive,
@@ -583,21 +579,22 @@ export default defineComponent({
function useBookmarks() {
const bookmarkDialogActive = ref(false);
const creatingBookmark = ref(false);
const editingBookmark = ref(false);
return {
bookmarkDialogActive,
creatingBookmark,
createBookmark,
editingBookmark,
editBookmark,
};
async function createBookmark(name: string) {
async function createBookmark(bookmark: any) {
creatingBookmark.value = true;
try {
const newBookmark = await saveCurrentAsBookmark({ bookmark: name });
const newBookmark = await saveCurrentAsBookmark({
bookmark: bookmark.name,
icon: bookmark.icon,
color: bookmark.color,
});
router.push(`/content/${newBookmark.collection}?bookmark=${newBookmark.id}`);
bookmarkDialogActive.value = false;
@@ -607,11 +604,6 @@ export default defineComponent({
creatingBookmark.value = false;
}
}
async function editBookmark(name: string) {
bookmarkTitle.value = name;
bookmarkDialogActive.value = false;
}
}
function clearFilters() {

View File

@@ -242,6 +242,8 @@ export default defineComponent({
if (edits.value.name) editsParsed.bookmark = edits.value.name;
if (edits.value.name?.length === 0) editsParsed.bookmark = null;
if (edits.value.icon) editsParsed.icon = edits.value.icon;
if (edits.value.color) editsParsed.color = edits.value.color;
if (edits.value.collection) editsParsed.collection = edits.value.collection;
if (edits.value.layout) editsParsed.layout = edits.value.layout;
if (edits.value.layout_query) editsParsed.layout_query = edits.value.layout_query;
@@ -332,6 +334,8 @@ export default defineComponent({
collection: preset.value.collection,
layout: preset.value.layout,
name: preset.value.bookmark,
icon: preset.value.icon,
color: preset.value.color,
search: preset.value.search,
scope: scope,
layout_query: preset.value.layout_query,
@@ -517,6 +521,27 @@ export default defineComponent({
},
},
},
{
field: 'icon',
name: '$t:icon',
type: 'string',
meta: {
interface: 'select-icon',
width: 'half',
},
schema: {
default_value: 'bookmark_outline',
},
},
{
field: 'color',
name: '$t:color',
type: 'string',
meta: {
interface: 'select-color',
width: 'half',
},
},
{
field: 'search',
name: t('search'),

View File

@@ -1,7 +1,7 @@
import api from '@/api';
import { useUserStore } from '@/stores/';
import { Preset } from '@directus/shared/types';
import { cloneDeep, merge } from 'lodash';
import { cloneDeep, merge, orderBy } from 'lodash';
import { nanoid } from 'nanoid';
import { defineStore } from 'pinia';
@@ -122,7 +122,14 @@ export const usePresetsStore = defineStore({
}),
getters: {
bookmarks(): Preset[] {
return this.collectionPresets.filter((preset) => preset.bookmark !== null);
return orderBy(
this.collectionPresets.filter((preset) => preset.bookmark !== null),
[
(preset) => preset.user === null && preset.role === null,
(preset) => preset.user === null && preset.role !== null,
'bookmark',
]
);
},
},
actions: {

View File

@@ -8,23 +8,25 @@
<v-card-title>{{ t('create_bookmark') }}</v-card-title>
<v-card-text>
<v-input
v-model="bookmarkName"
autofocus
:placeholder="t('bookmark_name')"
@keyup.enter="$emit('save', bookmarkName)"
/>
<div class="fields">
<v-input
v-model="bookmarkValue.name"
class="full"
autofocus
trim
:placeholder="t('bookmark_name')"
@keyup.enter="$emit('save', bookmarkValue)"
/>
<interface-select-icon width="half" :value="bookmarkValue.icon" @input="setIcon" />
<interface-select-color width="half" :value="bookmarkValue.color" @input="setColor" />
</div>
</v-card-text>
<v-card-actions>
<v-button secondary @click="cancel">
{{ t('cancel') }}
</v-button>
<v-button
:disabled="bookmarkName === null || bookmarkName.length === 0"
:loading="saving"
@click="$emit('save', bookmarkName)"
>
<v-button :disabled="bookmarkValue.name === null" :loading="saving" @click="$emit('save', bookmarkValue)">
{{ t('save') }}
</v-button>
</v-card-actions>
@@ -34,7 +36,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref } from 'vue';
import { defineComponent, reactive } from 'vue';
export default defineComponent({
props: {
@@ -51,14 +53,40 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const bookmarkName = ref(null);
const bookmarkValue = reactive({
name: null,
icon: 'bookmark_outline',
color: null,
});
return { t, bookmarkName, cancel };
return { t, bookmarkValue, setIcon, setColor, cancel };
function setIcon(icon: any) {
bookmarkValue.icon = icon;
}
function setColor(color: any) {
bookmarkValue.color = color;
}
function cancel() {
bookmarkName.value = null;
bookmarkValue.name = null;
bookmarkValue.icon = 'bookmark_outline';
bookmarkValue.color = null;
emit('update:modelValue', false);
}
},
});
</script>
<style lang="scss" scoped>
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
.full {
grid-column: 1 / span 2;
}
}
</style>

View File

@@ -1,72 +0,0 @@
<template>
<v-dialog :model-value="modelValue" persistent @update:model-value="$emit('update:modelValue', $event)" @esc="cancel">
<template #activator="slotBinding">
<slot name="activator" v-bind="slotBinding" />
</template>
<v-card>
<v-card-title>{{ t('edit_bookmark') }}</v-card-title>
<v-card-text>
<v-input
v-model="bookmarkName"
autofocus
:placeholder="t('bookmark_name')"
@keyup.enter="$emit('save', bookmarkName)"
/>
</v-card-text>
<v-card-actions>
<v-button secondary @click="cancel">
{{ t('cancel') }}
</v-button>
<v-button
:disabled="bookmarkName === null || bookmarkName.length === 0"
:loading="saving"
@click="$emit('save', bookmarkName)"
>
{{ t('save') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, watch } from 'vue';
export default defineComponent({
props: {
modelValue: {
type: Boolean,
default: false,
},
saving: {
type: Boolean,
default: false,
},
name: {
type: String,
required: true,
},
},
emits: ['save', 'update:modelValue'],
setup(props, { emit }) {
const { t } = useI18n();
const bookmarkName = ref(props.name);
watch(
() => props.name,
(newName: string) => (bookmarkName.value = newName)
);
return { t, bookmarkName, cancel };
function cancel() {
emit('update:modelValue', false);
}
},
});
</script>

View File

@@ -1,4 +0,0 @@
import BookmarkEdit from './bookmark-edit.vue';
export { BookmarkEdit };
export default BookmarkEdit;

View File

@@ -3,6 +3,8 @@ import { Filter } from './filter';
export type Preset = {
id?: number;
bookmark: string | null;
icon: string;
color?: string | null;
user: string | null;
role: string | null;
collection: string;