Save layout options query (#246)

* Add getPresetForCollection method

* Add savePreset action

* Add useSync composition

* Sync collection presets with store / api

* Clean up browse flow

* Cleanup tabular code

* Move portal target to browse page

* Save column widths to view options

* Add must-sort prop to v-table

* Add saving flow for viewQuery / viewOptions

* Optimize saving flow

* Provide main element to sub components

* Add per page option

* Add field setup drawer detail
This commit is contained in:
Rijk van Zanten
2020-03-25 09:49:29 -04:00
committed by GitHub
parent 9420b17894
commit 7bcfcb9b5b
17 changed files with 1050 additions and 303 deletions

View File

@@ -126,6 +126,7 @@ export default defineComponent({
| `loadingText` | What text to show when table is loading with no items | `Loading...` |
| `server-sort` | Handle sorting on the parent level. | `false` |
| `row-height` | Height of the individual rows in px | `48` |
| `must-sort` | Requires the sort to be on a particular column | `false` |
## Events
| Event | Description | Value |

View File

@@ -79,6 +79,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
mustSort: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const dragging = ref<boolean>(false);
@@ -124,33 +128,35 @@ export default defineComponent({
return classes;
}
/**
* If current sort is not this field, use this field in ascending order
* If current sort is field, reverse sort order to descending
* If current sort is field and sort is desc, set sort field to null (default)
*/
function changeSort(header: Header) {
if (header.sortable === false) return;
if (dragging.value === true) return;
if (header.value === props.sort.by) {
if (props.mustSort) {
return emit('update:sort', {
by: props.sort.by,
desc: !props.sort.desc,
});
}
if (props.sort.desc === false) {
emit('update:sort', {
return emit('update:sort', {
by: props.sort.by,
desc: true,
});
} else {
emit('update:sort', {
by: null,
desc: false,
});
}
} else {
emit('update:sort', {
by: header.value,
return emit('update:sort', {
by: null,
desc: false,
});
}
return emit('update:sort', {
by: header.value,
desc: false,
});
}
function toggleSelectAll() {
@@ -286,6 +292,8 @@ export default defineComponent({
width: 5px;
height: 100%;
cursor: ew-resize;
opacity: 0;
transition: opacity var(--fast) var(--transition);
&::after {
position: relative;
@@ -301,5 +309,9 @@ export default defineComponent({
background-color: var(--input-action-color-hover);
}
}
th:hover .resize-handle {
opacity: 1;
}
}
</style>

View File

@@ -15,6 +15,7 @@
:all-items-selected="allItemsSelected"
:fixed="fixedHeader"
:show-manual-sort="showManualSort"
:must-sort="mustSort"
@toggle-select-all="onToggleSelectAll"
>
<template v-for="header in _headers" #[`header.${header.value}`]>
@@ -108,6 +109,10 @@ export default defineComponent({
type: Object as PropType<Sort>,
default: null,
},
mustSort: {
type: Boolean,
default: false,
},
showSelect: {
type: Boolean,
default: false,

View File

@@ -6,6 +6,9 @@ Compositions are reusable pieces of logic that can be used inside Vue components
* [`useGroupable` / `useGroupableParent`](./groupable)
* [`useSizeClass`](./size-class)
* [`useElementSize`](./use-element-size)
* [`useEventListener`](./use-event-listener)
* [`useScrollDistance`](./use-scroll-distance)
* [`useSync`](./use-sync)
* [`useTimeFromNow`](./use-time-from-now)
* [`useWindowSize`](./use-window-size)

View File

@@ -0,0 +1,4 @@
import useSync from './use-sync';
export { useSync };
export default useSync;

View File

@@ -0,0 +1,31 @@
# Use Sync
```ts
function useSync<T, K extends keyof T>(
props: T,
key: K,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emit: (event: string, ...args: any[]) => void
): Ref<Readonly<T[K]>>
```
Small utility composition that allows you to easily setup the two-way binding with the prop:
```ts
// Before
const _options = computed({
get() {
return props.options;
},
set(val) {
emit('update:options', val);
}
});
```
```ts
// after
const _options = useSync(props, 'options', emit);
```

View File

@@ -0,0 +1,17 @@
import { computed, Ref } from '@vue/composition-api';
export default function useSync<T, K extends keyof T>(
props: T,
key: K,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
emit: (event: string, ...args: any[]) => void
): Ref<Readonly<T[K]>> {
return computed<T[K]>({
get() {
return props[key];
},
set(newVal) {
emit(`update:${key}`, newVal);
},
});
}

View File

@@ -106,11 +106,9 @@
"modules": {},
"comfortable": "Comfortable",
"coming_soon": "Coming Soon",
"comment": "Comment",
"comments": "Comments",
"compact": "Compact",
"config_error": "Missing Config",
"config_error_copy": "Make sure you've created the application's configuration file",
"confirm": "Confirm",
@@ -118,7 +116,6 @@
"contains": "Contains",
"continue": "Continue",
"continue_as": "<b>{name}</b> is already authenticated for this project. If you recognize this account, please press continue.",
"cozy": "Cozy",
"create": "Create",
"create_collection": "Create Collection",
"create_field": "Create Field",

View File

@@ -1,41 +1,46 @@
{
"layouts": {
"calendar": {
"calendar": "Calendar",
"fields": "Fields",
"today": "today",
"events": "Events",
"moreEvents": "and {amount} more...",
"noEvents": "no events yet!",
"date": "Date",
"datetime": "Datetime",
"time": "Time",
"title": "Title",
"color": "Color"
},
"cards": {
"cards": "Cards",
"title": "Title",
"subtitle": "Subtitle",
"src": "Image Source",
"content": "Body Content",
"fit": "Fit"
},
"tabular": {
"tabular": "Table",
"fields": "Fields"
},
"timeline": {
"timeline": "Timeline",
"fields": "Fields",
"today": "today",
"events": "Events",
"moreEvents": "and {amount} more...",
"noEvents": "no events yet!",
"date": "Date",
"content": "Content",
"title": "Title",
"color": "Color"
}
}
"layouts": {
"calendar": {
"calendar": "Calendar",
"fields": "Fields",
"today": "today",
"events": "Events",
"moreEvents": "and {amount} more...",
"noEvents": "no events yet!",
"date": "Date",
"datetime": "Datetime",
"time": "Time",
"title": "Title",
"color": "Color"
},
"cards": {
"cards": "Cards",
"title": "Title",
"subtitle": "Subtitle",
"src": "Image Source",
"content": "Body Content",
"fit": "Fit"
},
"tabular": {
"tabular": "Table",
"fields": "Fields",
"spacing": "Spacing",
"comfortable": "Comfortable",
"compact": "Compact",
"cozy": "Cozy",
"per_page": "Per Page"
},
"timeline": {
"timeline": "Timeline",
"fields": "Fields",
"today": "today",
"events": "Events",
"moreEvents": "and {amount} more...",
"noEvents": "no events yet!",
"date": "Date",
"content": "Content",
"title": "Title",
"color": "Color"
}
}
}

View File

@@ -1,25 +1,59 @@
<template>
<div class="layout-tabular">
<portal to="actions:prepend">
Search bar here
</portal>
<portal to="drawer">
<drawer-detail icon="exposure_plus_2" title="Items per page">
Example
<drawer-detail icon="table_chart" :title="$t('layouts.tabular.fields')">
<draggable v-model="activeFields">
<v-checkbox
v-for="field in activeFields"
v-model="activeFieldKeys"
:key="field.field"
:value="field.field"
:label="field.name"
/>
</draggable>
<v-checkbox
v-for="field in visibleFields.filter(
(field) => activeFieldKeys.includes(field.field) === false
)"
v-model="activeFieldKeys"
:key="field.field"
:value="field.field"
:label="field.name"
/>
</drawer-detail>
<drawer-detail icon="line_weight" :title="$t('layouts.tabular.spacing')">
<select v-model="spacing">
<option value="compact">{{ $t('layouts.tabular.compact') }}</option>
<option value="cozy">{{ $t('layouts.tabular.cozy') }}</option>
<option value="comfortable">{{ $t('layouts.tabular.comfortable') }}</option>
</select>
</drawer-detail>
<drawer-detail icon="format_list_numbered" :title="$t('layouts.tabular.per_page')">
<select v-model="perPage">
<option v-for="amount in [10, 25, 50, 100, 250]" :key="amount" :value="amount">
{{ amount }}
</option>
</select>
</drawer-detail>
</portal>
<v-table
:items="items"
:loading="loading"
:headers="headers"
ref="table"
v-model="_selection"
ref="table"
fixed-header
show-select
@click:row="onRowClick"
show-resize
must-sort
:sort="tableSort"
:items="items"
:loading="loading"
:headers.sync="headers"
:row-height="rowHeight"
:server-sort="isBigCollection"
@click:row="onRowClick"
@update:sort="onSortChange"
>
<template #footer>
@@ -38,17 +72,33 @@
</template>
<script lang="ts">
import { defineComponent, PropType, ref, watch, computed } from '@vue/composition-api';
import Vue from 'vue';
import { defineComponent, PropType, ref, watch, computed, inject } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import useFieldsStore from '@/stores/fields';
import { HeaderRaw, Item } from '@/components/v-table/types';
import { Field } from '@/stores/fields/types';
import router from '@/router';
import useSync from '@/compositions/use-sync';
import { debounce } from 'lodash';
import Draggable from 'vuedraggable';
const PAGE_COUNT = 75;
type ViewOptions = {
widths?: {
[field: string]: number;
};
perPage?: number;
spacing?: 'comfortable' | 'cozy' | 'compact';
};
export type ViewQuery = {
fields?: string;
sort?: string;
};
export default defineComponent({
components: { Draggable },
props: {
collection: {
type: String,
@@ -62,66 +112,49 @@ export default defineComponent({
type: Boolean,
default: false,
},
viewOptions: {
type: Object as PropType<ViewOptions>,
default: null,
},
viewQuery: {
type: Object as PropType<ViewQuery>,
default: null,
},
},
setup(props, { emit }) {
const table = ref<Vue>(null);
const mainElement = inject('main-element', ref<Element>(null));
const { currentProjectKey } = useProjectsStore().state;
const projectsStore = useProjectsStore();
const fieldsStore = useFieldsStore();
const { currentProjectKey } = projectsStore.state;
const error = ref(null);
const items = ref([]);
const loading = ref(true);
const itemCount = ref<number>(null);
const currentPage = ref(1);
const pages = computed<number>(() => Math.ceil(itemCount.value || 0 / PAGE_COUNT));
const isBigCollection = computed<boolean>(() => (itemCount.value || 0) > PAGE_COUNT);
const sort = ref({ by: 'id', desc: false });
const _selection = useSync(props, 'selection', emit);
const _viewOptions = useSync(props, 'viewOptions', emit);
const _viewQuery = useSync(props, 'viewQuery', emit);
const _selection = computed<Item[]>({
get() {
return props.selection;
},
set(newSelection) {
emit('update:selection', newSelection);
},
});
const { visibleFields, primaryKeyField } = useCollectionInfo();
const fieldsInCurrentCollection = computed<Field[]>(() => {
return fieldsStore.state.fields.filter(
(field) => field.collection === props.collection
);
});
const {
isBigCollection,
pages,
getItems,
error,
items,
loading,
itemCount,
currentPage,
toPage,
sort,
perPage,
activeFieldKeys,
activeFields,
} = useItems();
const visibleFields = computed<Field[]>(() => {
return fieldsInCurrentCollection.value.filter((field) => field.hidden_browse === false);
});
const headers = computed<HeaderRaw[]>(() => {
return visibleFields.value.map((field) => ({
text: field.name,
value: field.field,
}));
});
const primaryKeyField = computed<Field>(() => {
// It's safe to assume that every collection has a primary key.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return fieldsInCurrentCollection.value.find((field) => field.primary_key === true)!;
});
const { headers, rowHeight, spacing, onRowClick, tableSort, onSortChange } = useTable();
getItems();
watch(
() => props.collection,
() => {
items.value = [];
itemCount.value = null;
currentPage.value = 1;
getItems();
}
);
return {
error,
items,
@@ -138,91 +171,312 @@ export default defineComponent({
currentPage,
isBigCollection,
onSortChange,
rowHeight,
_viewOptions,
spacing,
tableSort,
perPage,
visibleFields,
activeFieldKeys,
activeFields,
};
async function refresh() {
await getItems();
}
async function getItems() {
error.value = null;
loading.value = true;
function useTable() {
const localWidths = ref<{ [field: string]: number }>({});
let sortString = sort.value.by;
if (sort.value.desc === true) sortString = '-' + sortString;
const saveWidthsToViewOptions = debounce(() => {
_viewOptions.value = {
..._viewOptions.value,
widths: localWidths.value,
};
}, 350);
try {
const response = await api.get(`/${currentProjectKey}/items/${props.collection}`, {
params: {
limit: PAGE_COUNT,
page: currentPage.value,
sort: sortString,
},
});
const headers = computed<HeaderRaw[]>({
get() {
return activeFields.value.map((field) => ({
text: field.name,
value: field.field,
width:
localWidths.value[field.field] ||
_viewOptions.value.widths?.[field.field] ||
null,
}));
},
set(val) {
const widths = {} as { [field: string]: number };
items.value = response.data.data;
val.forEach((header) => {
if (header.width) {
widths[header.value] = header.width;
}
});
if (itemCount.value === null) {
if (response.data.data.length === PAGE_COUNT) {
// Requesting the page filter count in the actual request every time slows
// the request down by like 600ms-1s. This makes sure we only fetch the count
// once if needed.
getTotalCount();
} else {
// If the response includes less items than the limit, it's safe to assume
// it's all the data in the DB
itemCount.value = response.data.data.length;
}
}
} catch (error) {
error.value = error;
} finally {
loading.value = false;
}
}
localWidths.value = widths;
function onRowClick(item: Item) {
if (props.selectMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(table.value as any).onItemSelected({
item,
value: _selection.value.includes(item) === false,
});
} else {
const primaryKey = item[primaryKeyField.value.field];
router.push(`/${currentProjectKey}/collections/${props.collection}/${primaryKey}`);
}
}
function toPage(page: number) {
currentPage.value = page;
getItems();
// We know this is only called after the element is mounted
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
table.value!.$el.parentElement!.parentElement!.scrollTo({
top: 0,
behavior: 'smooth',
});
}
function onSortChange(newSort: { by: string; desc: boolean }) {
// Let the table component handle the sorting for small datasets
if (isBigCollection.value === false) return;
sort.value = newSort;
currentPage.value = 1;
getItems();
}
async function getTotalCount() {
const response = await api.get(`/${currentProjectKey}/items/${props.collection}`, {
params: {
limit: 0,
fields: primaryKeyField.value.field,
meta: 'filter_count',
saveWidthsToViewOptions();
},
});
itemCount.value = response.data.meta.filter_count;
const rowHeight = computed<number>(() => {
const spacing = props.viewOptions?.spacing || 'comfortable';
switch (spacing) {
case 'compact':
return 32;
case 'cozy':
default:
return 48;
case 'comfortable':
return 64;
}
});
const spacing = computed({
get() {
return _viewOptions.value?.spacing || 'cozy';
},
set(newSpacing: 'compact' | 'cozy' | 'comfortable') {
_viewOptions.value = {
..._viewOptions.value,
spacing: newSpacing,
};
},
});
const tableSort = computed(() => {
if (sort.value.startsWith('-')) {
return { by: sort.value.substring(1), desc: true };
} else {
return { by: sort.value, desc: false };
}
});
return {
headers,
rowHeight,
spacing,
onRowClick,
onSortChange,
tableSort,
};
function onRowClick(item: Item) {
if (props.selectMode) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(table.value as any).onItemSelected({
item,
value: _selection.value.includes(item) === false,
});
} else {
const primaryKey = item[primaryKeyField.value.field];
router.push(
`/${currentProjectKey}/collections/${props.collection}/${primaryKey}`
);
}
}
function onSortChange(newSort: { by: string; desc: boolean }) {
let sortString = newSort.by;
if (newSort.desc === true) sortString = '-' + sortString;
sort.value = sortString;
}
}
function useCollectionInfo() {
const fieldsInCurrentCollection = computed<Field[]>(() => {
return fieldsStore.state.fields.filter(
(field) => field.collection === props.collection
);
});
const visibleFields = computed<Field[]>(() => {
return fieldsInCurrentCollection.value.filter(
(field) => field.hidden_browse === false
);
});
const primaryKeyField = computed<Field>(() => {
// It's safe to assume that every collection has a primary key.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return fieldsInCurrentCollection.value.find((field) => field.primary_key === true)!;
});
return { fieldsInCurrentCollection, visibleFields, primaryKeyField };
}
function useItems() {
const error = ref(null);
const items = ref([]);
const loading = ref(true);
const itemCount = ref<number>(null);
const currentPage = ref(1);
const perPage = computed<number>({
get() {
return props.viewOptions?.perPage || 25;
},
set(val) {
_viewOptions.value = {
..._viewOptions.value,
perPage: val,
};
currentPage.value = 1;
Vue.nextTick().then(() => {
getItems();
});
},
});
const pages = computed<number>(() => Math.ceil(itemCount.value || 0 / perPage.value));
const isBigCollection = computed<boolean>(() => (itemCount.value || 0) > perPage.value);
const sort = computed<string>({
get() {
return _viewQuery.value?.sort || primaryKeyField.value.field;
},
set(newSort) {
_viewQuery.value = {
..._viewQuery.value,
sort: newSort,
};
// Let the table component handle the sorting for small datasets
if (isBigCollection.value === false) return;
currentPage.value = 1;
Vue.nextTick().then(() => {
getItems();
});
},
});
const activeFieldKeys = computed<string[]>({
get() {
return (
_viewQuery.value?.fields?.split(',') ||
visibleFields.value.map((field) => field.field)
);
},
set(newFields: string[]) {
_viewQuery.value = {
..._viewQuery.value,
fields: newFields.join(','),
};
Vue.nextTick().then(() => {
getItems();
});
},
});
const activeFields = computed<Field[]>({
get() {
return activeFieldKeys.value
.map((key) => visibleFields.value.find((field) => field.field === key))
.filter((f) => f) as Field[];
},
set(val) {
activeFieldKeys.value = val.map((field) => field.field);
},
});
watch(
() => props.collection,
() => {
items.value = [];
itemCount.value = null;
currentPage.value = 1;
getItems();
}
);
return {
isBigCollection,
pages,
getItems,
getTotalCount,
error,
items,
loading,
itemCount,
sort,
toPage,
currentPage,
perPage,
activeFieldKeys,
activeFields,
};
async function getTotalCount() {
const response = await api.get(`/${currentProjectKey}/items/${props.collection}`, {
params: {
limit: 0,
fields: primaryKeyField.value.field,
meta: 'filter_count',
},
});
itemCount.value = response.data.meta.filter_count;
}
async function getItems() {
error.value = null;
loading.value = true;
const fieldsToFetch = [...activeFieldKeys.value];
if (fieldsToFetch.includes(primaryKeyField.value.field) === false) {
fieldsToFetch.push(primaryKeyField.value.field);
}
try {
const response = await api.get(
`/${currentProjectKey}/items/${props.collection}`,
{
params: {
fields: fieldsToFetch,
limit: perPage.value,
page: currentPage.value,
sort: sort.value,
},
}
);
items.value = response.data.data;
if (itemCount.value === null) {
if (response.data.data.length === perPage.value) {
// Requesting the page filter count in the actual request every time slows
// the request down by like 600ms-1s. This makes sure we only fetch the count
// once if needed.
getTotalCount();
} else {
// If the response includes less items than the limit, it's safe to assume
// it's all the data in the DB
itemCount.value = response.data.data.length;
}
}
} catch (error) {
error.value = error;
} finally {
loading.value = false;
}
}
function toPage(page: number) {
currentPage.value = page;
getItems();
mainElement.value?.scrollTo({
top: 0,
behavior: 'smooth',
});
}
}
},
});

View File

@@ -10,6 +10,8 @@
<v-breadcrumb :items="breadcrumb" />
</template>
<template #drawer><portal-target name="drawer" /></template>
<template #actions>
<v-dialog v-model="confirmDelete">
<template #activator="{ on }">
@@ -56,6 +58,8 @@
ref="layout"
:collection="collection"
:selection.sync="selection"
:view-options.sync="viewOptions"
:view-query.sync="viewQuery"
/>
</private-view>
<!-- @TODO: Render real 404 view here -->
@@ -63,7 +67,7 @@
</template>
<script lang="ts">
import { defineComponent, computed, ref, watch, toRefs } from '@vue/composition-api';
import { defineComponent, computed, ref, watch } from '@vue/composition-api';
import { NavigationGuard } from 'vue-router';
import CollectionsNavigation from '../../components/navigation/';
import useCollectionsStore from '@/stores/collections';
@@ -72,6 +76,8 @@ import useProjectsStore from '@/stores/projects';
import { i18n } from '@/lang';
import api from '@/api';
import { LayoutComponent } from '@/layouts/types';
import useCollectionPresetsStore from '@/stores/collection-presets';
import { debounce, clone } from 'lodash';
const redirectIfNeeded: NavigationGuard = async (to, from, next) => {
const collectionsStore = useCollectionsStore();
@@ -116,44 +122,18 @@ export default defineComponent({
},
setup(props) {
const layout = ref<LayoutComponent>(null);
const collectionsStore = useCollectionsStore();
const fieldsStore = useFieldsStore();
const projectsStore = useProjectsStore();
const collectionPresetsStore = useCollectionPresetsStore();
const { currentProjectKey } = toRefs(projectsStore.state);
const primaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(props.collection);
const selection = ref<Item[]>([]);
// Whenever the collection changes we're working on, we have to clear the selection
watch(
() => props.collection,
() => (selection.value = [])
);
const breadcrumb = [
{
name: i18n.tc('collection', 2),
to: `/${currentProjectKey.value}/collections`,
},
];
const currentCollection = computed(() => collectionsStore.getCollection(props.collection));
const addNewLink = computed<string>(
() => `/${currentProjectKey}/collections/${props.collection}/+`
);
const batchLink = computed<string>(() => {
const batchPrimaryKeys = selection.value
.map((item) => item[primaryKeyField.field])
.join();
return `/${currentProjectKey}/collections/${props.collection}/${batchPrimaryKeys}`;
});
const confirmDelete = ref(false);
const deleting = ref(false);
const { selection } = useSelection();
const { currentCollection, primaryKeyField } = useCollectionInfo();
const { addNewLink, batchLink } = useLinks();
const { viewOptions, viewQuery } = useCollectionPreset();
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
const { breadcrumb } = useBreadcrumb();
return {
currentCollection,
@@ -165,24 +145,147 @@ export default defineComponent({
batchDelete,
deleting,
layout,
viewOptions,
viewQuery,
};
async function batchDelete() {
deleting.value = true;
function useSelection() {
const selection = ref<Item[]>([]);
confirmDelete.value = false;
// Whenever the collection changes we're working on, we have to clear the selection
watch(
() => props.collection,
() => (selection.value = [])
);
const batchPrimaryKeys = selection.value
.map((item) => item[primaryKeyField.field])
.join();
return { selection };
}
await api.delete(`/${currentProjectKey}/items/${props.collection}/${batchPrimaryKeys}`);
function useCollectionInfo() {
const currentCollection = computed(() =>
collectionsStore.getCollection(props.collection)
);
const primaryKeyField = computed(() =>
fieldsStore.getPrimaryKeyFieldForCollection(props.collection)
);
await layout.value?.refresh();
return { currentCollection, primaryKeyField };
}
selection.value = [];
deleting.value = false;
confirmDelete.value = false;
function useBatchDelete() {
const confirmDelete = ref(false);
const deleting = ref(false);
return { confirmDelete, deleting, batchDelete };
async function batchDelete() {
const currentProjectKey = projectsStore.state.currentProjectKey;
deleting.value = true;
confirmDelete.value = false;
const batchPrimaryKeys = selection.value
.map((item) => item[primaryKeyField.value.field])
.join();
await api.delete(
`/${currentProjectKey}/items/${props.collection}/${batchPrimaryKeys}`
);
await layout.value?.refresh();
selection.value = [];
deleting.value = false;
confirmDelete.value = false;
}
}
function useLinks() {
const addNewLink = computed<string>(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
return `/${currentProjectKey}/collections/${props.collection}/+`;
});
const batchLink = computed<string>(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
const batchPrimaryKeys = selection.value
.map((item) => item[primaryKeyField.value.field])
.join();
return `/${currentProjectKey}/collections/${props.collection}/${batchPrimaryKeys}`;
});
return { addNewLink, batchLink };
}
function useCollectionPreset() {
const savePreset = debounce(collectionPresetsStore.savePreset, 450);
const localPreset = ref({
...collectionPresetsStore.getPresetForCollection(props.collection),
});
watch(
() => localPreset.value,
(newPreset) => {
savePreset(newPreset);
}
);
watch(
() => props.collection,
() => {
localPreset.value = {
...collectionPresetsStore.getPresetForCollection(props.collection),
};
}
);
const viewOptions = computed({
get() {
return localPreset.value.view_options?.[localPreset.value.view_type] || null;
},
set(val) {
localPreset.value = {
...localPreset.value,
view_options: {
...localPreset.value.view_options,
[localPreset.value.view_type]: val,
},
};
},
});
const viewQuery = computed({
get() {
return localPreset.value.view_query?.[localPreset.value.view_type] || null;
},
set(val) {
localPreset.value = {
...localPreset.value,
view_query: {
...localPreset.value.view_query,
[localPreset.value.view_type]: val,
},
};
},
});
return { viewOptions, viewQuery };
}
function useBreadcrumb() {
const breadcrumb = computed(() => {
const currentProjectKey = projectsStore.state.currentProjectKey;
return [
{
name: i18n.tc('collection', 2),
to: `/${currentProjectKey}/collections`,
},
];
});
return { breadcrumb };
}
},
});

View File

@@ -1,20 +1,26 @@
import Vue from 'vue';
import VueCompositionAPI from '@vue/composition-api';
import { useUserStore } from '@/stores/user/';
import { useProjectsStore } from '@/stores/projects/';
import { useCollectionPresetsStore } from './collection-presets';
import defaultCollectionPreset from './default-collection-preset';
import api from '@/api';
import mountComposition from '../../../.jest/mount-composition';
jest.mock('@/api');
describe('Compositions / Collection Presets', () => {
let req: any;
beforeAll(() => {
Vue.use(VueCompositionAPI);
});
beforeEach(() => {
req = {};
});
describe('Hydrate', () => {
it('Calls api.get with the correct parameters', () => {
it('Calls api.get with the correct parameters', async () => {
(api.get as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
@@ -23,51 +29,47 @@ describe('Compositions / Collection Presets', () => {
})
);
mountComposition(async () => {
const userStore = useUserStore(req);
(userStore.state.currentUser as any) = { id: 15, role: 25 };
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
const collectionPresetsStore = useCollectionPresetsStore(req);
const userStore = useUserStore(req);
(userStore.state.currentUser as any) = { id: 15, role: 25 };
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
const collectionPresetsStore = useCollectionPresetsStore(req);
await collectionPresetsStore.hydrate();
await collectionPresetsStore.hydrate();
expect(api.get).toHaveBeenCalledWith(`/my-project/collection_presets`, {
params: {
'filter[user][eq]': 15,
},
});
expect(api.get).toHaveBeenCalledWith(`/my-project/collection_presets`, {
params: {
'filter[user][eq]': 15,
},
});
expect(api.get).toHaveBeenCalledWith(`/my-project/collection_presets`, {
params: {
'filter[role][eq]': 25,
'filter[user][null]': 1,
},
});
expect(api.get).toHaveBeenCalledWith(`/my-project/collection_presets`, {
params: {
'filter[role][eq]': 25,
'filter[user][null]': 1,
},
});
expect(api.get).toHaveBeenCalledWith(`/my-project/collection_presets`, {
params: {
'filter[role][null]': 1,
'filter[user][null]': 1,
},
});
expect(api.get).toHaveBeenCalledWith(`/my-project/collection_presets`, {
params: {
'filter[role][null]': 1,
'filter[user][null]': 1,
},
});
});
});
describe('Dehydrate', () => {
it('Calls reset', () => {
mountComposition(async () => {
const collectionPresetsStore = useCollectionPresetsStore(req);
jest.spyOn(collectionPresetsStore as any, 'reset');
await collectionPresetsStore.dehydrate();
expect(collectionPresetsStore.reset).toHaveBeenCalled();
});
it('Calls reset', async () => {
const collectionPresetsStore = useCollectionPresetsStore(req);
jest.spyOn(collectionPresetsStore as any, 'reset');
await collectionPresetsStore.dehydrate();
expect(collectionPresetsStore.reset).toHaveBeenCalled();
});
});
describe('Create Preset', () => {
it('Calls the right endpoint', () => {
it('Calls the right endpoint', async () => {
(api.post as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
@@ -76,24 +78,22 @@ describe('Compositions / Collection Presets', () => {
})
);
mountComposition(async () => {
const collectionPresetsStore = useCollectionPresetsStore(req);
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
const collectionPresetsStore = useCollectionPresetsStore(req);
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
await collectionPresetsStore.createCollectionPreset({
title: 'test',
});
await collectionPresetsStore.create({
title: 'test',
});
expect(api.post).toHaveBeenCalledWith('/my-project/collection_presets', {
title: 'test',
});
expect(api.post).toHaveBeenCalledWith('/my-project/collection_presets', {
title: 'test',
});
});
});
describe('Update Preset', () => {
it('Calls the right endpoint', () => {
it('Calls the right endpoint', async () => {
(api.patch as jest.Mock).mockImplementation(() =>
Promise.resolve({
data: {
@@ -102,34 +102,252 @@ describe('Compositions / Collection Presets', () => {
})
);
mountComposition(async () => {
const collectionPresetsStore = useCollectionPresetsStore(req);
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
const collectionPresetsStore = useCollectionPresetsStore(req);
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
await collectionPresetsStore.updateCollectionPreset(15, {
title: 'test',
});
await collectionPresetsStore.update(15, {
title: 'test',
});
expect(api.patch).toHaveBeenCalledWith('/my-project/collection_presets/15', {
title: 'test',
});
expect(api.patch).toHaveBeenCalledWith('/my-project/collection_presets/15', {
title: 'test',
});
});
});
describe('Delete Preset', () => {
it('Calls the right endpoint', () => {
it('Calls the right endpoint', async () => {
(api.delete as jest.Mock).mockImplementation(() => Promise.resolve());
mountComposition(async () => {
const collectionPresetsStore = useCollectionPresetsStore(req);
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
const collectionPresetsStore = useCollectionPresetsStore(req);
const projectsStore = useProjectsStore(req);
projectsStore.state.currentProjectKey = 'my-project';
await (collectionPresetsStore as any).deleteCollectionPreset(15);
await (collectionPresetsStore as any).delete(15);
expect(api.delete).toHaveBeenCalledWith('/my-project/collection_presets/15');
expect(api.delete).toHaveBeenCalledWith('/my-project/collection_presets/15');
});
});
describe('Get Collection Preset for Collection', () => {
it('Returns null if userStore currentUser is null', () => {
const userStore = useUserStore(req);
userStore.state.currentUser = null;
const collectionPresetsStore = useCollectionPresetsStore(req);
const preset = collectionPresetsStore.getPresetForCollection('articles');
expect(preset).toBe(null);
});
it('Returns the default preset if there are no available presets', () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5, role: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
const preset = collectionPresetsStore.getPresetForCollection('articles');
expect(preset).toEqual({
...defaultCollectionPreset,
collection: 'articles',
});
});
it('Ignores bookmarks', () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5, role: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
collectionPresetsStore.state.collectionPresets = [
{
collection: 'articles',
user: null,
role: null,
title: 'should be ignored',
},
] as any;
const preset = collectionPresetsStore.getPresetForCollection('articles');
expect(preset).toEqual({
...defaultCollectionPreset,
collection: 'articles',
});
});
it('Returns the preset immediately if there is only 1', () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5, role: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
collectionPresetsStore.state.collectionPresets = [
{
collection: 'articles',
user: null,
role: null,
},
] as any;
const preset = collectionPresetsStore.getPresetForCollection('articles');
expect(preset).toEqual({
collection: 'articles',
user: null,
role: null,
});
});
it('Prefers the user preset if it exists', () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5, role: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
collectionPresetsStore.state.collectionPresets = [
{
collection: 'articles',
user: null,
role: 5,
},
{
collection: 'articles',
user: 5,
role: null,
},
{
collection: 'articles',
user: null,
role: null,
},
] as any;
const preset = collectionPresetsStore.getPresetForCollection('articles');
expect(preset).toEqual({
collection: 'articles',
user: 5,
role: null,
});
});
it('Prefers the role preset if user does not exist', () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5, role: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
collectionPresetsStore.state.collectionPresets = [
{
collection: 'articles',
user: null,
role: null,
},
{
collection: 'articles',
user: null,
role: 5,
},
] as any;
const preset = collectionPresetsStore.getPresetForCollection('articles');
expect(preset).toEqual({
collection: 'articles',
user: null,
role: 5,
});
});
it('Returns the last collection preset if more than 1 exist', () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5, role: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
collectionPresetsStore.state.collectionPresets = [
{
collection: 'articles',
user: null,
role: null,
test: false,
},
{
collection: 'articles',
user: null,
role: null,
test: false,
},
{
collection: 'articles',
user: null,
role: null,
test: true,
},
] as any;
const preset = collectionPresetsStore.getPresetForCollection('articles');
expect(preset).toEqual({
collection: 'articles',
user: null,
role: null,
test: true,
});
});
});
describe('Save Preset', () => {
it('Returns null immediately if userStore is empty', async () => {
const userStore = useUserStore(req);
userStore.state.currentUser = null;
const collectionPresetsStore = useCollectionPresetsStore(req);
const result = await collectionPresetsStore.savePreset();
expect(result).toBe(null);
});
it('Calls create if id is undefined or null', async () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
jest.spyOn(collectionPresetsStore, 'create').mockImplementation(() => ({}));
await collectionPresetsStore.savePreset({
id: undefined,
});
expect(collectionPresetsStore.create).toHaveBeenCalledWith({ id: undefined, user: 5 });
await collectionPresetsStore.savePreset({
id: null,
});
expect(collectionPresetsStore.create).toHaveBeenCalledWith({ id: null, user: 5 });
});
it('Calls create when the user is not the current user', async () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
jest.spyOn(collectionPresetsStore, 'create').mockImplementation(() => ({}));
await collectionPresetsStore.savePreset({
id: 15,
test: 'value',
user: null,
});
expect(collectionPresetsStore.create).toHaveBeenCalledWith({ test: 'value', user: 5 });
});
it('Calls update if the user field is already set', async () => {
const userStore = useUserStore(req);
userStore.state.currentUser = { id: 5 } as any;
const collectionPresetsStore = useCollectionPresetsStore(req);
jest.spyOn(collectionPresetsStore, 'update').mockImplementation(() => ({}));
await collectionPresetsStore.savePreset({
id: 15,
test: 'value',
user: 5,
});
expect(collectionPresetsStore.update).toHaveBeenCalledWith(15, {
test: 'value',
user: 5,
});
});
});

View File

@@ -4,6 +4,8 @@ import { useUserStore } from '@/stores/user/';
import { useProjectsStore } from '@/stores/projects/';
import api from '@/api';
import defaultCollectionPreset from './default-collection-preset';
export const useCollectionPresetsStore = createStore({
id: 'collectionPresetsStore',
state: () => ({
@@ -44,14 +46,14 @@ export const useCollectionPresetsStore = createStore({
async dehydrate() {
this.reset();
},
async createCollectionPreset(newPreset: Partial<CollectionPreset>) {
async create(newPreset: Partial<CollectionPreset>) {
const { currentProjectKey } = useProjectsStore().state;
const response = await api.post(`/${currentProjectKey}/collection_presets`, newPreset);
this.state.collectionPresets.push(response.data.data);
},
async updateCollectionPreset(id: number, updates: Partial<CollectionPreset>) {
async update(id: number, updates: Partial<CollectionPreset>) {
const { currentProjectKey } = useProjectsStore().state;
const response = await api.patch(
@@ -68,7 +70,7 @@ export const useCollectionPresetsStore = createStore({
return preset;
});
},
async deleteCollectionPreset(id: number) {
async delete(id: number) {
const { currentProjectKey } = useProjectsStore().state;
await api.delete(`/${currentProjectKey}/collection_presets/${id}`);
@@ -77,5 +79,86 @@ export const useCollectionPresetsStore = createStore({
return preset.id !== id;
});
},
/**
* Retrieves the most specific preset that applies to the given collection for the current
* user. If the user doesn't have a preset for this collection, it will fallback to the
* role and collection presets respectivly.
*/
getPresetForCollection(collection: string) {
const userStore = useUserStore();
if (userStore.state.currentUser === null) return null;
const { id: userID, role: userRole } = userStore.state.currentUser;
const defaultPreset = {
...defaultCollectionPreset,
collection: collection,
};
const availablePresets = this.state.collectionPresets.filter((preset) => {
const userMatches = preset.user === userID || preset.user === null;
const roleMatches = preset.role === userRole || preset.role === null;
const collectionMatches = preset.collection === collection;
// Filter out all bookmarks
if (preset.title) return false;
if (userMatches && collectionMatches) return true;
if (roleMatches && collectionMatches) return true;
return false;
});
if (availablePresets.length === 0) return defaultPreset;
if (availablePresets.length === 1) return availablePresets[0];
// In order of specificity: user-role-collection
const userPreset = availablePresets.find((preset) => preset.user === userID);
if (userPreset) return userPreset;
const rolePreset = availablePresets.find((preset) => preset.role === userRole);
if (rolePreset) return rolePreset;
// If the other two already came up empty, we can assume there's only one preset. That
// being said, as a safety precaution, we'll use the last saved preset in case there are
// duplicates in the DB
const collectionPreset = availablePresets[availablePresets.length - 1];
return collectionPreset;
},
/**
* Saves the given preset. If it's the default preset, it saves it as a new preset. If the
* preset already exists, but doesn't have a user associated, it will create a preset for
* the user. If the preset already exists and is for a user, we update the preset.
*/
async savePreset(preset: CollectionPreset) {
const userStore = useUserStore();
if (userStore.state.currentUser === null) return null;
const { id: userID } = userStore.state.currentUser;
// Clone the preset to make sure the future deletes don't affect the original object
preset = { ...preset };
if (preset.id === undefined || preset.id === null) {
return this.create({
...preset,
user: userID,
});
}
if (preset.user !== userID) {
if (preset.hasOwnProperty('id')) delete preset.id;
return this.create({
...preset,
user: userID,
});
} else {
const id = preset.id;
delete preset.id;
return this.update(id, preset);
}
},
},
});

View File

@@ -0,0 +1,14 @@
import { CollectionPreset } from './types';
const defaultCollectionPreset: Omit<CollectionPreset, 'collection'> = {
title: null,
role: null,
user: null,
search_query: null,
filters: null,
view_type: 'tabular',
view_query: null,
view_options: null,
};
export default defaultCollectionPreset;

View File

@@ -27,7 +27,7 @@ export type Filter = {
};
export type CollectionPreset = {
id: number;
id?: number;
title: string | null;
user: number | null;
role: number | null;

View File

@@ -22,10 +22,8 @@
<div class="spacer" />
<slot name="actions:prepend" />
<portal-target name="actions:prepend" />
<header-bar-actions @toggle:drawer="$emit('toggle:drawer')">
<slot name="actions" />
<portal-target name="actions" />
</header-bar-actions>
<slot name="actions:append" />
</header>

View File

@@ -46,7 +46,6 @@
<drawer-detail-group :drawer-open="drawerOpen">
<slot name="drawer" />
<portal-target name="drawer" />
</drawer-detail-group>
</aside>
@@ -84,12 +83,15 @@ export default defineComponent({
setup() {
const navOpen = ref(false);
const drawerOpen = ref(false);
const contentEl = ref<Element>();
provide('drawer-open', drawerOpen);
provide('main-element', contentEl);
return {
navOpen,
drawerOpen,
contentEl,
};
},
});