Add presets settings (#517)

* Add shared component creation modal

* Add bookmark strings

* Expose save-as-bookmark method

* Fix typing of filter

* Add save bookmark button

* Add presets browse page

* Add select / delete functionality

* Render null value in layout as value-null

* Start on presets detail view

* Render presets detail view

* Save view options correctly

* Add readonly mode to cards layout

* Add layout drawer to presets detail

* Add delete on detail

* Add empty state

* Fix linter warnings
This commit is contained in:
Rijk van Zanten
2020-05-04 12:31:11 -04:00
committed by GitHub
parent 1fbaa73d06
commit 622570cc45
19 changed files with 1092 additions and 39 deletions

View File

@@ -165,9 +165,9 @@ body {
align-items: center;
&.secondary {
--v-button-color: var(--foreground-color);
--v-button-color-hover: var(--foreground-color);
--v-button-color-activated: var(--foreground-color);
--v-button-color: var(--foreground-normal);
--v-button-color-hover: var(--foreground-normal);
--v-button-color-activated: var(--foreground-normal);
--v-button-background-color: var(--background-normal-alt);
--v-button-background-color-hover: var(--background-normal-alt);
--v-button-background-color-activated: var(--background-normal-alt);

View File

@@ -1,14 +1,16 @@
import useCollectionPresetStore from '@/stores/collection-presets';
import { ref, Ref, computed, watch } from '@vue/composition-api';
import { debounce } from 'lodash';
import useUserStore from '@/stores/user';
import { Filter, CollectionPreset } from './types';
import { Filter, CollectionPreset } from '@/stores/collection-presets/types';
export function useCollectionPreset(
collection: Ref<string>,
bookmark: Ref<number | null> = ref(null)
) {
const collectionPresetsStore = useCollectionPresetStore();
const userStore = useUserStore();
const bookmarkExists = computed(() => {
if (!bookmark.value) return false;
@@ -17,10 +19,12 @@ export function useCollectionPreset(
(preset) => preset.id === bookmark.value
);
});
const localPreset = ref<CollectionPreset>({});
initLocalPreset();
const savePreset = async () => await collectionPresetsStore.savePreset(localPreset.value);
const savePreset = async (preset?: Partial<CollectionPreset>) =>
await collectionPresetsStore.savePreset(preset ? preset : localPreset.value);
const autoSave = debounce(async () => {
if (!bookmark || bookmark.value === null) {
@@ -86,11 +90,11 @@ export function useCollectionPreset(
},
});
const filters = computed<Filter[]>({
const filters = computed({
get() {
return localPreset.value.filters || [];
},
set(val) {
set(val: readonly Filter[]) {
localPreset.value = {
...localPreset.value,
filters: val,
@@ -114,7 +118,21 @@ export function useCollectionPreset(
},
});
return { bookmarkExists, viewType, viewOptions, viewQuery, filters, searchQuery, savePreset };
const title = computed(() => {
return localPreset.value?.title;
});
return {
bookmarkExists,
viewType,
viewOptions,
viewQuery,
filters,
searchQuery,
savePreset,
saveCurrentAsBookmark,
title,
};
function initLocalPreset() {
if (bookmark.value === null) {
@@ -129,4 +147,27 @@ export function useCollectionPreset(
};
}
}
/**
* Saves the current state of localPreset as a bookmark. The parameter allows you to override
* any of the values of the collection preset on save.
*
* This will set the user of the bookmark to the current user, and is therefore only meant to be
* used to create bookmarks for yourself.
*
* @param overrides Individual overrides for the collection preset
*/
async function saveCurrentAsBookmark(overrides: Partial<CollectionPreset>) {
const data = {
...localPreset.value,
...overrides,
};
if (data.id) delete data.id;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
data.user = userStore.state.currentUser!.id;
await savePreset(data);
}
}

View File

@@ -21,7 +21,7 @@ export default defineComponent({
default: true,
},
},
setup(props) {
setup() {
return { formatTitle };
},
});

View File

@@ -194,6 +194,14 @@
"-1": "Couldn't Reach API"
},
"bookmarks": {
"name": "Bookmark name...",
"create": "Create Bookmark",
"personal": "Personal (Current User Only)",
"global": "Global (Every User)",
"role": "For role {role}"
},
"unexpected_error": "An unexpected error occured",
"password_reset_sent": "We've sent you a secure link to reset your password",
@@ -332,6 +340,13 @@
"show_system_collections": "Show System Collections",
"hide_system_collections": "Hide System Collections",
"role": "Role",
"user": "User",
"no_presets": "No Presets",
"no_presets_copy": "No presets or bookmarks have been saved yet.",
"no_presets_cta": "Add Preset",
"always": "Always",
"create": "Create",
"full": "All",
@@ -339,7 +354,7 @@
"on_create": "On Creation",
"on_update": "On Update",
"read": "Read",
"role": "Role Only",
"role_only": "Role Only",
"update": "Update",
"select_fields": "Select Fields",
@@ -430,9 +445,24 @@
"adding_in": "Adding New Item in {collection}",
"editing_in": "Editing Item in {collection}",
"settings_data_model": "Data Model",
"settings_collections_fields": "Collections & Fields",
"settings_extensions": "Extensions",
"settings_global": "Global Settings",
"settings_permissions": "Roles & Permissions",
"settings_project": "Project Settings",
"settings_saved": "Settings Saved",
"settings_webhooks": "Webhooks",
"settings_presets": "Presets & Bookmarks",
"scope": "Scope",
"layout": "Layout",
"editing_file": "Editing File: {title}",
"changes_are_immediate_and_permanent": "Changes are immediate and permanent",
"editing_preset": "Editing Preset",
"layout_preview": "Layout Preview",
"about_directus": "About Directus",
"activity_log": "Activity Log",
"add_field_filter": "Add a field filter",
@@ -454,10 +484,6 @@
"batch_edit": "Batch Editing Items: {collection}",
"batch_edit_field": "Batch Edit Field",
"between": "Between",
"bookmark_global": "Global: Save for all users",
"bookmark_personal": "Personal: Save for me",
"bookmark_role": "Role: Save for '{role}'",
"bookmarks": "Bookmarks",
"both": "Both",
"cancel": "Cancel",
"cant_disable_primary": "You can't disable primary key on an existing field. Remove this field instead.",
@@ -467,7 +493,8 @@
"choose_project": "Choose Project",
"clear": "Clear",
"click_to_toggle_all_none": "Click to toggle entire column on/off",
"collection": "Collection | Collections",
"collection": "Collection",
"collections": "Collections",
"collection_count": "No Collections | One Collection | {count} Collections",
"collection_contains_items": "{collection} contains {count} items",
"collection_invalid_name": "Invalid collection name",
@@ -718,7 +745,6 @@
"more_options": "More options",
"my_activity": "My Activity",
"my_profile": "My Profile: {name}",
"name_bookmark": "What would you like to name this bookmark?",
"navigate_changes": "Are you sure you want to leave this page? The changes you made will be lost if you navigate away from this page.",
"new": "New",
"new_field": "New Field",
@@ -802,14 +828,6 @@
"server_trouble": "Server Trouble",
"server_trouble_copy": "Try again later or contact your system administrator help.",
"settings": "Settings",
"settings_data_model": "Data Model",
"settings_collections_fields": "Collections & Fields",
"settings_extensions": "Extensions",
"settings_global": "Global Settings",
"settings_permissions": "Roles & Permissions",
"settings_project": "Project Settings",
"settings_saved": "Settings Saved",
"settings_webhooks": "Webhooks",
"setup_2fa": "Setup 2FA",
"show_directus_collections": "Show Directus System Collections",
"sign_in": "Sign In",

View File

@@ -77,8 +77,9 @@
:icon="icon"
:file="imageSource ? item[imageSource] : null"
:item="item"
:select-mode="selectMode || _selection.length > 0"
:select-mode="selectMode || (_selection && _selection.length > 0)"
:to="getLinkForItem(item)"
:readonly="readonly"
v-model="_selection"
>
<template #title v-if="title">
@@ -206,6 +207,10 @@ export default defineComponent({
type: String as PropType<string | null>,
default: null,
},
readonly: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const mainElement = inject('main-element', ref<Element>(null));

View File

@@ -1,5 +1,5 @@
<template>
<div class="card" :class="{ loading }" @click="handleClick">
<div class="card" :class="{ loading, readonly }" @click="handleClick">
<div class="header" :class="{ selected: value.includes(item) }">
<div class="selection-indicator" :class="{ 'select-mode': selectMode }">
<v-icon class="selector" :name="selectionIcon" @click.stop="toggleSelection" />
@@ -88,6 +88,10 @@ export default defineComponent({
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const type = computed(() => {
@@ -267,6 +271,10 @@ export default defineComponent({
}
}
.readonly {
pointer-events: none;
}
.title,
.subtitle {
width: 100%;

View File

@@ -64,7 +64,7 @@
v-if="loading || itemCount > 0"
ref="table"
fixed-header
:show-select="selection !== undefined"
:show-select="readonly ? false : selection !== undefined"
show-resize
must-sort
:sort="tableSort"
@@ -74,9 +74,9 @@
:row-height="tableRowHeight"
:server-sort="itemCount === limit || totalPages > 1"
:item-key="primaryKeyField.field"
:show-manual-sort="_filters.length === 0 && sortField !== null"
:show-manual-sort="_filters && _filters.length === 0 && sortField !== null"
:manual-sort-key="sortField && sortField.field"
@click:row="onRowClick"
@click:row="readonly ? null : onRowClick"
@update:sort="onSortChange"
@manual-sort="changeManualSort"
>
@@ -203,6 +203,10 @@ export default defineComponent({
type: String,
default: `/{{project}}/collections/{{collection}}/{{primaryKey}}`,
},
readonly: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const { currentProjectKey } = toRefs(useProjectsStore().state);

View File

@@ -7,6 +7,19 @@
</v-button>
</template>
<template #title-outer:append>
<add-bookmark
class="add-bookmark"
v-model="addBookmarkActive"
@save="createBookmark"
:saving="creatingBookmark"
>
<template #activator="{ on }">
<v-icon class="toggle" name="bookmark_outline" @click="on" />
</template>
</add-bookmark>
</template>
<template #drawer>
<layout-drawer-detail v-model="viewType" />
<portal-target name="drawer" />
@@ -97,6 +110,7 @@ import useCollection from '@/composables/use-collection';
import useCollectionPreset from '@/composables/use-collection-preset';
import LayoutDrawerDetail from '@/views/private/components/layout-drawer-detail';
import SearchInput from '@/views/private/components/search-input';
import AddBookmark from '@/views/private/components/add-bookmark';
const redirectIfNeeded: NavigationGuard = async (to, from, next) => {
const collectionsStore = useCollectionsStore();
@@ -139,6 +153,7 @@ export default defineComponent({
CollectionsNotFound,
LayoutDrawerDetail,
SearchInput,
AddBookmark,
},
props: {
collection: {
@@ -169,9 +184,12 @@ export default defineComponent({
searchQuery,
savePreset,
bookmarkExists,
saveCurrentAsBookmark,
} = useCollectionPreset(collection, bookmarkID);
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
const { addBookmarkActive, creatingBookmark, createBookmark } = useBookmarks();
if (viewType.value === null) {
viewType.value = 'tabular';
}
@@ -194,6 +212,9 @@ export default defineComponent({
savePreset,
bookmarkExists,
currentCollectionLink,
addBookmarkActive,
creatingBookmark,
createBookmark,
};
function useSelection() {
@@ -267,6 +288,27 @@ export default defineComponent({
return { addNewLink, batchLink, collectionsLink, currentCollectionLink };
}
function useBookmarks() {
const addBookmarkActive = ref(false);
const creatingBookmark = ref(false);
return { addBookmarkActive, creatingBookmark, createBookmark };
async function createBookmark(name: string) {
creatingBookmark.value = true;
try {
await saveCurrentAsBookmark({ title: name });
addBookmarkActive.value = false;
} catch (error) {
console.log(error);
} finally {
creatingBookmark.value = false;
}
}
}
},
});
</script>
@@ -299,4 +341,8 @@ export default defineComponent({
.v-info {
margin: 20vh 0;
}
.add-bookmark .toggle {
margin-left: 8px;
}
</style>

View File

@@ -40,6 +40,11 @@ export default defineComponent({
name: i18n.t('settings_webhooks'),
to: `/${currentProjectKey.value}/settings/webhooks`,
},
{
icon: 'bookmark',
name: i18n.t('settings_presets'),
to: `/${currentProjectKey.value}/settings/presets`,
},
];
return { navItems };

View File

@@ -3,6 +3,7 @@ import SettingsGlobal from './routes/global';
import { SettingsCollections, SettingsFields } from './routes/data-model/';
import { SettingsRolesBrowse, SettingsRolesDetail } from './routes/roles';
import { SettingsWebhooksBrowse, SettingsWebhooksDetail } from './routes/webhooks';
import { SettingsPresetsBrowse, SettingsPresetsDetail } from './routes/presets';
import SettingsNotFound from './routes/not-found';
export default defineModule(({ i18n }) => ({
@@ -42,6 +43,17 @@ export default defineModule(({ i18n }) => ({
component: SettingsRolesDetail,
props: true,
},
{
name: 'settings-presets-browse',
path: '/presets',
component: SettingsPresetsBrowse,
},
{
name: 'settings-presets-detail',
path: '/presets/:id',
component: SettingsPresetsDetail,
props: true,
},
{
name: 'settings-webhooks-browse',
path: '/webhooks',

View File

@@ -0,0 +1,327 @@
<template>
<private-view :title="$t('settings_presets')">
<template #title-outer:prepend>
<v-button class="header-icon" rounded disabled icon secondary>
<v-icon name="bookmark" />
</v-button>
</template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
<v-button
rounded
icon
class="action-delete"
@click="on"
:disabled="selection.length === 0"
>
<v-icon name="delete" />
</v-button>
</template>
<v-card>
<v-card-title>{{ $tc('batch_delete_confirm', selection.length) }}</v-card-title>
<v-card-actions>
<v-button @click="confirmDelete = false" secondary>
{{ $t('cancel') }}
</v-button>
<v-button
@click="deleteSelection"
class="action-delete"
:loading="deleting"
>
{{ $t('delete') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button rounded icon :to="addNewLink">
<v-icon name="add" />
</v-button>
</template>
<template #navigation>
<settings-navigation />
</template>
<div class="presets-browse">
<v-info
type="warning"
v-if="presets.length === 0"
:title="$t('no_presets')"
icon="bookmark"
>
{{ $t('no_presets_copy') }}
<template #append>
<v-button :to="addNewLink">
{{ $t('no_presets_cta') }}
</v-button>
</template>
</v-info>
<v-table
:headers="headers"
fixed-header
:items="presets"
:loading="loading"
@click:row="onRowClick"
v-model="selection"
show-select
v-else
>
<template #item.scope="{ item }">
<span :class="{ all: item.scope === 'all' }">
{{ item.scope === 'all' ? $t('all') : item.scope }}
</span>
</template>
<template #item.layout="{ item }">
<value-null v-if="!item.layout" />
<span v-else>{{ item.layout }}</span>
</template>
<template #item.name="{ item }">
<span :class="{ default: item.name === null }">
{{ item.name === null ? $t('default') : item.name }}
</span>
</template>
</v-table>
</div>
</private-view>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import SettingsNavigation from '../../../components/navigation';
import useProjectsStore from '@/stores/projects';
import api from '@/api';
import { Header } from '@/components/v-table/types';
import i18n from '@/lang';
import useCollectionsStore from '@/stores/collections';
import layouts from '@/layouts';
import { TranslateResult } from 'vue-i18n';
import router from '@/router';
import ValueNull from '@/views/private/components/value-null';
type PresetRaw = {
id: number;
title: null | string;
user: null | { first_name: string; last_name: string };
role: null | { name: string };
collection: string;
view_type: string;
};
type Preset = {
id: number;
name: null | string | TranslateResult;
scope: string;
collection: string | TranslateResult;
layout: string | TranslateResult;
};
export default defineComponent({
components: { SettingsNavigation, ValueNull },
setup() {
const projectsStore = useProjectsStore();
const collectionsStore = useCollectionsStore();
const selection = ref<Preset[]>([]);
const { addNewLink } = useLinks();
const { loading, presets, error, getPresets } = usePresets();
const { headers } = useTable();
const { confirmDelete, deleting, deleteSelection } = useDelete();
getPresets();
return {
addNewLink,
usePresets,
loading,
presets,
error,
getPresets,
headers,
selection,
onRowClick,
confirmDelete,
deleting,
deleteSelection,
};
function useLinks() {
const addNewLink = computed(() => {
const { currentProjectKey } = projectsStore.state;
return `/${currentProjectKey}/settings/presets/+`;
});
return { addNewLink };
}
function usePresets() {
const loading = ref(false);
const presetsRaw = ref<PresetRaw[]>(null);
const error = ref(null);
const presets = computed<Preset[]>(() => {
return (presetsRaw.value || []).map((preset) => {
let scope = 'all';
if (preset.role) {
scope = preset.role.name;
}
if (preset.user) {
scope = `${preset.user.first_name} ${preset.user.last_name}`;
}
const collection = collectionsStore.getCollection(preset.collection)?.name;
const layout = layouts.find((l) => l.id === preset.view_type)?.name;
return {
id: preset.id,
scope: scope,
collection: collection,
layout: layout,
name: preset.title,
} as Preset;
});
});
return { loading, presetsRaw, error, getPresets, presets };
async function getPresets() {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
try {
const response = await api.get(`/${currentProjectKey}/collection_presets`, {
params: {
fields: [
'id',
'title',
'user.first_name',
'user.last_name',
'role.name',
'collection',
'view_type',
],
},
});
presetsRaw.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
function useTable() {
const headers: Header[] = [
{
text: i18n.t('collection'),
value: 'collection',
align: 'left',
sortable: true,
width: 200,
},
{
text: i18n.t('scope'),
value: 'scope',
align: 'left',
sortable: true,
width: 200,
},
{
text: i18n.t('layout'),
value: 'layout',
align: 'left',
sortable: true,
width: 200,
},
{
text: i18n.t('name'),
value: 'name',
align: 'left',
sortable: true,
width: 200,
},
];
return { headers };
}
function onRowClick(item: Preset) {
const { currentProjectKey } = projectsStore.state;
if (selection.value.length === 0) {
router.push(`/${currentProjectKey}/settings/presets/${item.id}`);
} else {
if (selection.value.includes(item)) {
selection.value = selection.value.filter((i) => i !== item);
} else {
selection.value = [...selection.value, item];
}
}
}
function useDelete() {
const confirmDelete = ref(false);
const deleting = ref(false);
return { confirmDelete, deleting, deleteSelection };
async function deleteSelection() {
const { currentProjectKey } = projectsStore.state;
deleting.value = true;
try {
const IDs = selection.value.map((item) => item.id).join(',');
await api.delete(`/${currentProjectKey}/collection_presets/${IDs}`);
selection.value = [];
await getPresets();
confirmDelete.value = false;
} finally {
deleting.value = false;
}
}
}
},
});
</script>
<style lang="scss" scoped>
.header-icon {
--v-button-color-disabled: var(--warning);
--v-button-background-color-disabled: var(--warning-25);
}
.action-delete {
--v-button-background-color: var(--danger-25);
--v-button-color: var(--danger);
--v-button-background-color-hover: var(--danger-50);
--v-button-color-hover: var(--danger);
}
.presets-browse {
padding: var(--content-padding);
padding-top: 0;
}
.all {
color: var(--primary);
}
.default {
color: var(--foreground-subdued);
}
.v-info {
margin: 20vh 0;
}
</style>

View File

@@ -0,0 +1,4 @@
import PresetsBrowse from './browse.vue';
export { PresetsBrowse };
export default PresetsBrowse;

View File

@@ -0,0 +1,511 @@
<template>
<private-view :title="$t('editing_preset')">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon exact :to="backLink">
<v-icon name="arrow_back" />
</v-button>
</template>
<template #navigation>
<settings-navigation />
</template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
<v-button
rounded
icon
class="action-delete"
:disabled="preset === null || id === '+'"
@click="on"
>
<v-icon name="delete" />
</v-button>
</template>
<v-card>
<v-card-title>{{ $t('delete_are_you_sure') }}</v-card-title>
<v-card-actions>
<v-button @click="confirmDelete = false" secondary>
{{ $t('cancel') }}
</v-button>
<v-button @click="deleteAndQuit" class="action-delete" :loading="deleting">
{{ $t('delete') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button icon rounded :disabled="hasEdits === false" :loading="saving" @click="save">
<v-icon name="check" />
</v-button>
</template>
<template #drawer>
<div class="layout-drawer">
<portal-target name="drawer" />
</div>
</template>
<div class="preset-detail">
<v-form
:fields="fields"
:loading="loading"
:initial-values="initialValues"
v-model="edits"
/>
<div class="layout">
<component
v-if="values.layout && values.collection"
:is="`layout-${values.layout}`"
:collection="values.collection"
:view-options.sync="viewOptions"
:view-query.sync="viewQuery"
:filters="values.filters || []"
@update:filters="edits.filters = $event"
readonly
/>
</div>
</div>
</private-view>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import useProjectsStore from '@/stores/projects';
import SettingsNavigation from '../../../components/navigation';
import { CollectionPreset, Filter } from '@/stores/collection-presets/types';
import api from '@/api';
import i18n from '@/lang';
import useCollectionsStore from '@/stores/collections';
import layouts from '@/layouts';
import router from '@/router';
import useCollectionPresetsStore from '@/stores/collection-presets';
type User = {
id: number;
name: string;
};
type Role = {
id: number;
name: string;
};
type FormattedPreset = {
id: number;
scope: string;
collection: string;
layout: string | null;
name: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
view_query: Record<string, any> | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
view_options: Record<string, any> | null;
filters: readonly Filter[] | null;
};
export default defineComponent({
components: { SettingsNavigation },
props: {
id: {
type: String,
default: null,
},
},
setup(props) {
const projectsStore = useProjectsStore();
const collectionsStore = useCollectionsStore();
const collectionPresetsStore = useCollectionPresetsStore();
const { backLink } = useLinks();
const isNew = computed(() => props.id === '+');
const { loading: usersLoading, users } = useUsers();
const { loading: rolesLoading, roles } = useRoles();
const { loading: presetLoading, error, preset } = usePreset();
const { fields } = useForm();
const { edits, hasEdits, initialValues, values, viewQuery, viewOptions } = useValues();
const { save, saving } = useSave();
const { deleting, deleteAndQuit, confirmDelete } = useDelete();
const loading = computed(
() => usersLoading.value || presetLoading.value || rolesLoading.value
);
return {
backLink,
loading,
error,
preset,
edits,
fields,
values,
initialValues,
saving,
save,
viewQuery,
viewOptions,
hasEdits,
deleting,
deleteAndQuit,
confirmDelete,
};
function useSave() {
const saving = ref(false);
return { saving, save };
async function save() {
const { currentProjectKey } = projectsStore.state;
saving.value = true;
const editsParsed: Partial<CollectionPreset> = {};
if (edits.value.name) editsParsed.title = edits.value.name;
if (edits.value.name?.length === 0) editsParsed.title = null;
if (edits.value.collection) editsParsed.collection = edits.value.collection;
if (edits.value.layout) editsParsed.view_type = edits.value.layout;
if (edits.value.view_query) editsParsed.view_query = edits.value.view_query;
if (edits.value.view_options) editsParsed.view_options = edits.value.view_options;
if (edits.value.filters) editsParsed.filters = edits.value.filters;
if (edits.value.scope) {
if (edits.value.scope.startsWith('role_')) {
editsParsed.role = +edits.value.scope.substring(5);
} else if (edits.value.scope.startsWith('user_')) {
editsParsed.user = +edits.value.scope.substring(5);
}
}
try {
if (isNew.value === true) {
await api.post(`/${currentProjectKey}/collection_presets`, editsParsed);
} else {
await api.patch(
`/${currentProjectKey}/collection_presets/${props.id}`,
editsParsed
);
}
await collectionPresetsStore.hydrate();
edits.value = {};
} catch (err) {
console.error(err);
} finally {
saving.value = false;
router.push(`/${currentProjectKey}/settings/presets`);
}
}
}
function useDelete() {
const deleting = ref(false);
const confirmDelete = ref(false);
return { deleting, confirmDelete, deleteAndQuit };
async function deleteAndQuit() {
const { currentProjectKey } = projectsStore.state;
deleting.value = true;
try {
await api.delete(`/${currentProjectKey}/collection_presets/${props.id}`);
router.push(`/${currentProjectKey}/settings/presets`);
} catch (error) {
console.error(error);
} finally {
deleting.value = false;
}
}
}
function useValues() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const edits = ref<any>({});
const hasEdits = computed(() => Object.keys(edits.value).length > 0);
const initialValues = computed(() => {
if (isNew.value === true) return {};
if (preset.value === null) return {};
let scope = 'all';
if (preset.value.user !== null) {
scope = `user_${preset.value.user}`;
} else if (preset.value.role !== null) {
scope = `role_${preset.value.role}`;
}
const value: FormattedPreset = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: preset.value.id!,
collection: preset.value.collection,
layout: preset.value.view_type,
name: preset.value.title,
scope: scope,
view_query: preset.value.view_query,
view_options: preset.value.view_options,
filters: preset.value.filters,
};
return value;
});
const values = computed(() => {
return {
...initialValues.value,
...edits.value,
};
});
const viewQuery = computed({
get() {
if (!values.value.view_query) return null;
if (!values.value.layout) return null;
return values.value.view_query[values.value.layout];
},
set(newQuery) {
edits.value = {
...edits.value,
view_query: {
...edits.value.view_query,
[values.value.layout]: newQuery,
},
};
},
});
const viewOptions = computed({
get() {
if (!values.value.view_options) return null;
if (!values.value.layout) return null;
return values.value.view_options[values.value.layout];
},
set(newOptions) {
edits.value = {
...edits.value,
view_options: {
...edits.value.view_options,
[values.value.layout]: newOptions,
},
};
},
});
return { edits, initialValues, values, viewQuery, viewOptions, hasEdits };
}
function usePreset() {
const loading = ref(false);
const error = ref(null);
const preset = ref<CollectionPreset>(null);
fetchPreset();
return { loading, error, preset, fetchPreset };
async function fetchPreset() {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
try {
const response = await api.get(
`/${currentProjectKey}/collection_presets/${props.id}`
);
preset.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
function useLinks() {
const backLink = computed(() => {
const { currentProjectKey } = projectsStore.state;
return `/${currentProjectKey}/settings/presets`;
});
return { backLink };
}
function useUsers() {
const loading = ref(false);
const error = ref(null);
const users = ref<User[]>(null);
fetchUsers();
return { loading, error, users };
async function fetchUsers() {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
try {
const response = await api.get(`/${currentProjectKey}/users`, {
params: {
fields: ['first_name', 'last_name', 'id'],
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
users.value = response.data.data.map((user: any) => ({
name: user.first_name + ' ' + user.last_name,
id: user.id,
}));
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
function useRoles() {
const loading = ref(false);
const error = ref(null);
const roles = ref<Role[]>(null);
fetchRoles();
return { loading, error, roles };
async function fetchRoles() {
const { currentProjectKey } = projectsStore.state;
loading.value = true;
try {
const response = await api.get(`/${currentProjectKey}/roles`, {
params: {
fields: ['name', 'id'],
},
});
roles.value = response.data.data;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
}
function useForm() {
const scopeChoices = computed<string>(() => {
if (usersLoading.value || rolesLoading.value) return '';
let options = `all :: ${i18n.t('all')}\n`;
roles.value?.forEach((role) => {
options += `role_${role.id} :: ${i18n.t('role')}: ${role.name}\n`;
});
users.value?.forEach((user) => {
options += `user_${user.id} :: ${i18n.t('user')}: ${user.name}\n`;
});
return options;
});
const fields = computed(() => [
{
field: 'collection',
name: i18n.t('collection'),
interface: 'dropdown',
options: {
choices: collectionsStore.state.collections.reduce(
(string, collection) =>
(string += `${collection.collection} :: ${collection.name}\n`),
''
),
},
width: 'half',
},
{
field: 'scope',
name: i18n.t('scope'),
interface: 'dropdown',
options: {
choices: scopeChoices.value,
},
width: 'half',
},
{
field: 'layout',
name: i18n.t('layout'),
interface: 'dropdown',
options: {
choices: layouts.reduce(
(string, layout) => (string += `${layout.id}::${layout.name}\n`),
''
),
},
width: 'half',
},
{
field: 'name',
name: i18n.t('name'),
interface: 'text-input',
width: 'half',
},
{
field: 'divider',
name: i18n.t('divider'),
interface: 'divider',
width: 'full',
options: {
title: i18n.t('layout_preview'),
},
},
]);
return { fields };
}
},
});
</script>
<style lang="scss" scoped>
.header-icon {
--v-button-background-color: var(--warning-25);
--v-button-color: var(--warning);
--v-button-background-color-hover: var(--warning-50);
--v-button-color-hover: var(--warning);
}
.action-delete {
--v-button-background-color: var(--danger-25);
--v-button-color: var(--danger);
--v-button-background-color-hover: var(--danger-50);
--v-button-color-hover: var(--danger);
}
.preset-detail {
padding: var(--content-padding);
}
.layout {
--content-padding: 0;
margin-top: 48px;
}
.layout-drawer {
--drawer-detail-icon-color: var(--warning);
}
</style>

View File

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

View File

@@ -0,0 +1,5 @@
import SettingsPresetsBrowse from './browse/';
import SettingsPresetsDetail from './detail/';
export { SettingsPresetsBrowse, SettingsPresetsDetail };
export default { SettingsPresetsBrowse, SettingsPresetsDetail };

View File

@@ -34,8 +34,8 @@ export type CollectionPreset = {
user: number | null;
role: number | null;
collection: string;
search_query: null;
filters: Filter[] | null;
search_query: string | null;
filters: readonly Filter[] | null;
view_type: string | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
view_query: { [view_type: string]: any } | null;

View File

@@ -0,0 +1,59 @@
<template>
<v-dialog :active="active" @toggle="$listeners.toggle" persistent>
<template #activator="slotBinding">
<slot name="activator" v-bind="slotBinding" />
</template>
<v-card>
<v-card-title>{{ $t('bookmarks.create') }}</v-card-title>
<v-card-text>
<v-input v-model="bookmarkName" :placeholder="$t('bookmarks.name')" />
</v-card-text>
<v-card-actions>
<v-button @click="cancel" secondary>
{{ $t('cancel') }}
</v-button>
<v-button
:disabled="bookmarkName === null || bookmarkName.length === 0"
@click="$emit('save', bookmarkName)"
:loading="saving"
>
{{ $t('save') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
export default defineComponent({
model: {
prop: 'active',
event: 'toggle',
},
props: {
active: {
type: Boolean,
default: false,
},
saving: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const bookmarkName = ref(null);
return { bookmarkName, cancel };
function cancel() {
bookmarkName.value = null;
emit('toggle', false);
}
},
});
</script>

View File

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

View File

@@ -47,6 +47,12 @@ export default defineComponent({
});
</script>
<style>
body {
--drawer-detail-icon-color: var(--foreground-normal);
}
</style>
<style lang="scss" scoped>
.drawer-detail {
--v-badge-offset-x: 2px;
@@ -62,17 +68,11 @@ export default defineComponent({
height: 64px;
color: var(--foreground-normal);
background-color: var(--background-normal-alt);
&:not(.open):hover {
// Show arrow
}
&.open {
// Invert arrow
}
}
.icon {
--v-icon-color: var(--drawer-detail-icon-color);
display: flex;
align-items: center;
justify-content: center;