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:
Rijk van Zanten
2020-03-20 17:05:55 -04:00
committed by GitHub
parent a141e3a6ea
commit 3ab97ca2b2
51 changed files with 1213 additions and 431 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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);
});
});

View File

@@ -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>

View File

@@ -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');
});
});

View File

@@ -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>