mirror of
https://github.com/directus/directus.git
synced 2026-02-08 19:25:06 -05:00
Collections module additions (#201)
* Render add new link, only render delete on isnew is false * Add header actions buttons based on state * Add header buttons and breadcrumbs * Style tweaks * Add navigation guard for single collections * Add delete button logic * Add ability to delete items on browse * Add select mode to tabular layout * Add saving / deleting logic to detail view * remove tests (temporarily) * Remove empty tests temporarily * Add pagination to tabular layout if collection is large * Add server sort * wip table tweaks * show shadow only on scroll, fix padding on top of private view. * Update table * fix header hiding the scrollbar * Fix rAF leak * Make pagination sticky * fix double scroll bug * add selfScroll prop to private view * Last try * Lower the default limit * Fix tests for table / private / public view * finish header * remove unnessesary code * Fix debug overflow + icon alignment * Fix breadcrumbs * Fix item fetching * browse view now collapses on scroll * Add drawer-button component * Fix styling of drawer-button drawer-detail * Revert "browse view now collapses on scroll" This reverts commit a8534484d496deef01e399574126f7ba877e098c. * Final commit for the night * Add scroll solution for header overflow * Render table header over shadow * Add useScrollDistance compositoin * Add readme for scroll distance * Restructure header bar using sticky + margin / add shadow * Tweak box shadow to not show up at top on scroll up * Fix tests Co-authored-by: Nitwel <nitwel@arcor.de>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { computed } from '@vue/composition-api';
|
||||
import { useProjectsStore } from '@/stores/projects';
|
||||
import { useCollectionsStore } from '@/stores/collections';
|
||||
import { useProjectsStore } from '@/stores/projects/';
|
||||
import { useCollectionsStore } from '@/stores/collections/';
|
||||
import { Collection } from '@/stores/collections/types';
|
||||
import VueI18n from 'vue-i18n';
|
||||
|
||||
export type NavItem = {
|
||||
@@ -16,7 +17,7 @@ export default function useNavigation() {
|
||||
|
||||
const navItems = computed<NavItem[]>(() => {
|
||||
return collectionsStore.visibleCollections.value
|
||||
.map(collection => {
|
||||
.map((collection: Collection) => {
|
||||
const navItem: NavItem = {
|
||||
collection: collection.collection,
|
||||
name: collection.name,
|
||||
|
||||
@@ -9,15 +9,18 @@ export default defineModule({
|
||||
name: i18n.tc('collection', 2),
|
||||
routes: [
|
||||
{
|
||||
name: 'collections-overview',
|
||||
path: '/',
|
||||
component: CollectionsOverview
|
||||
},
|
||||
{
|
||||
name: 'collections-browse',
|
||||
path: '/:collection',
|
||||
component: CollectionsBrowse,
|
||||
props: true
|
||||
},
|
||||
{
|
||||
name: 'collections-detail',
|
||||
path: '/:collection/:primaryKey',
|
||||
component: CollectionsDetail,
|
||||
props: true
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import CollectionsBrowse from './browse.vue';
|
||||
import PrivateView from '@/views/private';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('private-view', PrivateView);
|
||||
|
||||
describe('Modules / Collections / Browse', () => {
|
||||
it('Renders', () => {
|
||||
const component = shallowMount(CollectionsBrowse, {
|
||||
localVue,
|
||||
propsData: {
|
||||
collection: 'my-test',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
});
|
||||
expect(component.isVueInstance()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,111 @@
|
||||
<template>
|
||||
<private-view v-if="currentCollection" :title="currentCollection.name">
|
||||
<template #title-outer:prepend>
|
||||
<v-button rounded disabled icon secondary>
|
||||
<v-icon :name="currentCollection.icon" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="breadcrumb" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-button rounded icon style="--v-button-background-color: var(--success);">
|
||||
<v-dialog v-model="confirmDelete">
|
||||
<template #activator="{ on }">
|
||||
<v-button
|
||||
rounded
|
||||
icon
|
||||
class="action-delete"
|
||||
v-if="selection.length > 0"
|
||||
@click="on"
|
||||
>
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>{{ $tc('batch_delete_confirm', selection.length) }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button @click="confirmDelete = false" secondary>
|
||||
{{ $t('cancel') }}
|
||||
</v-button>
|
||||
<v-button @click="batchDelete" class="action-delete" :loading="deleting">
|
||||
{{ $t('delete') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-button rounded icon class="action-batch" v-if="selection.length > 1" :to="batchLink">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
|
||||
<v-button rounded icon :to="addNewLink">
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
<v-button rounded icon style="--v-button-background-color: var(--warning);">
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
<v-button rounded icon style="--v-button-background-color: var(--danger);">
|
||||
<v-icon name="favorite" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<collections-navigation />
|
||||
</template>
|
||||
|
||||
<layout-tabular :collection="collection" />
|
||||
<layout-tabular
|
||||
class="layout"
|
||||
ref="layout"
|
||||
:collection="collection"
|
||||
:selection.sync="selection"
|
||||
/>
|
||||
</private-view>
|
||||
<!-- @TODO: Render real 404 view here -->
|
||||
<p v-else>Not found</p>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed } from '@vue/composition-api';
|
||||
import useCollectionsStore from '@/stores/collections';
|
||||
import { Collection } from '@/stores/collections/types';
|
||||
import { defineComponent, computed, ref, watch, toRefs } from '@vue/composition-api';
|
||||
import { NavigationGuard } from 'vue-router';
|
||||
import CollectionsNavigation from '../../components/navigation/';
|
||||
import useCollectionsStore from '@/stores/collections';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import { i18n } from '@/lang';
|
||||
import api from '@/api';
|
||||
import { LayoutComponent } from '@/layouts/types';
|
||||
|
||||
const redirectIfNeeded: NavigationGuard = async (to, from, next) => {
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const collectionInfo = collectionsStore.getCollection(to.params.collection);
|
||||
|
||||
if (collectionInfo.single === true) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const primaryKeyField = fieldsStore.getPrimaryKeyFieldForCollection(to.params.collection);
|
||||
|
||||
const item = await api.get(`/${to.params.project}/items/${to.params.collection}`, {
|
||||
params: {
|
||||
limit: 1,
|
||||
fields: primaryKeyField.field,
|
||||
single: true
|
||||
}
|
||||
});
|
||||
|
||||
const primaryKey = item.data.data[primaryKeyField.field];
|
||||
|
||||
return next(`/${to.params.project}/collections/${to.params.collection}/${primaryKey}`);
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
|
||||
type Item = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[field: string]: any;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
beforeRouteEnter: redirectIfNeeded,
|
||||
beforeRouteUpdate: redirectIfNeeded,
|
||||
name: 'collections-browse',
|
||||
components: { CollectionsNavigation },
|
||||
props: {
|
||||
@@ -38,15 +115,95 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const layout = ref<LayoutComponent>(null);
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const currentCollection = computed<Collection | null>(() => {
|
||||
return (
|
||||
collectionsStore.state.collections.find(
|
||||
collection => collection.collection === props.collection
|
||||
) || null
|
||||
);
|
||||
const fieldsStore = useFieldsStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
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}`;
|
||||
});
|
||||
return { currentCollection };
|
||||
|
||||
const confirmDelete = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
return {
|
||||
currentCollection,
|
||||
addNewLink,
|
||||
batchLink,
|
||||
selection,
|
||||
breadcrumb,
|
||||
confirmDelete,
|
||||
batchDelete,
|
||||
deleting,
|
||||
layout
|
||||
};
|
||||
|
||||
async function batchDelete() {
|
||||
deleting.value = true;
|
||||
|
||||
confirmDelete.value = false;
|
||||
|
||||
const batchPrimaryKeys = selection.value
|
||||
.map(item => item[primaryKeyField.field])
|
||||
.join();
|
||||
|
||||
await api.delete(`/${currentProjectKey}/items/${props.collection}/${batchPrimaryKeys}`);
|
||||
|
||||
await layout.value?.refresh();
|
||||
|
||||
selection.value = [];
|
||||
deleting.value = false;
|
||||
confirmDelete.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.private-view {
|
||||
--private-view-content-padding: 0 !important;
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
--v-button-background-color: var(--danger);
|
||||
--v-button-background-color-hover: var(--danger-dark);
|
||||
}
|
||||
|
||||
.action-batch {
|
||||
--v-button-background-color: var(--warning);
|
||||
--v-button-background-color-hover: var(--warning-dark);
|
||||
}
|
||||
|
||||
.layout {
|
||||
--layout-offset-top: 64px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { createLocalVue, shallowMount } from '@vue/test-utils';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import CollectionsDetail from './detail.vue';
|
||||
import PrivateView from '@/views/private';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('private-view', PrivateView);
|
||||
|
||||
describe('Modules / Collections / Detail', () => {
|
||||
it('Renders', () => {
|
||||
const component = shallowMount(CollectionsDetail, {
|
||||
localVue,
|
||||
propsData: {
|
||||
collection: 'my-test',
|
||||
primaryKey: 'id'
|
||||
}
|
||||
});
|
||||
expect(component.isVueInstance()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,50 @@
|
||||
<template>
|
||||
<private-view title="Edit">
|
||||
<private-view :title="$t('editing', { collection: currentCollection.name })">
|
||||
<template #title-outer:prepend>
|
||||
<v-button rounded icon secondary exact :to="breadcrumb[1].to">
|
||||
<v-icon name="arrow_back" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="breadcrumb" />
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-dialog v-model="confirmDelete">
|
||||
<template #activator="{ on }">
|
||||
<v-button rounded icon class="action-delete" @click="on">
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>{{ $t('delete_are_you_sure') }}</v-card-title>
|
||||
|
||||
<v-card-actions>
|
||||
<v-button @click="confirmDelete = false" secondary>
|
||||
{{ $t('cancel') }}
|
||||
</v-button>
|
||||
<v-button @click="deleteAndQuit" class="action-delete" :loading="deleting">
|
||||
{{ $t('delete') }}
|
||||
</v-button>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-button
|
||||
rounded
|
||||
icon
|
||||
:loading="saving"
|
||||
:disabled="hasEdits === false"
|
||||
@click="saveAndQuit"
|
||||
>
|
||||
<v-icon name="check" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template v-if="item">
|
||||
<v-form :initial-values="item" :collection="collection" />
|
||||
<v-form :initial-values="item" :collection="collection" v-model="edits" />
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
@@ -11,12 +54,20 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, ref } from '@vue/composition-api';
|
||||
import { defineComponent, computed, ref, toRefs } from '@vue/composition-api';
|
||||
import useProjectsStore from '@/stores/projects';
|
||||
import useFieldsStore from '@/stores/fields';
|
||||
import { Field } from '@/stores/fields/types';
|
||||
import api from '@/api';
|
||||
import CollectionsNavigation from '../../components/navigation/';
|
||||
import useCollectionsStore from '../../../../stores/collections';
|
||||
import { i18n } from '@/lang';
|
||||
import router from '@/router';
|
||||
|
||||
type Values = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[field: string]: any;
|
||||
};
|
||||
|
||||
export default defineComponent({
|
||||
name: 'collections-detail',
|
||||
@@ -32,26 +83,76 @@ export default defineComponent({
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const { currentProjectKey } = useProjectsStore().state;
|
||||
const projectsStore = useProjectsStore();
|
||||
const collectionsStore = useCollectionsStore();
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const { currentProjectKey } = toRefs(projectsStore.state);
|
||||
|
||||
const isNew = computed<boolean>(() => props.primaryKey === '+');
|
||||
|
||||
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)
|
||||
.sort((a, b) => (a.sort || Infinity) - (b.sort || Infinity));
|
||||
});
|
||||
const item = ref(null);
|
||||
const loading = ref(false);
|
||||
|
||||
const item = ref<Values>(null);
|
||||
const error = ref(null);
|
||||
fetchItem();
|
||||
return { visibleFields, item, loading, error };
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const deleting = ref(false);
|
||||
const confirmDelete = ref(false);
|
||||
|
||||
const currentCollection = collectionsStore.getCollection(props.collection);
|
||||
|
||||
if (isNew.value === true) {
|
||||
useDefaultValues();
|
||||
} else {
|
||||
fetchItem();
|
||||
}
|
||||
|
||||
const breadcrumb = computed(() => [
|
||||
{
|
||||
name: i18n.tc('collection', 2),
|
||||
to: `/${currentProjectKey.value}/collections/`
|
||||
},
|
||||
{
|
||||
name: currentCollection.name,
|
||||
to: `/${currentProjectKey.value}/collections/${props.collection}/`
|
||||
}
|
||||
]);
|
||||
|
||||
const edits = ref({});
|
||||
|
||||
const hasEdits = computed<boolean>(() => Object.keys(edits.value).length > 0);
|
||||
|
||||
return {
|
||||
visibleFields,
|
||||
item,
|
||||
loading,
|
||||
error,
|
||||
isNew,
|
||||
currentCollection,
|
||||
breadcrumb,
|
||||
edits,
|
||||
hasEdits,
|
||||
saveAndQuit,
|
||||
saving,
|
||||
deleting,
|
||||
deleteAndQuit,
|
||||
confirmDelete
|
||||
};
|
||||
|
||||
async function fetchItem() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`
|
||||
`/${currentProjectKey.value}/items/${props.collection}/${props.primaryKey}`
|
||||
);
|
||||
item.value = response.data.data;
|
||||
} catch (error) {
|
||||
@@ -60,6 +161,66 @@ export default defineComponent({
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function useDefaultValues() {
|
||||
const defaults: Values = {};
|
||||
|
||||
visibleFields.value.forEach(field => {
|
||||
defaults[field.field] = field.default_value;
|
||||
});
|
||||
|
||||
item.value = defaults;
|
||||
}
|
||||
|
||||
async function saveAndQuit() {
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
if (isNew.value === true) {
|
||||
await api.post(`/${currentProjectKey}/items/${props.collection}`, edits.value);
|
||||
} else {
|
||||
await api.patch(
|
||||
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`,
|
||||
edits.value
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
/** @TODO show real notification */
|
||||
alert(error);
|
||||
} finally {
|
||||
saving.value = true;
|
||||
router.push(`/${currentProjectKey}/collections/${props.collection}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAndQuit() {
|
||||
if (isNew.value === true) return;
|
||||
|
||||
deleting.value = true;
|
||||
|
||||
try {
|
||||
await api.delete(
|
||||
`/${currentProjectKey}/items/${props.collection}/${props.primaryKey}`
|
||||
);
|
||||
} catch (error) {
|
||||
/** @TODO show real notification */
|
||||
alert(error);
|
||||
} finally {
|
||||
router.push(`/${currentProjectKey}/collections/${props.collection}`);
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.action-delete {
|
||||
--v-button-background-color: var(--danger);
|
||||
--v-button-background-color-hover: var(--danger-dark);
|
||||
}
|
||||
|
||||
.v-form {
|
||||
padding: var(--content-padding);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import CollectionsOverview from './overview.vue';
|
||||
import VueCompositionAPI from '@vue/composition-api';
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import useNavigation from '../../compositions/use-navigation';
|
||||
import VTable from '@/components/v-table';
|
||||
import PrivateView from '@/views/private';
|
||||
import router from '@/router';
|
||||
|
||||
jest.mock('../../compositions/use-navigation');
|
||||
jest.mock('@/router');
|
||||
|
||||
const localVue = createLocalVue();
|
||||
localVue.use(VueCompositionAPI);
|
||||
localVue.component('v-table', VTable);
|
||||
localVue.component('private-view', PrivateView);
|
||||
|
||||
describe('Modules / Collections / Routes / CollectionsOverview', () => {
|
||||
beforeEach(() => {
|
||||
(useNavigation as jest.Mock).mockImplementation(() => ({
|
||||
navItems: []
|
||||
}));
|
||||
});
|
||||
|
||||
it('Uses useNavigation to get navigation links', () => {
|
||||
shallowMount(CollectionsOverview, {
|
||||
localVue,
|
||||
mocks: {
|
||||
$tc: () => 'title'
|
||||
}
|
||||
});
|
||||
expect(useNavigation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Calls router.push on navigation', () => {
|
||||
const component = shallowMount(CollectionsOverview, {
|
||||
localVue,
|
||||
mocks: {
|
||||
$tc: () => 'title'
|
||||
}
|
||||
});
|
||||
(component.vm as any).navigateToCollection({
|
||||
collection: 'test',
|
||||
name: 'Test',
|
||||
icon: 'box',
|
||||
to: '/test-route'
|
||||
});
|
||||
|
||||
expect(router.push).toHaveBeenCalledWith('/test-route');
|
||||
});
|
||||
});
|
||||
@@ -59,5 +59,9 @@ export default defineComponent({
|
||||
<style lang="scss" scoped>
|
||||
.icon {
|
||||
--v-icon-color: var(--foreground-color-secondary);
|
||||
|
||||
::v-deep i {
|
||||
vertical-align: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user