mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
v-form (#170)
* 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:
@@ -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);
|
||||
|
||||
4
src/components/v-form/index.ts
Normal file
4
src/components/v-form/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VForm from './v-form.vue';
|
||||
|
||||
export { VForm };
|
||||
export default VForm;
|
||||
36
src/components/v-form/readme.md
Normal file
36
src/components/v-form/readme.md
Normal 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` |
|
||||
64
src/components/v-form/v-form.story.ts
Normal file
64
src/components/v-form/v-form.story.ts
Normal 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!'
|
||||
}"
|
||||
/>
|
||||
`
|
||||
});
|
||||
14
src/components/v-form/v-form.test.ts
Normal file
14
src/components/v-form/v-form.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
141
src/components/v-form/v-form.vue
Normal file
141
src/components/v-form/v-form.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user