mirror of
https://github.com/directus/directus.git
synced 2026-01-27 04:48:04 -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:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding: var(--v-card-padding);
|
||||
word-break: break-all;
|
||||
|
||||
@include type-card-title;
|
||||
}
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
4
src/components/v-table/table-header/index.ts
Normal file
4
src/components/v-table/table-header/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import TableHeader from './table-header.vue';
|
||||
|
||||
export { TableHeader };
|
||||
export default TableHeader;
|
||||
0
src/components/v-table/table-header/readme.md
Normal file
0
src/components/v-table/table-header/readme.md
Normal 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' });
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
4
src/components/v-table/table-row/index.ts
Normal file
4
src/components/v-table/table-row/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import TableRow from './table-row.vue';
|
||||
|
||||
export { TableRow };
|
||||
export default TableRow;
|
||||
0
src/components/v-table/table-row/readme.md
Normal file
0
src/components/v-table/table-row/readme.md
Normal 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>'
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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([[]]);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
4
src/compositions/use-scroll-distance/index.ts
Normal file
4
src/compositions/use-scroll-distance/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import useScrollDistance from './use-scroll-distance';
|
||||
|
||||
export { useScrollDistance };
|
||||
export default useScrollDistance;
|
||||
38
src/compositions/use-scroll-distance/readme.md
Normal file
38
src/compositions/use-scroll-distance/readme.md
Normal 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>
|
||||
```
|
||||
@@ -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>
|
||||
`
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
37
src/compositions/use-scroll-distance/use-scroll-distance.ts
Normal file
37
src/compositions/use-scroll-distance/use-scroll-distance.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,4 +5,5 @@
|
||||
--fast: 125ms;
|
||||
--medium: 200ms;
|
||||
--slow: 300ms;
|
||||
--content-padding: 12px;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
75
src/views/private/components/drawer-button/drawer-button.vue
Normal file
75
src/views/private/components/drawer-button/drawer-button.vue
Normal 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>
|
||||
4
src/views/private/components/drawer-button/index.ts
Normal file
4
src/views/private/components/drawer-button/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import DrawerButton from './drawer-button.vue';
|
||||
|
||||
export { DrawerButton };
|
||||
export default DrawerButton;
|
||||
28
src/views/private/components/drawer-button/readme.md
Normal file
28
src/views/private/components/drawer-button/readme.md
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user