mirror of
https://github.com/directus/directus.git
synced 2026-01-28 06:58:02 -05:00
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:
79
src/components/v-form/form-field-interface.vue
Normal file
79
src/components/v-form/form-field-interface.vue
Normal 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>
|
||||
88
src/components/v-form/form-field-label.vue
Normal file
88
src/components/v-form/form-field-label.vue
Normal 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>
|
||||
49
src/components/v-form/form-field-menu.vue
Normal file
49
src/components/v-form/form-field-menu.vue
Normal 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>
|
||||
111
src/components/v-form/form-field.vue
Normal file
111
src/components/v-form/form-field.vue
Normal 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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
src/interfaces/repeater/index.ts
Normal file
11
src/interfaces/repeater/index.ts
Normal 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: [],
|
||||
}));
|
||||
53
src/interfaces/repeater/repeater-row-form.vue
Normal file
53
src/interfaces/repeater/repeater-row-form.vue
Normal 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>
|
||||
88
src/interfaces/repeater/repeater-row-header.vue
Normal file
88
src/interfaces/repeater/repeater-row-header.vue
Normal 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>
|
||||
62
src/interfaces/repeater/repeater-row.vue
Normal file
62
src/interfaces/repeater/repeater-row.vue
Normal 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>
|
||||
137
src/interfaces/repeater/repeater.vue
Normal file
137
src/interfaces/repeater/repeater.vue
Normal 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>
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
29
src/styles/mixins/type-styles.scss
Normal file
29
src/styles/mixins/type-styles.scss
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user