mirror of
https://github.com/directus/directus.git
synced 2026-01-27 18:38:05 -05:00
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:
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
src/compositions/use-sync/index.ts
Normal file
4
src/compositions/use-sync/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import useSync from './use-sync';
|
||||
|
||||
export { useSync };
|
||||
export default useSync;
|
||||
31
src/compositions/use-sync/readme.md
Normal file
31
src/compositions/use-sync/readme.md
Normal 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);
|
||||
```
|
||||
17
src/compositions/use-sync/use-sync.ts
Normal file
17
src/compositions/use-sync/use-sync.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
14
src/stores/collection-presets/default-collection-preset.ts
Normal file
14
src/stores/collection-presets/default-collection-preset.ts
Normal 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;
|
||||
@@ -27,7 +27,7 @@ export type Filter = {
|
||||
};
|
||||
|
||||
export type CollectionPreset = {
|
||||
id: number;
|
||||
id?: number;
|
||||
title: string | null;
|
||||
user: number | null;
|
||||
role: number | null;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user