Split up v-form, add repeater (#557)

* Add translation strings

* Move type styles to mixins

* Add repeater interface

* Prevent stepper buttons when disabled

* Split up v-form

* Support disabled in repeater
This commit is contained in:
Rijk van Zanten
2020-05-12 12:00:16 -04:00
committed by GitHub
parent c4885d41f4
commit fb43a5dde9
16 changed files with 764 additions and 210 deletions

View File

@@ -0,0 +1,79 @@
<template>
<div
class="interface"
:class="{
subdued: batchMode && batchActive,
}"
>
<v-skeleton-loader v-if="loading && field.hideLoader !== true" />
<component
:is="`interface-${field.interface}`"
v-bind="field.options"
:disabled="disabled"
:value="value === undefined ? field.default_value : value"
:width="field.width"
:type="field.type"
:collection="field.collection"
:field="field.field"
:primary-key="primaryKey"
@input="$emit('input', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
field: {
type: Object as PropType<Field>,
required: true,
},
batchMode: {
type: Boolean,
default: false,
},
batchActive: {
type: Boolean,
default: false,
},
primaryKey: {
type: [Number, String],
default: null,
},
value: {
type: [String, Number, Object, Array, Boolean],
default: null,
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
});
</script>
<style lang="scss" scoped>
.interface {
position: relative;
.v-skeleton-loader {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 100%;
height: 100%;
}
&.subdued {
opacity: 0.5;
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="label type-label" :class="{ disabled: disabled }">
<v-checkbox
v-if="batchMode"
:input-value="batchActive"
:value="field.field"
@change="$emit('toggle-batch', field)"
/>
<span @click="toggle">
{{ field.name }}
<v-icon class="required" sup name="star" v-if="field.required" />
<v-icon v-if="!disabled" class="ctx-arrow" :class="{ active }" name="arrow_drop_down" />
</span>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
batchMode: {
type: Boolean,
default: false,
},
field: {
type: Object as PropType<Field>,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
toggle: {
type: Function,
required: true,
},
active: {
type: Boolean,
default: false,
},
},
});
</script>
<style lang="scss" scoped>
.label {
position: relative;
display: flex;
width: max-content;
margin-bottom: 8px;
cursor: pointer;
&.readonly {
cursor: not-allowed;
}
.v-checkbox {
margin-right: 4px;
}
.required {
--v-icon-color: var(--primary);
margin-left: -3px;
}
.ctx-arrow {
position: absolute;
top: -3px;
right: -20px;
color: var(--foreground-subdued);
opacity: 0;
transition: opacity var(--fast) var(--transition);
&.active {
opacity: 1;
}
}
&:hover {
.ctx-arrow {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<v-list dense>
<v-list-item :disabled="value === null" @click="$emit('input', null)">
<v-list-item-icon><v-icon name="delete_outline" /></v-list-item-icon>
<v-list-item-content>{{ $t('clear_value') }}</v-list-item-content>
</v-list-item>
<v-list-item
:disabled="field.default_value === undefined || value === field.default_value"
@click="$emit('unset', field)"
>
<v-list-item-icon>
<v-icon name="settings_backup_restore" />
</v-list-item-icon>
<v-list-item-content>{{ $t('reset_to_default') }}</v-list-item-content>
</v-list-item>
<v-list-item
v-if="initialValue"
:disabled="initialValue === undefined || value === initialValue"
@click="$emit('unset', field)"
>
<v-list-item-icon>
<v-icon name="undo" />
</v-list-item-icon>
<v-list-item-content>{{ $t('undo_changes') }}</v-list-item-content>
</v-list-item>
</v-list>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
field: {
type: Object as PropType<Field>,
required: true,
},
value: {
type: [String, Number, Object, Array, Boolean],
default: null,
},
initialValue: {
type: [String, Number, Object, Array, Boolean],
default: null,
},
},
});
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div class="field" :key="field.field" :class="field.width">
<v-menu
v-if="field.hideLabel !== true"
placement="bottom-start"
show-arrow
close-on-content-click
:disabled="isDisabled"
>
<template #activator="{ toggle, active }">
<form-field-label
:field="field"
:toggle="toggle"
:active="active"
:disabled="isDisabled"
/>
</template>
<form-field-menu
:field="field"
:value="_value"
:initial-value="initialValue"
@input="$emit('input', $event)"
@unset="$emit('unset', $event)"
/>
</v-menu>
<form-field-interface
:value="_value"
:field="field"
:loading="loading"
:batch-mode="batchMode"
:batch-active="batchActive"
:disabled="isDisabled"
@input="$emit('input', $event)"
/>
<small class="note" v-if="field.note" v-html="marked(field.note)" />
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
import marked from 'marked';
import FormFieldLabel from './form-field-label.vue';
import FormFieldMenu from './form-field-menu.vue';
import FormFieldInterface from './form-field-interface.vue';
export default defineComponent({
components: { FormFieldLabel, FormFieldMenu, FormFieldInterface },
props: {
field: {
type: Object as PropType<Field>,
required: true,
},
batchMode: {
type: Boolean,
default: false,
},
batchActive: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
value: {
type: [String, Number, Object, Array, Boolean],
default: undefined,
},
initialValue: {
type: [String, Number, Object, Array, Boolean],
default: null,
},
primaryKey: {
type: [String, Number],
default: null,
},
loading: {
type: Boolean,
default: false,
},
},
setup(props) {
const isDisabled = computed(() => {
if (props.disabled) return true;
if (props.field.readonly) return true;
if (props.batchMode && props.batchActive === false) return true;
return false;
});
const _value = computed(() => {
if (props.value !== undefined) return props.value;
return props.initialValue;
});
return { isDisabled, marked, _value };
},
});
</script>
<style lang="scss" scoped>
.note {
display: block;
margin-top: 4px;
color: var(--foreground-subdued);
font-style: italic;
}
</style>

View File

@@ -1,14 +0,0 @@
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, propsData: { collection: 'test' } });
expect(component.isVueInstance()).toBe(true);
});
});

View File

@@ -1,104 +1,20 @@
<template>
<div class="v-form" ref="el" :class="gridClass">
<div v-for="field in formFields" class="field" :key="field.field" :class="field.width">
<v-menu
v-if="field.hideLabel !== true"
placement="bottom-start"
show-arrow
close-on-content-click
:disabled="
loading ||
field.readonly === true ||
(batchMode && batchActiveFields.includes(field.field) === false)
"
>
<template #activator="{ toggle, active }">
<div class="label type-label" :class="{ readonly: field.readonly }">
<v-checkbox
v-if="batchMode"
@change="toggleBatchField(field)"
:input-value="batchActiveFields"
:value="field.field"
/>
<span @click="toggle">
{{ field.name }}
<v-icon class="required" sup name="star" v-if="field.required" />
<v-icon
v-if="!field.readonly"
class="ctx-arrow"
:class="{ active }"
name="arrow_drop_down"
/>
</span>
</div>
</template>
<v-list dense>
<v-list-item
@click="setValue(field, null)"
:disabled="values[field.field] === null"
>
<v-list-item-icon><v-icon name="delete_outline" /></v-list-item-icon>
<v-list-item-content>{{ $t('clear_value') }}</v-list-item-content>
</v-list-item>
<v-list-item
@click="unsetValue(field)"
:disabled="
field.default_value === undefined ||
values[field.field] === field.default_value
"
>
<v-list-item-icon>
<v-icon name="settings_backup_restore" />
</v-list-item-icon>
<v-list-item-content>{{ $t('reset_to_default') }}</v-list-item-content>
</v-list-item>
<v-list-item
v-if="initialValues"
@click="unsetValue(field)"
:disabled="
initialValues[field.field] === undefined ||
values[field.field] === initialValues[field.field]
"
>
<v-list-item-icon>
<v-icon name="undo" />
</v-list-item-icon>
<v-list-item-content>{{ $t('undo_changes') }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<div
class="interface"
:class="{
subdued: batchMode && batchActiveFields.includes(field.field) === false,
}"
>
<v-skeleton-loader v-if="loading && field.hideLoader !== true" />
<component
:is="`interface-${field.interface}`"
v-bind="field.options"
:disabled="
field.readonly ||
(batchMode && batchActiveFields.includes(field.field) === false)
"
:value="
values[field.field] === undefined
? field.default_value
: values[field.field]
"
:width="field.width"
:type="field.type"
:collection="field.collection"
:field="field.field"
:primary-key="primaryKey"
@input="setValue(field, $event)"
/>
</div>
<small class="note" v-if="field.note" v-html="marked(field.note)" />
</div>
<form-field
v-for="field in formFields"
:field="field"
:key="field.field"
:value="(edits || {})[field.field]"
:initial-value="(initialValues || {})[field.field]"
:disabled="disabled"
:batch-mode="batchMode"
:batch-active="batchActiveFields.includes(field.field)"
:primary-key="primaryKey"
:loading="loading"
@input="setValue(field, $event)"
@unset="unsetValue(field)"
@toggle-batch="toggleBatchField(field)"
/>
</div>
</template>
@@ -109,16 +25,18 @@ import { Field } from '@/stores/fields/types';
import { useElementSize } from '@/composables/use-element-size';
import { isEmpty } from '@/utils/is-empty';
import { clone } from 'lodash';
import { FormField } from './types';
import { FormField as TFormField } from './types';
import interfaces from '@/interfaces';
import marked from 'marked';
import getDefaultInterfaceForType from '@/utils/get-default-interface-for-type';
import FormField from './form-field.vue';
type FieldValues = {
[field: string]: any;
};
export default defineComponent({
components: { FormField },
model: {
prop: 'edits',
},
@@ -128,7 +46,7 @@ export default defineComponent({
default: undefined,
},
fields: {
type: Array as PropType<FormField[]>,
type: Array as PropType<TFormField[]>,
default: undefined,
},
initialValues: {
@@ -151,6 +69,10 @@ export default defineComponent({
type: [String, Number],
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const el = ref<Element>(null);
@@ -233,11 +155,11 @@ export default defineComponent({
}
if (interfaceUsed?.hideLabel === true) {
(field as FormField).hideLabel = true;
(field as TFormField).hideLabel = true;
}
if (interfaceUsed?.hideLoader === true) {
(field as FormField).hideLoader = true;
(field as TFormField).hideLoader = true;
}
return field;
@@ -276,7 +198,16 @@ export default defineComponent({
return null;
});
return { formFields, gridClass };
return { formFields, gridClass, isDisabled };
function isDisabled(field: Field) {
return (
props.loading ||
props.disabled === true ||
field.readonly === true ||
(props.batchMode && batchActiveFields.value.includes(field.field) === false)
);
}
}
function setValue(field: Field, value: any) {
@@ -359,69 +290,4 @@ body {
grid-column: start / fill;
}
}
.interface {
position: relative;
.v-skeleton-loader {
position: absolute;
top: 0;
left: 0;
z-index: 2;
width: 100%;
height: 100%;
}
&.subdued {
opacity: 0.5;
}
}
.label {
position: relative;
display: flex;
width: max-content;
margin-bottom: 8px;
cursor: pointer;
&.readonly {
cursor: not-allowed;
}
.v-checkbox {
margin-right: 4px;
}
.required {
--v-icon-color: var(--primary);
margin-left: -3px;
}
.ctx-arrow {
position: absolute;
top: -3px;
right: -20px;
color: var(--foreground-subdued);
opacity: 0;
transition: opacity var(--fast) var(--transition);
&.active {
opacity: 1;
}
}
&:hover {
.ctx-arrow {
opacity: 1;
}
}
}
.note {
display: block;
margin-top: 4px;
color: var(--foreground-subdued);
font-style: italic;
}
</style>

View File

@@ -33,12 +33,14 @@
name="keyboard_arrow_up"
class="step-up"
@click="stepUp"
:disabled="disabled"
/>
<v-icon
:class="{ disabled: value <= min }"
name="keyboard_arrow_down"
class="step-down"
@click="stepDown"
:disabled="disabled"
/>
</span>
<div v-if="$slots.append" class="append">
@@ -187,6 +189,7 @@ export default defineComponent({
function stepUp() {
if (!input.value) return;
if (props.disabled === true) return;
if (props.value < props.max) {
input.value.stepUp();
@@ -196,6 +199,7 @@ export default defineComponent({
function stepDown() {
if (!input.value) return;
if (props.disabled === true) return;
if (props.value > props.min) {
input.value.stepDown();

View File

@@ -18,6 +18,7 @@ import InterfaceOneToMany from './one-to-many';
import InterfaceHash from './hash';
import InterfaceSlug from './slug';
import InterfaceUser from './user';
import InterfaceRepeater from './repeater';
export const interfaces = [
InterfaceTextInput,
@@ -40,6 +41,7 @@ export const interfaces = [
InterfaceHash,
InterfaceSlug,
InterfaceUser,
InterfaceRepeater,
];
export default interfaces;

View File

@@ -0,0 +1,11 @@
import { defineInterface } from '../define';
import InterfaceRepeater from './repeater.vue';
export default defineInterface(({ i18n }) => ({
id: 'repeater',
name: i18n.t('repeater'),
icon: 'replay',
types: ['json'],
component: InterfaceRepeater,
options: [],
}));

View File

@@ -0,0 +1,53 @@
<template>
<div class="form">
<v-divider />
<v-form
:disabled="disabled"
:fields="fields"
:edits="value"
@input="$emit('input', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
value: {
type: Object,
default: null,
},
fields: {
type: Array as PropType<Partial<Field>[]>,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
},
});
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/type-styles.scss';
.form {
--form-vertical-gap: 24px;
--form-horizontal-gap: 12px;
padding: 12px;
padding-top: 0;
::v-deep .type-label {
@include type-text;
}
}
.v-divider {
margin-bottom: 12px;
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div :class="{ subdued: !displayValue }" class="header type-text" @click="toggle">
<v-icon v-if="disabled === false" name="drag_handle" class="drag-handle" />
{{ displayValue ? displayValue : placeholder }}
<span class="spacer" />
<v-icon
v-if="disabled === false"
name="close"
class="delete"
@click.stop.prevent="$emit('delete')"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import { render } from 'micromustache';
import i18n from '@/lang';
export default defineComponent({
props: {
value: {
type: Object,
default: null,
},
template: {
type: String,
required: true,
},
placeholder: {
type: String,
default: i18n.t('empty_item'),
},
toggle: {
type: Function,
required: true,
},
active: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props) {
const displayValue = computed(() =>
props.value ? render(props.template, props.value) : null
);
return { displayValue };
},
});
</script>
<style lang="scss" scoped>
.header {
display: flex;
align-items: center;
padding: 12px;
cursor: pointer;
}
.spacer {
flex-grow: 1;
}
.subdued {
color: var(--foreground-subdued);
}
.v-icon {
--v-icon-color: var(--foreground-subdued);
}
.drag-handle {
margin-right: 8px;
&:hover {
--v-icon-color: var(--foreground-normal);
}
}
.delete:hover {
--v-icon-color: var(--danger);
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<v-item class="row" v-slot:default="{ active, toggle }">
<repeater-row-header
:template="template"
:value="value"
:active="active"
:toggle="toggle"
@delete="$emit('delete')"
:disabled="disabled"
/>
<transition-expand>
<div v-if="active">
<repeater-row-form
:disabled="disabled"
:fields="fields"
:value="value"
@input="$emit('input', $event)"
/>
</div>
</transition-expand>
</v-item>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
import RepeaterRowHeader from './repeater-row-header.vue';
import RepeaterRowForm from './repeater-row-form.vue';
export default defineComponent({
components: { RepeaterRowHeader, RepeaterRowForm },
props: {
value: {
type: Object,
default: null,
},
fields: {
type: Array as PropType<Partial<Field>[]>,
default: () => [],
},
template: {
type: String,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
});
</script>
<style lang="scss" scoped>
.row {
background-color: var(--background-subdued);
border-radius: var(--border-radius);
& + .row {
margin-top: 8px;
}
}
</style>

View File

@@ -0,0 +1,137 @@
<template>
<v-item-group class="repeater">
<draggable :value="value" handle=".drag-handle" @input="onSort">
<repeater-row
v-for="(row, index) in value"
:key="index"
:value="row"
:template="template"
:fields="fields"
@input="updateValues(index, $event)"
@delete="removeItem(row)"
:disabled="disabled"
/>
</draggable>
<button @click="addNew" class="add-new" v-if="showAddNew">
<v-icon name="add" />
{{ createItemText }}
</button>
</v-item-group>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import RepeaterRow from './repeater-row.vue';
import { Field } from '@/stores/fields/types';
import Draggable from 'vuedraggable';
import i18n from '@/lang';
export default defineComponent({
components: { RepeaterRow, Draggable },
props: {
value: {
type: Array,
default: null,
},
fields: {
type: Array as PropType<Partial<Field>[]>,
default: () => [],
},
template: {
type: String,
required: true,
},
createItemText: {
type: String,
default: i18n.t('add_a_new_item'),
},
limit: {
type: Number,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const showAddNew = computed(() => {
if (props.disabled) return false;
if (props.value === null) return true;
if (props.limit === null) return true;
if (Array.isArray(props.value) && props.value.length <= props.limit) return true;
return false;
});
return { updateValues, onSort, removeItem, addNew, showAddNew };
function updateValues(index: number, updatedValues: any) {
emit(
'input',
props.value.map((item, i) => {
if (i === index) {
return updatedValues;
}
return item;
})
);
}
function onSort(sortedItems: any[]) {
emit('input', sortedItems);
}
function removeItem(row: any) {
if (props.value) {
emit(
'input',
props.value.filter((existingItem) => existingItem !== row)
);
} else {
emit('input', null);
}
}
function addNew() {
const newDefaults: any = {};
props.fields.forEach((field) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
newDefaults[field.field!] = field.default_value;
});
if (props.value !== null) {
emit('input', [...props.value, newDefaults]);
} else {
emit('input', [newDefaults]);
}
}
},
});
</script>
<style lang="scss" scoped>
.add-new {
display: flex;
align-items: center;
width: 100%;
height: 48px;
margin-top: 8px;
padding: 10px; // 10 not 12, offset for border
color: var(--foreground-subdued);
border: 2px dashed var(--border-normal);
border-radius: var(--border-radius);
transition: var(--fast) var(--transition);
transition-property: color, border-color;
.v-icon {
margin-right: 8px;
}
&:hover {
color: var(--primary);
border-color: var(--primary);
}
}
</style>

View File

@@ -177,6 +177,15 @@
"webhooks": "Webhooks",
"roles": "User Roles",
"field_width": "Field Width",
"field_width_half": "Half Width (Wraps)",
"field_width_left": "Half Width (Left Only)",
"field_width_right": "Half Width (Right Only)",
"field_width_full": "Full Width",
"field_width_fill": "Fill the Page",
"add_a_new_item": "Add a new item...",
"add_filter": "Add Filter",
"user_directory": "User Directory",
@@ -306,6 +315,8 @@
"color": "Color",
"circle": "Circle",
"empty_item": "Empty Item",
"filter": "Filter",
"advanced_filter": "Advanced Filter",
"operators": {
@@ -684,12 +695,6 @@
"field_setup_options": "All set! Just review these interface options...",
"field_type": "Field Type",
"field_updated": "Field Updated",
"field_width": "Field Width",
"field_width_half": "Half Width (Wraps)",
"field_width_left": "Half Width (Left Only)",
"field_width_right": "Half Width (Right Only)",
"field_width_full": "Full Width",
"field_width_fill": "Fill the Page",
"field_width_note": "The width of this field within the form layout. Half-widths wrap based on other fields and their sort order.",
"fields_are_saved_instantly": "Saves Automatically",
"fieldtypes": {

View File

@@ -1,3 +1,5 @@
@import './mixins/type-styles.scss';
:root {
--family-monospace: 'Fira Code', monospace;
--family-serif: 'Merriweather', serif;
@@ -5,31 +7,13 @@
}
.type-title {
color: var(--foreground-normal);
font-weight: normal;
font-size: 24px;
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 29px;
letter-spacing: -0.8px;
@include type-title;
}
.type-label {
color: var(--foreground-normal);
font-weight: 600;
font-size: 16px;
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 19px;
letter-spacing: -0.32px;
@include type-label;
}
.type-text {
color: var(--foreground-normal);
font-weight: 500;
font-size: 14px;
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 22px;
letter-spacing: -0.15px;
@include type-text;
}

View File

@@ -0,0 +1,29 @@
@mixin type-title {
color: var(--foreground-normal);
font-weight: normal;
font-size: 24px;
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 29px;
letter-spacing: -0.8px;
}
@mixin type-label {
color: var(--foreground-normal);
font-weight: 600;
font-size: 16px;
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 19px;
letter-spacing: -0.32px;
}
@mixin type-text {
color: var(--foreground-normal);
font-weight: 500;
font-size: 14px;
font-family: var(--family-sans-serif);
font-style: normal;
line-height: 22px;
letter-spacing: -0.15px;
}