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

@@ -22,14 +22,11 @@ const defaultError: Error = {
};
describe('API', () => {
beforeAll(() => {
globalThis.window = Object.create(window);
Vue.use(VueCompositionAPI);
});
beforeEach(() => {
jest.spyOn(auth, 'logout');
jest.spyOn(auth, 'checkAuth');
Vue.use(VueCompositionAPI);
window = Object.create(window);
});
it('Calculates the correct API root URL based on window', () => {

View File

@@ -2,8 +2,11 @@ import Vue from 'vue';
import VAvatar from './v-avatar/';
import VButton from './v-button/';
import VBreadcrumb from './v-breadcrumb';
import VCard, { VCardActions, VCardTitle, VCardSubtitle, VCardText } from './v-card';
import VCheckbox from './v-checkbox/';
import VChip from './v-chip/';
import VDialog from './v-dialog';
import VForm from './v-form';
import VHover from './v-hover/';
import VIcon from './v-icon/';
@@ -30,8 +33,15 @@ import VTabs, { VTab, VTabsItems, VTabItem } from './v-tabs/';
Vue.component('v-avatar', VAvatar);
Vue.component('v-button', VButton);
Vue.component('v-breadcrumb', VBreadcrumb);
Vue.component('v-card', VCard);
Vue.component('v-card-title', VCardTitle);
Vue.component('v-card-subtitle', VCardSubtitle);
Vue.component('v-card-text', VCardText);
Vue.component('v-card-actions', VCardActions);
Vue.component('v-checkbox', VCheckbox);
Vue.component('v-chip', VChip);
Vue.component('v-dialog', VDialog);
Vue.component('v-form', VForm);
Vue.component('v-hover', VHover);
Vue.component('v-icon', VIcon);

View File

@@ -2,6 +2,7 @@
<component
:is="component"
:active-class="to ? 'activated' : null"
:exact="exact"
class="v-button"
:class="[
sizeClass,
@@ -63,6 +64,10 @@ export default defineComponent({
type: [String, Object] as PropType<string | Location>,
default: null
},
exact: {
type: Boolean,
default: false
},
secondary: {
type: Boolean,
default: false

View File

@@ -1,4 +1,8 @@
import VCard from './v-card.vue';
import VCardTitle from './v-card-title.vue';
import VCardSubtitle from './v-card-subtitle.vue';
import VCardText from './v-card-text.vue';
import VCardActions from './v-card-actions.vue';
export { VCard };
export { VCard, VCardTitle, VCardSubtitle, VCardActions, VCardText };
export default VCard;

View File

@@ -10,7 +10,6 @@
flex-wrap: wrap;
align-items: center;
padding: var(--v-card-padding);
word-break: break-all;
@include type-card-title;
}

View File

@@ -30,4 +30,6 @@ Renders an overlay with a flex slot rendering whatever content you pass
| `toggle` | Change the active state | `boolean` |
## CSS Variables
n/a
| Variable | Default |
|----------------------|---------|
| `--v-dialog-z-index` | `100` |

View File

@@ -55,10 +55,15 @@ export default defineComponent({
<style lang="scss" scoped>
.v-dialog {
--v-dialog-z-index: 100;
display: contents;
.container {
position: fixed;
top: 0;
left: 0;
z-index: var(--v-dialog-z-index);
display: flex;
align-items: center;
justify-content: center;
@@ -75,15 +80,16 @@ export default defineComponent({
.v-sheet {
--v-sheet-padding: 24px;
--v-sheet-max-width: 560px;
}
.v-overlay {
--v-overlay-z-index: 100;
--v-overlay-z-index: 1;
}
.content {
position: relative;
z-index: 105;
z-index: 2;
max-height: 90%;
transform: translateY(50px);
opacity: 0;

View File

@@ -2,7 +2,11 @@
<div class="v-form" ref="el" :class="gridClass">
<div v-for="field in formFields" class="field" :key="field.field" :class="field.width">
<label>{{ field.name }}</label>
<interface-text-input :value="initialValues[field.field]" :options="field.options" />
<interface-text-input
:value="values[field.field]"
:options="field.options"
@input="onInput(field, $event)"
/>
</div>
</div>
</template>
@@ -10,8 +14,10 @@
<script lang="ts">
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
import { useFieldsStore } from '@/stores/fields';
import { Field } from '@/stores/fields/types';
import { useElementSize } from '@/compositions/use-element-size';
import { isEmpty } from '@/utils/is-empty';
import { clone } from 'lodash';
type FieldValues = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -19,6 +25,9 @@ type FieldValues = {
};
export default defineComponent({
model: {
prop: 'edits'
},
props: {
collection: {
type: String,
@@ -33,7 +42,7 @@ export default defineComponent({
default: null
}
},
setup(props) {
setup(props, { emit }) {
const el = ref<Element>(null);
const fieldsStore = useFieldsStore();
@@ -93,7 +102,18 @@ export default defineComponent({
return null;
});
return { el, width, formFields, gridClass };
const values = computed(() => {
return Object.assign({}, props.initialValues, props.edits);
});
return { el, width, formFields, gridClass, values, onInput };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function onInput(field: Field, value: any) {
const edits = clone(props.edits);
edits[field.field] = value;
emit('input', edits);
}
}
});
</script>

View File

@@ -111,16 +111,21 @@ export default defineComponent({
```
## Props
| Prop | Description | Default |
|----------------|---------------------------------------------------------------------|---------|
| `headers`* | What columns to show in the table. Supports the `.sync` modifier | -- |
| `items`* | The individual items to render as rows | -- |
| `item-key` | Primary key of the item. Used for keys / selections | `id` |
| `sort-by` | What column / order to sort by. Supports the `.sync` modifier | -- |
| `show-select` | Show checkboxes | `false` |
| `show-resize` | Show resize handlers | `false` |
| `selection` | What items are selected. Can be used with `v-model` as well | `[]` |
| `fixed-header` | Make the header fixed | `false` |
| Prop | Description | Default |
|--------------------|------------------------------------------------------------------------------------------------|--------------|
| `headers`* | What columns to show in the table. Supports the `.sync` modifier | -- |
| `items`* | The individual items to render as rows | -- |
| `item-key` | Primary key of the item. Used for keys / selections | `id` |
| `sort` | What column / order to sort by. Supports the `.sync` modifier. `{ by: string, desc: boolean }` | -- |
| `show-select` | Show checkboxes | `false` |
| `show-resize` | Show resize handlers | `false` |
| `show-manual-sort` | Show manual sort drag handles | `false` |
| `selection` | What items are selected. Can be used with `v-model` as well | `[]` |
| `fixed-header` | Make the header fixed | `false` |
| `loading` | Show progress indicator | `false` |
| `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` |
## Events
| Event | Description | Value |

View File

@@ -0,0 +1,4 @@
import TableHeader from './table-header.vue';
export { TableHeader };
export default TableHeader;

View File

@@ -4,13 +4,13 @@ import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VCheckbox from '../v-checkbox/';
import VIcon from '../v-icon/';
import VCheckbox from '@/components/v-checkbox';
import VIcon from '@/components/v-icon';
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
import TableHeader from './_table-header.vue';
import TableHeader from './table-header.vue';
describe('Table / Header', () => {
let component: Wrapper<Vue>;
@@ -185,27 +185,6 @@ describe('Table / Header', () => {
expect(component.find('th:nth-child(3)').classes()).toContain('align-right');
});
it('Generates the correct inline styles for column widths', async () => {
component.setProps({
headers: [],
sortDesc: false
});
await component.vm.$nextTick();
const styles = (component.vm as any).getStyleForHeader({
text: 'Col2',
value: 'col2',
align: 'center',
sortable: true,
width: 150
});
expect(styles).toEqual({
width: '150px'
});
});
it('Renders the provided element in the nested scoped slot for the header', async () => {
const component = mount(TableHeader, {
localVue,
@@ -230,7 +209,7 @@ describe('Table / Header', () => {
}
});
expect(component.find('.v-table_table-header th:nth-child(2) .content > *').html()).toEqual(
expect(component.find('.table-header th:nth-child(2) .content > span > *').html()).toEqual(
'<p>Column 2</p>'
);
});
@@ -355,62 +334,4 @@ describe('Table / Header', () => {
expect(component.emitted('update:headers')).toBe(undefined);
});
it('Calculates the right width CSS property based on header', async () => {
component.setProps({
headers: [
{
text: 'Col1',
value: 'col1',
align: 'left',
sortable: true
},
{
text: 'Col2',
value: 'col2',
align: 'left',
sortable: true,
width: 175
},
{
text: 'Col3',
value: 'col3',
align: 'left',
sortable: true,
width: 250
}
]
});
await component.vm.$nextTick();
const { getStyleForHeader } = component.vm as any;
expect(
getStyleForHeader(
{
width: null
},
0
)
).toEqual(null);
expect(
getStyleForHeader(
{
width: 175
},
1
)
).toEqual({ width: '175px' });
expect(
getStyleForHeader(
{
width: 175
},
2
)
).toEqual({ width: 'auto' });
});
});

View File

@@ -1,9 +1,10 @@
<template>
<thead class="v-table_table-header" :class="{ dragging }">
<thead class="table-header" :class="{ dragging }">
<tr :class="{ fixed }">
<th v-if="showManualSort" class="manual cell" @click="toggleManualSort" scope="col">
<v-icon name="sort" class="drag-handle" small />
</th>
<th v-if="showSelect" class="select cell" scope="col">
<v-checkbox
:inputValue="allItemsSelected"
@@ -11,16 +12,20 @@
@change="toggleSelectAll"
/>
</th>
<th
v-for="(header, index) in headers"
:key="header.value"
:class="getClassesForHeader(header)"
:style="getStyleForHeader(header, index)"
class="cell"
scope="col"
>
<div class="content" @click="changeSort(header)">
<slot :name="`header.${header.value}`" :header="header">{{ header.text }}</slot>
<span v-show="header.width > 90 || header.width === null">
<slot :name="`header.${header.value}`" :header="header">
{{ header.text }}
</slot>
</span>
<v-icon v-if="header.sortable" name="sort" class="sort-icon" small />
</div>
<span
@@ -37,7 +42,7 @@
<script lang="ts">
import { defineComponent, ref, PropType } from '@vue/composition-api';
import useEventListener from '@/compositions/use-event-listener';
import { Header, Sort } from './types';
import { Header, Sort } from '../types';
import { throttle, clone } from 'lodash';
export default defineComponent({
@@ -81,7 +86,7 @@ export default defineComponent({
const dragStartWidth = ref<number>(0);
const dragHeader = ref<Header>(null);
useEventListener(window, 'mousemove', throttle(onMouseMove, 20));
useEventListener(window, 'mousemove', throttle(onMouseMove, 40));
useEventListener(window, 'mouseup', onMouseUp);
return {
@@ -91,7 +96,6 @@ export default defineComponent({
dragStartWidth,
dragStartX,
getClassesForHeader,
getStyleForHeader,
onMouseMove,
onResizeHandleMouseDown,
toggleManualSort,
@@ -120,22 +124,6 @@ export default defineComponent({
return classes;
}
function getStyleForHeader(header: Header, index: number) {
if (header.width !== null) {
let width: string;
if (index === props.headers.length - 1) {
width = 'auto';
} else {
width = header.width + 'px';
}
return { width };
}
return null;
}
/**
* If current sort is not this field, use this field in ascending order
* If current sort is field, reverse sort order to descending
@@ -221,7 +209,7 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
.v-table_table-header {
.table-header {
.cell {
position: relative;
height: 48px;
@@ -230,12 +218,30 @@ export default defineComponent({
font-size: 14px;
background-color: var(--input-background-color);
border-bottom: 1px solid var(--input-border-color);
&.select,
&.sort {
display: flex;
align-items: center;
}
.content {
display: flex;
align-items: center;
height: 100%;
> span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.sortable {
.sort-icon {
position: absolute;
transform: scaleY(-1) translateX(4px);
margin-left: 4px;
transform: scaleY(-1);
opacity: 0;
transition: opacity var(--fast) var(--transition);
}
@@ -253,7 +259,7 @@ export default defineComponent({
&.sort-desc {
.sort-icon {
transform: scaleY(1) translateX(4px);
transform: scaleY(1);
}
}
}
@@ -264,13 +270,13 @@ export default defineComponent({
.select,
.manual {
width: 42px;
padding-right: 0;
}
.fixed th {
position: sticky;
top: 0;
top: var(--v-table-sticky-offset-top);
z-index: 2;
}
.resize-handle {

View File

@@ -0,0 +1,4 @@
import TableRow from './table-row.vue';
export { TableRow };
export default TableRow;

View File

@@ -4,13 +4,13 @@ import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VCheckbox from '../v-checkbox/';
import VIcon from '../v-icon/';
import VCheckbox from '@/components/v-checkbox/';
import VIcon from '@/components/v-icon/';
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
import TableRow from './_table-row.vue';
import TableRow from './table-row.vue';
describe('Table / Row', () => {
let component: Wrapper<Vue>;
@@ -38,7 +38,7 @@ describe('Table / Row', () => {
});
it('Renders right amount of cells per row', async () => {
expect(component.find('.v-table_table-row').findAll('td').length).toBe(2);
expect(component.find('.table-row').findAll('td').length).toBe(2);
});
it('Renders the provided element in the nested scoped slot', async () => {
@@ -65,7 +65,7 @@ describe('Table / Row', () => {
}
});
expect(component.find('.v-table_table-row td:nth-child(2) > *').html()).toEqual(
expect(component.find('.table-row td:nth-child(2) > *').html()).toEqual(
'<p>Test 1 Col 2</p>'
);
});

View File

@@ -1,21 +1,25 @@
<template>
<tr
class="v-table_table-row"
:class="{ subdued, clickable: hasClickListener, 'sorted-manually': sortedManually }"
class="table-row"
:class="{ subdued, clickable: hasClickListener }"
@click="$emit('click', $event)"
>
<td v-if="showManualSort" class="manual cell">
<v-icon name="drag_handle" class="drag-handle" />
<v-icon
name="drag_handle"
class="drag-handle"
:class="{ 'sorted-manually': sortedManually }"
/>
</td>
<td v-if="showSelect" class="select cell">
<td v-if="showSelect" class="select cell" @click.stop>
<v-checkbox :inputValue="isSelected" @change="toggleSelect" />
</td>
<td
class="cell"
v-for="header in headers"
:class="getClassesForCell(header)"
v-for="header in headers"
:key="header.value"
:style="{ height: height + 'px' }"
:style="{ height: height + 'px', lineHeight: height + 'px' }"
>
<slot :name="`item.${header.value}`" :item="item">{{ item[header.value] }}</slot>
</td>
@@ -24,7 +28,7 @@
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Header } from './types';
import { Header } from '../types';
export default defineComponent({
props: {
@@ -89,7 +93,7 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
.v-table_table-row {
.table-row {
.cell {
padding: 0 20px;
overflow: hidden;
@@ -97,28 +101,36 @@ export default defineComponent({
text-overflow: ellipsis;
background-color: var(--input-background-color);
border-bottom: 1px solid var(--divider-color);
&.select,
&.sort {
display: flex;
align-items: center;
}
}
&.subdued {
&.subdued .cell {
opacity: 0.3;
}
&.clickable:hover .cell {
&.clickable:not(.subdued):hover .cell {
background-color: var(--highlight);
cursor: pointer;
}
.drag-handle {
--v-icon-color: var(--input-action-color-disabled);
}
&.sorted-manually .drag-handle {
--v-icon-color: var(--input-action-color);
&:hover {
--v-icon-color: var(--input-action-color-hover);
.sorted-manually {
--v-icon-color: var(--input-action-color);
cursor: ns-resize;
&:hover {
--v-icon-color: var(--input-action-color-hover);
cursor: ns-resize;
}
}
}
}
}

View File

@@ -7,6 +7,8 @@ import markdown from './readme.md';
import { action } from '@storybook/addon-actions';
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent } from '@vue/composition-api';
Vue.component('v-table', VTable);
export default {
@@ -372,10 +374,10 @@ selection: {{ selection }}
`
});
export const fixedHeader = () => ({
data() {
return {
headers: [
export const fixedHeader = () =>
defineComponent({
setup() {
const headers = [
{
text: 'Name',
value: 'name'
@@ -389,8 +391,9 @@ export const fixedHeader = () => ({
text: 'Contact',
value: 'contact'
}
],
items: [
];
const items = [
{
id: 1,
name: 'Amsterdam',
@@ -511,13 +514,16 @@ export const fixedHeader = () => ({
tel: '(330) 777-3240',
contact: 'Helenka Killely'
}
]
};
},
template: `
<v-sheet><v-table :headers="headers" :items="items" :height="200" fixed-header /></v-sheet>
`
});
];
return { headers, items };
},
template: `
<v-sheet>
<v-table style="--v-table-height: 200px" :headers="headers" :items="items" fixed-header />
</v-sheet>
`
});
export const loading = () => ({
data() {
@@ -629,7 +635,7 @@ export const columnWidths = () => ({
{
text: 'Phone',
value: 'tel',
width: 150
width: 500
},
{
text: 'Contact',

View File

@@ -4,8 +4,8 @@ import { mount, createLocalVue, Wrapper } from '@vue/test-utils';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
import VCheckbox from '../v-checkbox/';
import VIcon from '../v-icon/';
import VCheckbox from '@/components/v-checkbox/';
import VIcon from '@/components/v-icon/';
localVue.component('v-checkbox', VCheckbox);
localVue.component('v-icon', VIcon);
@@ -15,14 +15,14 @@ import VTable from './v-table.vue';
describe('Table', () => {
let component: Wrapper<Vue>;
beforeEach(
() => (component = mount(VTable, { localVue, propsData: { headers: [], items: [] } }))
);
beforeEach(() => {
component = mount(VTable, { localVue, propsData: { headers: [], items: [] } });
});
it('Renders the correct amount of rows for the given items', async () => {
component.setProps({ items: [{}, {}, {}] });
await component.vm.$nextTick();
expect(component.findAll('.v-table_table-row').length).toBe(3);
expect(component.findAll('.table-row').length).toBe(3);
});
it('Adds the defaults to the passed headers', async () => {
@@ -293,7 +293,7 @@ describe('Table', () => {
await component.vm.$nextTick();
component.find('.v-table_table-row .select > *').trigger('click');
component.find('.table-row .select > *').trigger('click');
expect(component.emitted('select')?.[0]).toEqual([
[
@@ -341,7 +341,7 @@ describe('Table', () => {
await component.vm.$nextTick();
component.find('.v-table_table-row .select > *').trigger('click');
component.find('.table-row .select > *').trigger('click');
expect(component.emitted('select')?.[1]).toEqual([[]]);
});
@@ -437,7 +437,7 @@ describe('Table', () => {
await component.vm.$nextTick();
component.find('.v-table_table-header .select > *').trigger('click');
component.find('.table-header .select > *').trigger('click');
expect(component.emitted('select')?.[0]).toEqual([
[
@@ -501,7 +501,7 @@ describe('Table', () => {
await component.vm.$nextTick();
component.find('.v-table_table-header .select > *').trigger('click');
component.find('.table-header .select > *').trigger('click');
expect(component.emitted('select')?.[1]).toEqual([[]]);
});

View File

@@ -1,6 +1,11 @@
<template>
<div class="v-table" :class="{ loading }">
<table :summary="_headers.map(header => header.text).join(', ')">
<table
:summary="_headers.map(header => header.text).join(', ')"
:style="{
'--grid-columns': columnStyle
}"
>
<table-header
:headers.sync="_headers"
:sort.sync="_sort"
@@ -16,14 +21,14 @@
<slot :header="header" :name="`header.${header.value}`" />
</template>
</table-header>
<thead v-if="loading" class="loading-indicator">
<th :colspan="_headers.length" scope="colgroup">
<thead v-if="loading" class="loading-indicator" :class="{ sticky: fixedHeader }">
<th scope="colgroup" :style="{ gridColumn: loadingColSpan }">
<v-progress-linear indeterminate v-if="loading" />
</th>
</thead>
<tbody v-if="loading && items.length === 0">
<tr class="loading-text">
<td :colspan="_headers.length">{{ loadingText }}</td>
<td :style="{ gridColumn: loadingColSpan }">{{ loadingText }}</td>
</tr>
</tbody>
<draggable
@@ -55,14 +60,15 @@
</table-row>
</draggable>
</table>
<slot name="footer" />
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref, PropType } from '@vue/composition-api';
import { Header, HeaderRaw, Item, ItemSelectEvent, Sort } from './types';
import TableHeader from './_table-header.vue';
import TableRow from './_table-row.vue';
import TableHeader from './table-header/';
import TableRow from './table-row/';
import { sortBy, clone, forEach, pick } from 'lodash';
import { i18n } from '@/lang/';
import draggable from 'vuedraggable';
@@ -184,6 +190,13 @@ export default defineComponent({
}
});
const loadingColSpan = computed<string>(() => {
let length = _headers.value.length;
if (props.showSelect) length = length + 1;
if (props.showManualSort) length = length + 1;
return `1 / span ${length}`;
});
const _items = computed({
get: () => {
if (props.serverSort === true || _sort.value.by === '$manual') {
@@ -211,6 +224,22 @@ export default defineComponent({
const hasRowClick = computed<boolean>(() => listeners.hasOwnProperty('click:row'));
const columnStyle = computed<string>(() => {
let gridTemplateColumns = _headers.value
.map((header, index, array) => {
if (index !== array.length - 1) {
return header.width ? `${header.width}px` : 'min-content';
} else {
return `minmax(min-content, 1fr)`;
}
})
.reduce((acc, val) => (acc += ' ' + val), '');
if (props.showSelect) gridTemplateColumns = 'auto ' + gridTemplateColumns;
if (props.showManualSort) gridTemplateColumns = 'auto ' + gridTemplateColumns;
return gridTemplateColumns;
});
return {
_headers,
_items,
@@ -221,7 +250,9 @@ export default defineComponent({
onToggleSelectAll,
someItemsSelected,
onEndDrag,
hasRowClick
hasRowClick,
loadingColSpan,
columnStyle
};
function onItemSelected(event: ItemSelectEvent) {
@@ -270,15 +301,46 @@ export default defineComponent({
<style lang="scss" scoped>
.v-table {
--v-table-height: auto;
--v-table-sticky-offset-top: 0;
position: relative;
height: var(--v-table-height);
overflow: auto;
overflow-y: auto;
table {
width: 100%;
table-layout: fixed;
display: grid;
grid-template-columns: var(--grid-columns);
min-width: 100%;
border-spacing: 0;
::v-deep {
tbody,
thead,
tr {
display: contents;
}
td,
th {
&.align-left {
text-align: left;
}
&.align-center {
text-align: center;
}
&.align-right {
text-align: right;
}
}
.sortable-ghost {
.cell {
background-color: var(--highlight);
}
}
}
}
&.loading {
@@ -301,6 +363,12 @@ export default defineComponent({
th {
padding: 0;
}
&.sticky th {
position: sticky;
top: 48px;
z-index: +1;
}
}
.loading-text {
@@ -312,25 +380,5 @@ export default defineComponent({
}
}
}
::v-deep {
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.sortable-ghost {
.cell {
background-color: var(--highlight);
}
}
}
}
</style>

View File

@@ -1,12 +1,25 @@
import { Ref, ref, isRef, onMounted, onUnmounted } from '@vue/composition-api';
import { notEmpty } from '@/utils/is-empty';
import { ResizeObserver } from 'resize-observer';
import { ResizeObserver as ResizeObserverPolyfill } from 'resize-observer';
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ResizeObserver: any;
}
}
export default function useElementSize<T extends Element>(target: T | Ref<T> | Ref<null>) {
const width = ref(0);
const height = ref(0);
const resizeObserver = new ResizeObserver(([entry]) => {
let RO = ResizeObserverPolyfill;
if ('ResizeObserver' in window) {
RO = window.ResizeObserver;
}
const resizeObserver = new RO(([entry]) => {
width.value = entry.contentRect.width;
height.value = entry.contentRect.height;
});

View File

@@ -0,0 +1,4 @@
import useScrollDistance from './use-scroll-distance';
export { useScrollDistance };
export default useScrollDistance;

View File

@@ -0,0 +1,38 @@
# `useScrollDistance`
```ts
function useScrollDistance<T extends Element>(t: T | Ref<T | null | Vue>): { top: Ref<number>, left: Ref<number>, target: Element }
```
Returns a ref for the top and left scroll positions of the given element. Parameter supports Element, Ref<Element>, Ref<Vue>, and Ref<null>.
## Usage
```html
<template>
<v-sheet
ref="el"
style="
--v-sheet-max-width: 150px;
--v-sheet-max-height: 150px;
overflow: auto;
"
>
<div style="width: 600px; height: 600px;" />
</v-sheet>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
export default defineComponent({
setup() {
const el = ref<Vue>(null);
const { top, left } = useScrollDistance(el);
return { el };
}
});
</script>
```

View File

@@ -0,0 +1,29 @@
import withPadding from '../../../.storybook/decorators/with-padding';
import { defineComponent, ref } from '@vue/composition-api';
import useScrollDistance from './use-scroll-distance';
import Vue from 'vue';
export default {
title: 'Compositions / Scroll Distance',
decorators: [withPadding]
};
export const basic = () =>
defineComponent({
setup() {
const el = ref<Vue>(null);
const { top, left } = useScrollDistance(el);
return { top, left, el };
},
template: `
<div>
<v-sheet ref="el" style="--v-sheet-max-width: 150px; --v-sheet-max-height: 150px; overflow: auto;">
<div style="width: 600px; height: 600px;" />
</v-sheet>
<pre style="max-width: max-content; margin-top: 20px; background-color: #eee; font-family: monospace; padding: 0.5rem; border-radius: 8px;">
top: {{ top }}
left: {{ left }}
</pre>
</div>
`
});

View File

@@ -0,0 +1,83 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI, { defineComponent, ref, onMounted } from '@vue/composition-api';
import useScrollDistance from './use-scroll-distance';
import mountComposition from '../../../.jest/mount-composition';
import Vue from 'vue';
import VSheet from '@/components/v-sheet';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
describe('Compositions / useScrollDistance', () => {
it('Returns the correct scroll position', () => {
const TestComponent = defineComponent({
setup() {
const el = ref<Element>(null);
const { top, left } = useScrollDistance(el);
onMounted(() => {
el.value!.scrollTop = 150;
el.value!.scrollLeft = 200;
el.value!.dispatchEvent(new Event('scroll'));
expect(top.value).toBe(150);
expect(left.value).toBe(200);
});
return { el };
},
template: `
<div ref="el" style="max-width: 150px; max-height: 150px; overflow: auto;">
<div style="width: 600px; height: 600px;" />
</div>
`
});
shallowMount(TestComponent, { localVue });
});
it('Supports elements or refs for param', () => {
mountComposition(() => {
const testEl = null;
const testVal = ref(testEl);
const result = useScrollDistance(testVal);
expect(result.target.value).toBe(null);
}).destroy();
mountComposition(() => {
const testEl = document.createElement('div');
const testVal = testEl;
const result = useScrollDistance(testVal);
expect(result.target.value).toBe(testEl);
}).destroy();
mountComposition(() => {
const testEl = document.createElement('div');
const testVal = ref(testEl);
const result = useScrollDistance(testVal);
expect(result.target.value).toBe(testEl);
}).destroy();
const TestComponent = defineComponent({
components: { VSheet },
setup() {
const el = ref<Vue>(null);
const { target } = useScrollDistance(el);
onMounted(() => {
expect(target.value instanceof HTMLElement).toBe(true);
});
return { el };
},
template: `
<v-sheet ref="el" style="max-width: 150px; max-height: 150px; overflow: auto;">
<div style="width: 600px; height: 600px;" />
</v-sheet>
`
});
shallowMount(TestComponent, { localVue }).destroy();
});
});

View File

@@ -0,0 +1,37 @@
import { ref, onMounted, onUnmounted, Ref, isRef, computed } from '@vue/composition-api';
import { throttle } from 'lodash';
export default function useScrollDistance<T extends Element>(t: T | Ref<T | null | Vue>) {
const top = ref<number>();
const left = ref<number>();
const onScroll = throttle((event: Event) => {
const target = event.target as Element;
top.value = target.scrollTop;
left.value = target.scrollLeft;
}, 20);
const target = computed<Element | null>(() => {
const target = isRef(t) ? t.value : t;
if (target === null) {
return null;
}
if (target.hasOwnProperty('$el')) {
return (target as Vue).$el as Element;
}
return target as Element;
});
onMounted(() => {
target.value?.addEventListener('scroll', onScroll, { passive: true });
});
onUnmounted(() => {
target.value?.removeEventListener('scroll', onScroll);
});
return { top, left, target };
}

View File

@@ -3,13 +3,30 @@
:items="items"
:loading="loading"
:headers="headers"
ref="table"
v-model="_selection"
fixed-header
show-select
@click:row="onRowClick"
></v-table>
:server-sort="isBigCollection"
@update:sort="onSortChange"
>
<template #footer>
<div class="pagination" v-if="isBigCollection">
<v-pagination
:length="pages"
:total-visible="5"
show-first-last
:value="currentPage"
@input="toPage"
/>
</div>
</template>
</v-table>
</template>
<script lang="ts">
import { defineComponent, ref, watch, computed } from '@vue/composition-api';
import { defineComponent, PropType, ref, watch, computed } from '@vue/composition-api';
import api from '@/api';
import useProjectsStore from '@/stores/projects';
import useFieldsStore from '@/stores/fields';
@@ -17,20 +34,46 @@ import { HeaderRaw, Item } from '@/components/v-table/types';
import { Field } from '@/stores/fields/types';
import router from '@/router';
const PAGE_COUNT = 75;
export default defineComponent({
props: {
collection: {
type: String,
required: true
},
selection: {
type: Array as PropType<Item[]>,
default: () => []
},
selectMode: {
type: Boolean,
default: false
}
},
setup(props) {
setup(props, { emit }) {
const table = ref<Vue>(null);
const { currentProjectKey } = useProjectsStore().state;
const fieldsStore = useFieldsStore();
const error = ref(null);
const items = ref([]);
const loading = ref(true);
const itemCount = ref(0);
const currentPage = ref(1);
const pages = computed<number>(() => Math.ceil(itemCount.value / PAGE_COUNT));
const isBigCollection = computed<boolean>(() => itemCount.value > PAGE_COUNT);
const sort = ref({ by: 'id', desc: false });
const _selection = computed<Item[]>({
get() {
return props.selection;
},
set(newSelection) {
emit('update:selection', newSelection);
}
});
const fieldsInCurrentCollection = computed<Field[]>(() => {
return fieldsStore.state.fields.filter(field => field.collection === props.collection);
@@ -48,17 +91,22 @@ export default defineComponent({
});
const primaryKeyField = computed<Field>(() => {
/**
* @NOTE
* It's safe to assume that every collection has a primary key.
*/
// 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)!;
});
getItems();
watch(() => props.collection, getItems);
watch(
() => props.collection,
() => {
items.value = [];
itemCount.value = 0;
currentPage.value = 1;
getItems();
}
);
return {
error,
@@ -66,17 +114,40 @@ export default defineComponent({
loading,
headers,
onRowClick,
primaryKeyField
primaryKeyField,
_selection,
refresh,
table,
itemCount,
pages,
toPage,
currentPage,
isBigCollection,
onSortChange
};
async function refresh() {
await getItems();
}
async function getItems() {
items.value = [];
error.value = null;
loading.value = true;
let sortString = sort.value.by;
if (sort.value.desc === true) sortString = '-' + sortString;
try {
const response = await api.get(`/${currentProjectKey}/items/${props.collection}`);
const response = await api.get(`/${currentProjectKey}/items/${props.collection}`, {
params: {
limit: PAGE_COUNT,
page: currentPage.value,
meta: 'filter_count',
sort: sortString
}
});
items.value = response.data.data;
itemCount.value = response.data.meta.filter_count;
} catch (error) {
error.value = error;
} finally {
@@ -85,8 +156,35 @@ export default defineComponent({
}
function onRowClick(item: Item) {
const primaryKey = item[primaryKeyField.value.field];
router.push(`/${currentProjectKey}/collections/${props.collection}/${primaryKey}`);
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();
}
}
});
@@ -94,6 +192,17 @@ export default defineComponent({
<style lang="scss" scoped>
.v-table {
height: 100%;
--v-table-sticky-offset-top: var(--layout-offset-top);
display: contents;
padding: 0 32px;
}
.pagination {
position: sticky;
left: 0;
width: 100%;
padding: 32px;
text-align: center;
}
</style>

View File

@@ -1,5 +1,6 @@
import { Component } from 'vue';
import VueI18n from 'vue-i18n';
import { VueConstructor } from 'vue/types/umd';
export type LayoutOptions = {
id: string;
@@ -17,3 +18,7 @@ export interface Layout extends LayoutConfig {
}
export type LayoutContext = { i18n: VueI18n };
export interface LayoutComponent extends VueConstructor {
refresh: () => Promise<void>;
}

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>

View File

@@ -57,6 +57,13 @@ export const useCollectionsStore = createStore({
},
async dehydrate() {
this.reset();
},
getCollection(collectionKey: string) {
return (
this.state.collections.find(
collection => collection.collection === collectionKey
) || null
);
}
}
});

View File

@@ -48,6 +48,15 @@ export const useFieldsStore = createStore({
},
async dehydrate() {
this.reset();
},
getPrimaryKeyFieldForCollection(collection: string) {
/** @NOTE it's safe to assume every collection has a primary key */
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const primaryKeyField = this.state.fields.find(
field => field.collection === collection && field.primary_key === true
);
return primaryKeyField;
}
}
});

View File

@@ -5,4 +5,5 @@
--fast: 125ms;
--medium: 200ms;
--slow: 300ms;
--content-padding: 12px;
}

View File

@@ -0,0 +1,31 @@
import markdown from './readme.md';
import { defineComponent, provide, toRefs } from '@vue/composition-api';
import { withKnobs, boolean } from '@storybook/addon-knobs';
import withPadding from '../../../../../.storybook/decorators/with-padding';
import withAltColors from '../../../../../.storybook/decorators/with-alt-colors';
import DrawerButton from './drawer-button.vue';
export default {
title: 'Views / Private / Components / Drawer Button',
parameters: {
notes: markdown
},
decorators: [withKnobs, withAltColors, withPadding]
};
export const basic = () =>
defineComponent({
components: { DrawerButton },
props: {
drawerOpen: {
default: boolean('Drawer Open', true)
}
},
setup(props) {
provide('drawer-open', toRefs(props).drawerOpen);
},
template: `
<drawer-button icon="info">Close Drawer</drawer-button>
`
});

View File

@@ -0,0 +1,21 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VIcon from '@/components/v-icon/';
import DrawerButton from './drawer-button.vue';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
localVue.component('v-icon', VIcon);
describe('Views / Private / Components / Drawer Button', () => {
it('Does not render the title when the drawer is closed', () => {
const component = shallowMount(DrawerButton, {
localVue,
provide: {
'drawer-open': false
}
});
expect(component.find('.title').exists()).toBe(false);
});
});

View File

@@ -0,0 +1,75 @@
<template>
<component
class="drawer-button"
:is="to ? 'router-link' : 'button'"
@click="$emit('click', $event)"
>
<div class="icon">
<v-icon :name="icon" />
</div>
<div class="title" v-if="drawerOpen">
<slot />
</div>
</component>
</template>
<script lang="ts">
import { defineComponent, inject, ref } from '@vue/composition-api';
export default defineComponent({
props: {
to: {
type: String,
default: null
},
icon: {
type: String,
default: 'box'
}
},
setup() {
const drawerOpen = inject('drawer-open', ref(false));
return { drawerOpen };
}
});
</script>
<style lang="scss" scoped>
.drawer-button {
position: relative;
width: 100%;
height: 64px;
color: var(--foreground-color);
transition: background-color var(--fast) var(--transition);
&:hover {
background-color: var(--background-color-hover);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 100%;
}
.title {
position: absolute;
top: 50%;
left: 52px;
overflow: hidden;
white-space: nowrap;
transform: translateY(-50%);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--medium) var(--transition);
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,4 @@
import DrawerButton from './drawer-button.vue';
export { DrawerButton };
export default DrawerButton;

View File

@@ -0,0 +1,28 @@
# Drawer Button
Looks the same as a drawer detail, but can be used as a button / router link.
## Usage
```html
<drawer-button icon="info">Close Sidebar</drawer-button>
```
## Props
| Prop | Description | Default |
|---------|----------------------------------------------------|---------|
| `icon`* | What icon to render on the left of the button | `box` |
| `to` | router-link to prop. Turns button into router-link | `null` |
## Events
| Event | Description | Value |
|---------|----------------------------------|--------------|
| `click` | When the button has been clicked | `MouseEvent` |
## Slots
| Slot | Description | Data |
|-----------|---------------------|------|
| _default_ | Title of the button | -- |
## CSS Variables
n/a

View File

@@ -44,9 +44,7 @@ export default defineComponent({
<style lang="scss" scoped>
.drawer-detail {
.toggle {
display: flex;
align-items: center;
justify-content: flex-start;
position: relative;
width: 100%;
height: 64px;
color: var(--foreground-color);
@@ -65,10 +63,17 @@ export default defineComponent({
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 100%;
margin-right: 12px;
padding: 20px;
padding-right: 0;
}
.title {
position: absolute;
top: 50%;
left: 52px;
overflow: hidden;
white-space: nowrap;
transform: translateY(-50%);
}
.content {

View File

@@ -90,7 +90,7 @@ export default defineComponent({
.action-buttons {
> * {
display: block;
display: inherit;
}
}
}
@@ -98,7 +98,7 @@ export default defineComponent({
@include breakpoint(medium) {
.action-buttons {
> * {
display: block !important;
display: inherit !important;
}
}
}

View File

@@ -11,6 +11,15 @@ localVue.component('v-button', VButton);
localVue.component('v-icon', VIcon);
describe('Views / Private / Header Bar', () => {
const observeMock = {
observe: () => null,
disconnect: () => null // maybe not needed
};
beforeEach(() => {
(window as any).IntersectionObserver = jest.fn(() => observeMock);
});
it('Emits toggle event when toggle buttons are clicked', () => {
const component = mount(HeaderBar, {
localVue,

View File

@@ -1,5 +1,5 @@
<template>
<header class="header-bar" :class="{ dense }">
<header class="header-bar" ref="headerEl" :class="{ shadow: showShadow }">
<v-button secondary class="nav-toggle" icon rounded @click="$emit('toggle:nav')">
<v-icon name="menu" />
</v-button>
@@ -30,7 +30,7 @@
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { defineComponent, ref, onMounted, onUnmounted } from '@vue/composition-api';
import HeaderBarActions from '../header-bar-actions';
export default defineComponent({
@@ -39,14 +39,29 @@ export default defineComponent({
title: {
type: String,
required: true
},
dense: {
type: Boolean,
default: false
}
},
setup() {
return {};
const headerEl = ref<Element>();
const showShadow = ref(false);
const observer = new IntersectionObserver(
([e]) => {
showShadow.value = e.intersectionRatio < 1;
},
{ threshold: [1] }
);
onMounted(() => {
observer.observe(headerEl.value as HTMLElement);
});
onUnmounted(() => {
observer.disconnect();
});
return { headerEl, showShadow };
}
});
</script>
@@ -56,19 +71,23 @@ export default defineComponent({
@import '@/styles/mixins/type-styles';
.header-bar {
position: relative;
position: sticky;
top: -1px;
left: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: flex-start;
width: 100%;
height: 64px;
height: 65px;
margin: 24px 0;
padding: 0 12px;
background-color: var(--background-color);
transition: height var(--medium) var(--transition);
box-shadow: 0;
transition: box-shadow var(--medium) var(--transition);
&.dense {
height: 64px;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.2);
&.shadow {
box-shadow: 0 4px 7px -4px rgba(0, 0, 0, 0.2);
}
.nav-toggle {
@@ -113,7 +132,6 @@ export default defineComponent({
}
@include breakpoint(small) {
height: 112px;
padding: 0 32px;
}
}

View File

@@ -15,10 +15,9 @@
</div>
</div>
</aside>
<div class="content">
<div class="content" ref="contentEl">
<header-bar
:title="title"
:dense="_headerDense"
@toggle:drawer="drawerOpen = !drawerOpen"
@toggle:nav="navOpen = !navOpen"
>
@@ -29,7 +28,8 @@
<slot :name="scopedSlotName" v-bind="slotData" />
</template>
</header-bar>
<main ref="mainContent" @scroll="onMainScroll" :class="{ 'header-dense': headerDense }">
<main>
<slot />
</main>
</div>
@@ -40,6 +40,10 @@
:class="{ 'is-open': drawerOpen }"
@click="drawerOpen = true"
>
<drawer-button class="drawer-toggle" @click.stop="drawerOpen = !drawerOpen" icon="info">
Close Drawer
</drawer-button>
<drawer-detail-group :drawer-open="drawerOpen">
<slot name="drawer" />
</drawer-detail-group>
@@ -51,68 +55,36 @@
</template>
<script lang="ts">
import { defineComponent, ref, provide, computed } from '@vue/composition-api';
import { defineComponent, ref, provide } from '@vue/composition-api';
import ModuleBar from './components/module-bar/';
import DrawerDetailGroup from './components/drawer-detail-group/';
import HeaderBar from './components/header-bar';
import ProjectChooser from './components/project-chooser';
import { throttle } from 'lodash';
import DrawerButton from './components/drawer-button/';
export default defineComponent({
components: {
ModuleBar,
DrawerDetailGroup,
HeaderBar,
ProjectChooser
ProjectChooser,
DrawerButton
},
props: {
title: {
type: String,
required: true
},
headerDense: {
type: Boolean,
default: false
},
headerDenseAuto: {
type: Boolean,
default: true
}
},
setup(props) {
const mainElement = ref<Element>(null);
setup() {
const navOpen = ref(false);
const drawerOpen = ref(false);
provide('drawer-open', drawerOpen);
const headerDenseAutoState = ref(false);
const _headerDense = computed<boolean>(() => {
if (props.headerDenseAuto === true) {
return headerDenseAutoState.value;
} else {
return props.headerDense;
}
});
const onMainScroll = throttle(event => {
const { scrollTop } = event.target;
if (scrollTop <= 5 && headerDenseAutoState.value === true) {
headerDenseAutoState.value = false;
} else if (scrollTop > 5 && headerDenseAutoState.value === false) {
headerDenseAutoState.value = true;
}
}, 50);
return {
navOpen,
drawerOpen,
_headerDense,
mainElement,
onMainScroll
drawerOpen
};
}
});
@@ -122,8 +94,6 @@ export default defineComponent({
@import '@/styles/mixins/breakpoint';
.private-view {
--private-view-content-padding: 12px;
display: flex;
width: 100%;
height: 100%;
@@ -182,30 +152,21 @@ export default defineComponent({
.content {
position: relative;
flex-grow: 1;
width: 100%;
height: 100%;
overflow: hidden;
.header-bar {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
overflow: auto;
main {
height: 100%;
padding: var(--private-view-content-padding);
padding-top: 114px;
overflow: auto;
display: contents;
}
// Offset for partially visible drawer
@include breakpoint(medium) {
padding-right: 64px;
margin-right: 64px;
}
@include breakpoint(large) {
padding-right: 0;
margin-right: 0;
}
}

View File

@@ -62,7 +62,9 @@ describe('Views / Public', () => {
describe('Background', () => {
it('Defaults background art to color when current project key is unknown', () => {
expect((component.vm as any).artStyles).toEqual({
background: '#263238'
background: '#263238',
backgroundPosition: 'center center',
backgroundSize: 'cover'
});
});
@@ -81,7 +83,9 @@ describe('Views / Public', () => {
await component.vm.$nextTick();
expect((component.vm as any).artStyles).toEqual({
background: '#4CAF50'
background: '#4CAF50',
backgroundPosition: 'center center',
backgroundSize: 'cover'
});
});
@@ -99,7 +103,9 @@ describe('Views / Public', () => {
store.state.currentProjectKey = 'my-project';
expect((component.vm as any).artStyles).toEqual({
background: '#263238'
background: '#263238',
backgroundPosition: 'center center',
backgroundSize: 'cover'
});
});
@@ -107,7 +113,9 @@ describe('Views / Public', () => {
store.state.projects = [mockProject];
store.state.currentProjectKey = 'my-project';
expect((component.vm as any).artStyles).toEqual({
background: `url(${mockProject.api.project_background?.full_url})`
background: `url(${mockProject.api.project_background?.full_url})`,
backgroundPosition: 'center center',
backgroundSize: 'cover'
});
});
});

View File

@@ -56,7 +56,9 @@ export default defineComponent({
});
const artStyles = computed(() => ({
background: backgroundStyles.value
background: backgroundStyles.value,
backgroundSize: 'cover',
backgroundPosition: 'center center'
}));
return { version, artStyles };
@@ -98,6 +100,8 @@ export default defineComponent({
display: none;
flex-grow: 1;
height: 100%;
background-position: center center;
background-size: cover;
@include breakpoint(small) {
display: block;