* Add v-form with grid

* Sort the fields

* Add auto dense mode to header

* Tweak auto dense threshold

* Add readme / story / test placeholder
This commit is contained in:
Rijk van Zanten
2020-03-12 16:22:08 -04:00
committed by GitHub
parent 101abb9634
commit 68fe8099c1
9 changed files with 314 additions and 16 deletions

View File

@@ -4,6 +4,7 @@ import VAvatar from './v-avatar/';
import VButton from './v-button/';
import VCheckbox from './v-checkbox/';
import VChip from './v-chip/';
import VForm from './v-form';
import VHover from './v-hover/';
import VIcon from './v-icon/';
import VInput from './v-input/';
@@ -21,6 +22,7 @@ Vue.component('v-avatar', VAvatar);
Vue.component('v-button', VButton);
Vue.component('v-checkbox', VCheckbox);
Vue.component('v-chip', VChip);
Vue.component('v-form', VForm);
Vue.component('v-hover', VHover);
Vue.component('v-icon', VIcon);
Vue.component('v-input', VInput);

View File

@@ -0,0 +1,4 @@
import VForm from './v-form.vue';
export { VForm };
export default VForm;

View File

@@ -0,0 +1,36 @@
# Form
Renders a form using interfaces based on the passed collection name.
## Usage
```html
<v-form
collection="articles"
:edits.sync="{}"
:initial-values="{
title: 'Hello World'
}"
/>
```
## Props
| Prop | Description | Default |
|-----------------|-------------------------------------------------------------------------------------|---------|
| `collection` | The collection of which you want to render the fields | -- |
| `initialValues` | Object of the starting values of the fields | -- |
| `edits` | The edits that were made after the form was rendered. Supports the `.sync` modifier | -- |
## Slots
n/a
## Events
| Event | Description |
|----------------|--------------------------------------------------------------------------|
| `update:edits` | Update the edits state. Enables the `.sync` modifier on the `edits` prop |
## CSS Variables
| Variable | Default |
|---------------------------|----------------------------------------|
| `--v-form-column-width` | `300px` |
| `--v-form-row-max-height` | `calc(var(--v-form-column-width) * 2)` |
| `--v-form-horizontal-gap` | `12px` |
| `--v-form-vertical-gap` | `52px` |

View File

@@ -0,0 +1,64 @@
import { withKnobs, text, boolean, color, optionsKnob as options } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
import Vue from 'vue';
import markdown from './readme.md';
import withPadding from '../../../.storybook/decorators/with-padding';
import VForm from './v-form.vue';
import { defineComponent } from '@vue/composition-api';
import { useFieldsStore } from '@/stores/fields';
Vue.component('v-form', VForm);
export default {
title: 'Components / Form',
parameters: {
notes: markdown
},
decorators: [withPadding]
};
export const basic = () =>
defineComponent({
setup() {
const fieldsStore = useFieldsStore({});
fieldsStore.state.fields = [
{
collection: 'articles',
field: 'title',
datatype: 'VARCHAR',
unique: false,
primary_key: false,
auto_increment: false,
default_value: null,
note: '',
signed: true,
id: 197,
type: 'string',
sort: 2,
interface: 'text-input',
hidden_detail: false,
hidden_browse: false,
required: false,
options: {
monospace: true
},
locked: false,
translation: null,
readonly: false,
width: 'full',
validation: null,
group: null,
length: '65535',
name: 'Title'
}
];
},
template: `
<v-form
collection="articles"
:initial-values="{
title: 'Hello World!'
}"
/>
`
});

View File

@@ -0,0 +1,14 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueCompositionAPI from '@vue/composition-api';
import VForm from './v-form.vue';
const localVue = createLocalVue();
localVue.use(VueCompositionAPI);
describe('Components / Form', () => {
it('Renders', () => {
const component = shallowMount(VForm, { localVue });
expect(component.isVueInstance()).toBe(true);
});
});

View File

@@ -0,0 +1,141 @@
<template>
<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" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
import { useFieldsStore } from '@/stores/fields';
import { useElementSize } from '@/compositions/use-element-size';
import { isEmpty } from '@/utils/is-empty';
type FieldValues = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[field: string]: any;
};
export default defineComponent({
props: {
collection: {
type: String,
required: true
},
initialValues: {
type: Object as PropType<FieldValues>,
default: null
},
edits: {
type: Object as PropType<FieldValues>,
default: null
}
},
setup(props) {
const el = ref<Element>(null);
const fieldsStore = useFieldsStore();
const fieldsInCollection = computed(() =>
fieldsStore.state.fields.filter(field => field.collection === props.collection)
);
const formFields = computed(() => {
let fields = [...fieldsInCollection.value];
// Filter out the fields that are marked hidden on detail
fields = fields.filter(field => {
const hiddenDetail = field.hidden_detail;
if (isEmpty(hiddenDetail)) return true;
return hiddenDetail === false;
});
// Sort the fields on the sort column value
fields = fields.sort((a, b) => {
if (a.sort == b.sort) return 0;
if (a.sort === null) return 1;
if (b.sort === null) return -1;
return a.sort > b.sort ? 1 : -1;
});
// Change the class to half-right if the current element is preceded by another half width field
// this makes them align side by side
fields = fields.map((field, index, fields) => {
if (index === 0) return field;
if (field.width === 'half') {
const prevField = fields[index - 1];
if (prevField.width === 'half') {
field.width = 'half-right';
}
}
return field;
});
return fields;
});
const { width } = useElementSize(el);
const gridClass = computed<string | null>(() => {
if (el.value === null) return null;
if (width.value > 612 && width.value <= 700) {
return 'grid';
} else {
return 'grid with-fill';
}
return null;
});
return { el, width, formFields, gridClass };
}
});
</script>
<style lang="scss" scoped>
.v-form {
--v-form-column-width: 300px;
--v-form-row-max-height: calc(var(--v-form-column-width) * 2);
--v-form-horizontal-gap: 12px;
--v-form-vertical-gap: 52px;
&.grid {
display: grid;
grid-template-columns: [start] minmax(0, 1fr) [half] minmax(0, 1fr) [full];
gap: var(--v-form-vertical-gap) var(--v-form-horizontal-gap);
&.with-fill {
grid-template-columns:
[start] minmax(0, var(--v-form-column-width)) [half] minmax(
0,
var(--v-form-column-width)
)
[full] 1fr [fill];
}
}
& > .half,
& > .half-left,
& > .half-space {
grid-column: start / half;
}
& > .half-right {
grid-column: half / full;
}
& > .full {
grid-column: start / full;
}
& > .fill {
grid-column: start / fill;
}
}
</style>

View File

@@ -1,15 +1,7 @@
<template>
<private-view title="Edit">
<template v-if="item">
<div style="margin-bottom: 20px" v-for="field in visibleFields" :key="field.field">
<p>{{ field.name }}</p>
<interface-text-input
v-if="field.type === 'string'"
:value="item[field.field]"
:options="{}"
/>
<span v-else style="font-family: monospace;">{{ item[field.field] }}</span>
</div>
<v-form :initial-values="item" :collection="collection" />
</template>
<template #navigation>

View File

@@ -29,8 +29,8 @@ export interface FieldRaw {
translation: null | Translation[];
readonly: boolean;
width: null | Width;
validaton: string;
group: number;
validation: string | null;
group: number | null;
length: string | number;
}

View File

@@ -16,6 +16,7 @@
<div class="content">
<header-bar
:title="title"
:dense="_headerDense"
@toggle:drawer="drawerOpen = !drawerOpen"
@toggle:nav="navOpen = !navOpen"
>
@@ -26,7 +27,7 @@
<slot :name="scopedSlotName" v-bind="slotData" />
</template>
</header-bar>
<main>
<main ref="mainContent" @scroll="onMainScroll" :class="{ 'header-dense': headerDense }">
<slot />
</main>
</div>
@@ -47,10 +48,11 @@
</template>
<script lang="ts">
import { defineComponent, ref, provide } from '@vue/composition-api';
import { defineComponent, ref, provide, computed } from '@vue/composition-api';
import ModuleBar from './module-bar/';
import DrawerDetailGroup from './drawer-detail-group/';
import HeaderBar from './header-bar';
import { throttle } from 'lodash';
export default defineComponent({
components: {
@@ -62,17 +64,50 @@ export default defineComponent({
title: {
type: String,
required: true
},
headerDense: {
type: Boolean,
default: false
},
headerDenseAuto: {
type: Boolean,
default: true
}
},
setup() {
setup(props) {
const mainElement = ref<Element>(null);
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
drawerOpen,
_headerDense,
mainElement,
onMainScroll
};
}
});
@@ -140,13 +175,23 @@ export default defineComponent({
}
.content {
position: relative;
flex-grow: 1;
height: 100%;
overflow: hidden;
.header-bar {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
main {
height: calc(100% - 112px); /* TODO: add "collapsed" header state support */
height: 100%;
padding: var(--private-view-content-padding);
padding-top: 114px;
overflow: auto;
}
// Offset for partially visible drawer