Field setup (#536)

* Use the correct table header widths for settings / colections

* [WIP] Rework field setup flow

* Finish setup actions logic

* Remove old field-setup

* Use fixed strategy in popper, render to portal

* Stop relying on no-overflow mode

* Stop propagation of outside click event

* Finish field setup, start on relational setup

* Finish up v-menu

* Add relational m2o markup

* Add relational m2o markup

* Remove empty source, fix codesmell
This commit is contained in:
Rijk van Zanten
2020-05-08 14:07:00 -04:00
committed by GitHub
parent 6fd43f8e8e
commit a070a25353
23 changed files with 1296 additions and 673 deletions

View File

@@ -7,6 +7,7 @@
</transition>
<router-view v-if="!hydrating" />
<portal-target name="dialog-outlet" multiple />
<portal-target name="popper-outlet" multiple />
</div>
</template>

View File

@@ -111,6 +111,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
dbSafe: {
type: Boolean,
default: false,
},
},
setup(props, { emit, listeners }) {
const input = ref<HTMLInputElement>(null);
@@ -133,6 +137,13 @@ export default defineComponent({
value = slugify(value, { separator: props.slugSeparator });
}
if (props.dbSafe === true) {
value = value.toLowerCase();
value = value.replace(/\s/g, '_');
// Replace é -> e etc
value = value.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
emit('input', value);
}

View File

@@ -41,7 +41,9 @@ export function usePopper(
popperInstance.value = createPopper(reference.value!, popper.value!, {
placement: options.value.attached ? 'bottom-start' : options.value.placement,
modifiers: getModifiers(resolve),
strategy: 'fixed',
});
popperInstance.value.forceUpdate();
});
}
@@ -58,7 +60,12 @@ export function usePopper(
offset: options.value.attached ? [0, 0] : [0, 8],
},
},
preventOverflow,
{
...preventOverflow,
options: {
padding: 8,
},
},
computeStyles,
flip,
eventListeners,

View File

@@ -1,11 +1,5 @@
<template>
<div
class="v-menu"
v-click-outside="{
handler: deactivate,
disabled: isActive === false || closeOnClick === false,
}"
>
<div class="v-menu">
<div ref="activator" class="v-menu-activator" :class="{ attached }">
<slot
name="activator"
@@ -18,30 +12,33 @@
/>
</div>
<div
ref="popper"
class="v-menu-popper"
:class="{ active: isActive, attached }"
:data-placement="popperPlacement"
:style="styles"
>
<div
class="arrow"
:class="{ active: showArrow && isActive }"
:style="arrowStyles"
data-popper-arrow
/>
<transition-expand :to="popperPlacement">
<portal to="popper-outlet">
<transition name="bounce">
<div
class="v-menu-popper"
:id="id"
:class="{ active: isActive, attached }"
:data-placement="popperPlacement"
:style="styles"
v-if="isActive"
class="v-menu-content"
:class="{ 'overflow-scroll': overflowScroll }"
@click="onContentClick"
v-click-outside="{
handler: deactivate,
disabled: isActive === false || closeOnClick === false,
events: ['click'],
}"
>
<slot :active="isActive" />
<div
class="arrow"
:class="{ active: showArrow && isActive }"
:style="arrowStyles"
data-popper-arrow
/>
<div class="v-menu-content" @click.stop="onContentClick">
<slot :active="isActive" />
</div>
</div>
</transition-expand>
</div>
</transition>
</portal>
</div>
</template>
@@ -49,6 +46,20 @@
import { defineComponent, ref, PropType, computed, watch } from '@vue/composition-api';
import { usePopper } from './use-popper';
import { Placement } from '@popperjs/core';
import { nanoid } from 'nanoid';
import Vue from 'vue';
/**
* @NOTE
*
* The framerate takes a little hit when opening menus, as we're rendering the content of it while
* we're opening it. We could potentially optimize this by rendering the menu content ahead of time,
* so all the processing power is freed up for the popper calculations and transition logic.
*
* However, we _can not_ render all menu content at all times, as that greatly decreases perf in the
* app. I'm thinking we might be able to pre-render the menu content on hover of the activator, so
* the actual act of opening the menu is quicker.
*/
export default defineComponent({
props: {
@@ -80,19 +91,25 @@ export default defineComponent({
type: Boolean,
default: false,
},
overflowScroll: {
type: Boolean,
default: true,
},
},
setup(props, { emit }) {
const activator = ref<HTMLElement>(null);
const popper = ref<HTMLElement>(null);
const reference = computed<HTMLElement | null>(() => {
return (activator.value as HTMLElement)?.childNodes[0] as HTMLElement;
});
const id = computed(() => nanoid());
const popper = ref<HTMLElement>(null);
const { isActive, activate, deactivate, toggle } = useActiveState();
watch(isActive, () => {
Vue.nextTick(() => {
popper.value = document.getElementById(id.value);
});
});
const { start, stop, styles, arrowStyles, placement: popperPlacement } = usePopper(
reference,
popper,
@@ -103,12 +120,11 @@ export default defineComponent({
}))
);
const { isActive, activate, deactivate, toggle } = useActiveState();
return {
id,
activator,
reference,
popper,
reference,
isActive,
toggle,
deactivate,
@@ -136,10 +152,10 @@ export default defineComponent({
},
});
watch(isActive, async (newActive) => {
if (newActive !== null && newActive === true) {
watch(popper, async () => {
if (popper.value !== null) {
await start();
} else {
} else if (stop) {
stop();
}
});
@@ -185,9 +201,9 @@ body {
}
.v-menu-popper {
position: absolute;
position: fixed;
left: -999px;
z-index: 5;
z-index: 500;
min-width: var(--v-menu-min-width);
transform: translateY(2px);
pointer-events: none;
@@ -195,107 +211,272 @@ body {
&.active {
pointer-events: all;
}
}
.arrow,
.arrow::before,
.arrow::after {
position: absolute;
z-index: 1;
width: 8px;
height: 8px;
border-radius: 2px;
.arrow,
.arrow::before,
.arrow::after {
position: absolute;
z-index: 1;
width: 8px;
height: 8px;
border-radius: 2px;
}
.arrow {
&::before,
&::after {
background: var(--border-normal);
transform: rotate(45deg) scale(0);
transition: transform var(--fast) var(--transition-out);
transition-delay: 0;
content: '';
}
.arrow {
&::before,
&::after {
background: var(--border-normal);
transform: rotate(45deg) scale(0);
transition: transform var(--fast) var(--transition-out);
transition-delay: 0;
content: '';
}
&.active::before,
&.active::after {
transform: rotate(45deg) scale(1);
transition: transform var(--medium) var(--transition-in);
}
&.active::before,
&.active::after {
transform: rotate(45deg) scale(1);
transition: transform var(--medium) var(--transition-in);
}
&::after {
background: var(--background-subdued);
}
}
&::after {
background: var(--background-subdued);
[data-placement^='top'] .arrow {
bottom: -4px;
&::after {
bottom: 3px;
}
}
[data-placement^='bottom'] .arrow {
top: -4px;
&::after {
top: 3px;
}
}
[data-placement^='right'] .arrow {
left: -4px;
&::after {
left: 3px;
}
}
[data-placement^='left'] .arrow {
right: -4px;
&::after {
right: 3px;
}
}
.v-menu-content {
max-height: 30vh;
padding: 0 4px;
overflow-x: hidden;
overflow-y: auto;
background-color: var(--background-subdued);
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
box-shadow: 0px 4px 12px rgba(38, 50, 56, 0.1);
transition-timing-function: var(--transition-out);
transition-duration: var(--fast);
transition-property: opacity, transform;
contain: content;
.v-list {
--v-list-background-color: transparent;
}
}
[data-placement='top'] > .v-menu-content {
transform-origin: bottom center;
}
[data-placement='top-start'] > .v-menu-content {
transform-origin: bottom left;
}
[data-placement='top-end'] > .v-menu-content {
transform-origin: bottom right;
}
[data-placement='right'] > .v-menu-content {
transform-origin: center left;
}
[data-placement='right-start'] > .v-menu-content {
transform-origin: top left;
}
[data-placement='right-end'] > .v-menu-content {
transform-origin: bottom left;
}
[data-placement='bottom'] > .v-menu-content {
transform-origin: top center;
}
[data-placement='bottom-start'] > .v-menu-content {
transform-origin: top left;
}
[data-placement='bottom-end'] > .v-menu-content {
transform-origin: top right;
}
[data-placement='left'] > .v-menu-content {
transform-origin: center right;
}
[data-placement='left-start'] > .v-menu-content {
transform-origin: top right;
}
[data-placement='left-end'] > .v-menu-content {
transform-origin: bottom right;
}
.bounce-enter-active,
.bounce-leave-active {
transition: opacity var(--fast) var(--transition);
& > .v-menu-content {
transition: transform var(--fast) cubic-bezier(0, 0, 0.2, 1.5);
}
}
.bounce-enter,
.bounce-leave-to {
opacity: 0;
&[data-placement='top'] > .v-menu-content {
transform: scaleY(0.8);
}
&[data-placement='top-start'] > .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
}
&[data-placement='top-end'] > .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
}
&[data-placement='right'] > .v-menu-content {
transform: scaleX(0.8);
}
&[data-placement='right-start'] > .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
}
&[data-placement='right-end'] > .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
}
&[data-placement='bottom'] > .v-menu-content {
transform: scaleY(0.8);
}
&[data-placement='bottom-start'] > .v-menu-content {
transform: scaleY(0.8);
}
&[data-placement='bottom-end'] > .v-menu-content {
transform: scaleY(0.8);
}
&[data-placement='left'] > .v-menu-content {
transform: scaleX(0.8);
}
&[data-placement='left-start'] > .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
}
&[data-placement='left-end'] > .v-menu-content {
transform: scaleY(0.8) scaleX(0.8);
}
}
// .bounce-enter,
// .bounce-leave-to {
// & [data-placement='top'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='top-start'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='top-end'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='right'] > .v-menu-content {
// transform: scaleX(0.8);
// }
// & [data-placement='right-start'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='right-end'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='bottom'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='bottom-start'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='bottom-end'] > .v-menu-content {
// transform: scaleY(0.8);
// }
// & [data-placement='left'] > .v-menu-content {
// transform: scaleX(0.8);
// }
// & [data-placement='left-start'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// & [data-placement='left-end'] > .v-menu-content {
// transform: scaleY(0.8) scaleX(0.8);
// }
// }
// .bounce-enter-active > .v-menu-content,
// .bounce-leave-active > .v-menu-content {
// transform: scaleY(1) scaleX(1);
// transition-timing-function: cubic-bezier(0, 0, 0.2, 1.5);
// transition-duration: var(--fast);
// }
.attached {
&[data-placement^='top'] {
> .v-menu-content {
border-bottom: none;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
&[data-placement^='top'] .arrow {
bottom: -4px;
&::after {
bottom: 3px;
}
}
&[data-placement^='bottom'] .arrow {
top: -4px;
&::after {
top: 3px;
}
}
&[data-placement^='right'] .arrow {
left: -4px;
&::after {
left: 3px;
}
}
&[data-placement^='left'] .arrow {
right: -4px;
&::after {
right: 3px;
}
}
.v-menu-content {
max-height: 50vh;
padding: 0 4px;
background-color: var(--background-subdued);
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
box-shadow: 0px 4px 12px rgba(38, 50, 56, 0.1);
transition-timing-function: var(--transition-out);
transition-duration: var(--fast);
transition-property: opacity, transform;
&.overflow-scroll {
contain: content;
overflow-x: hidden;
overflow-y: auto;
}
.v-list {
--v-list-background-color: transparent;
}
}
&.attached {
&[data-placement^='top'] {
> .v-menu-content {
border-bottom: none;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
}
&[data-placement^='bottom'] {
> .v-menu-content {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
&[data-placement^='bottom'] {
> .v-menu-content {
border-top: none;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
}
}

View File

@@ -114,6 +114,7 @@ export function onEvent({
handler: Handler;
middleware: Middleware;
}) {
event.stopPropagation();
const path = event.composedPath();
const isClickOutside = path
? path.indexOf(el) < 0

View File

@@ -1,5 +1,5 @@
<template>
<v-menu attached :disabled="disabled" :overflow-scroll="false">
<v-menu attached :disabled="disabled">
<template #activator="{ toggle, active }">
<v-input
:active="active"

View File

@@ -29,7 +29,7 @@ export default defineInterface(({ i18n }) => ({
field: 'trim',
name: 'Trim',
width: 'half',
interface: 'switch',
interface: 'toggle',
},
{
field: 'font',

View File

@@ -89,9 +89,15 @@
"item_delete_success": "Item Deleted | Items Deleted",
"item_delete_failed": "Couldn't Delete Item | Couldn't Delete Items",
"item_in": "Item {primaryKey} in {collection} | Items {primaryKey} in {collection}",
"this_collection": "This Collection",
"related_collection": "Related Collection",
"submit": "Submit",
"add_field_related": "Add Field to Related Collection",
"create_corresponding_field": "Create Corresponding Field",
"corresponding_field_name": "Corresponding Field Name",
"datetime": "DateTime",
"date-fns_date": "PPP",
"date-fns_time": "K:mm a",
@@ -114,6 +120,10 @@
"december": "December"
},
"many_to_many": "Many to Many (M2M)",
"one_to_many": "One to Many (O2M)",
"many_to_one": "Many to One (M2O)",
"set_to_now": "Set to Now",
"name": "Name",
@@ -277,7 +287,7 @@
"max": "Max",
"minimum_value": "Minimum Value",
"maximum_value": "Maximum Value",
"placeholder": "Placerholder",
"placeholder": "Placeholder",
"step_interval": "Step Interval",
"icon_left": "Icon (Left)",
"icon_right": "Icon (Right)",
@@ -805,7 +815,6 @@
"project_not_configured": "Project Not Configured",
"readable_fields_copy": "Select the fields that the user can view",
"regex": "RegEx",
"related_collection": "Related Collection",
"related_entries": "Has related entries",
"relational": "Relational",
"relationship": "Relationship",
@@ -859,7 +868,6 @@
"translated_field_name": "Translated field name...",
"not_translated_in_language": "Not Translated in {language}",
"this_item_is_not_available": "This item is not available.",
"this_collection": "This Collection",
"to": "To",
"turn_all_on": "Turn all on",
"turn_all_off": "Turn all off",

View File

@@ -100,14 +100,16 @@ export default defineComponent({
{
text: '',
value: 'icon',
width: 42,
sortable: false,
},
{
text: i18n.t('name'),
value: 'collection',
value: 'name',
width: 300,
},
{
text: i18n.t('description'),
text: i18n.t('note'),
value: 'note',
},
]);

View File

@@ -1,105 +1,108 @@
<template>
<v-menu attached :class="hidden ? 'half' : field.width" close-on-content-click>
<template #activator="{ toggle, active }">
<v-input
class="field"
:class="{ hidden, active }"
readonly
@click="toggle"
:value="field.name"
>
<template #prepend>
<v-icon class="drag-handle" name="drag_indicator" @click.stop />
</template>
<div :class="hidden ? 'half' : field.width">
<v-menu attached close-on-content-click>
<template #activator="{ toggle, active }">
<v-input
class="field"
:class="{ hidden, active }"
readonly
@click="toggle"
:value="field.name"
>
<template #prepend>
<v-icon class="drag-handle" name="drag_indicator" @click.stop />
</template>
<template #append>
<v-icon name="expand_more" />
</template>
</v-input>
</template>
<template #append>
<v-icon name="expand_more" />
</template>
</v-input>
</template>
<v-list dense>
<v-list-item @click="$emit('edit')">
<v-list-item-icon><v-icon name="edit" /></v-list-item-icon>
<v-list-item-content>
{{ $t('edit_field') }}
</v-list-item-content>
</v-list-item>
<v-list dense>
<v-list-item @click="$emit('edit')">
<v-list-item-icon><v-icon name="edit" /></v-list-item-icon>
<v-list-item-content>
{{ $t('edit_field') }}
</v-list-item-content>
</v-list-item>
<v-dialog v-model="duplicateActive">
<template #activator="{ on }">
<v-list-item @click="on">
<v-list-item-icon>
<v-icon name="control_point_duplicate" />
</v-list-item-icon>
<v-list-item-content>{{ $t('duplicate_field') }}</v-list-item-content>
</v-list-item>
</template>
<v-dialog v-model="duplicateActive">
<template #activator="{ on }">
<v-list-item @click="on">
<v-list-item-icon>
<v-icon name="control_point_duplicate" />
</v-list-item-icon>
<v-list-item-content>{{ $t('duplicate_field') }}</v-list-item-content>
</v-list-item>
</template>
<v-card>
<v-card-title>{{ $t('duplicate_where_to') }}</v-card-title>
<v-card-text>
<span class="label">{{ $tc('collection', 0) }}</span>
<v-select class="monospace" :items="collections" v-model="duplicateTo" />
<v-card>
<v-card-title>{{ $t('duplicate_where_to') }}</v-card-title>
<v-card-text>
<span class="label">{{ $tc('collection', 0) }}</span>
<v-select
class="monospace"
:items="collections"
v-model="duplicateTo"
/>
<span class="label">{{ $tc('field', 0) }}</span>
<v-input class="monospace" v-model="duplicateName" />
</v-card-text>
<v-card-actions>
<v-button secondary @click="duplicateActive = false">
{{ $t('cancel') }}
</v-button>
<v-button @click="saveDuplicate" :loading="duplicating">
{{ $t('duplicate') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-divider />
<v-list-item @click="setWidth('half')" :disabled="hidden || field.width === 'half'">
<v-list-item-icon><v-icon name="border_vertical" /></v-list-item-icon>
<v-list-item-content>{{ $t('half_width') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="setWidth('full')" :disabled="hidden || field.width === 'full'">
<v-list-item-icon><v-icon name="border_right" /></v-list-item-icon>
<v-list-item-content>{{ $t('full_width') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="setWidth('fill')" :disabled="hidden || field.width === 'fill'">
<v-list-item-icon><v-icon name="aspect_ratio" /></v-list-item-icon>
<v-list-item-content>{{ $t('fill_width') }}</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item @click="$emit('toggle-visibility', field)">
<template v-if="field.hidden_detail === false">
<v-list-item-icon><v-icon name="visibility_off" /></v-list-item-icon>
<v-list-item-content>{{ $t('hide_field_on_detail') }}</v-list-item-content>
</template>
<template v-else>
<v-list-item-icon><v-icon name="visibility" /></v-list-item-icon>
<v-list-item-content>{{ $t('show_field_on_detail') }}</v-list-item-content>
</template>
</v-list-item>
<span class="label">{{ $tc('field', 0) }}</span>
<v-input class="monospace" v-model="duplicateName" />
</v-card-text>
<v-card-actions>
<v-button secondary @click="duplicateActive = false">
{{ $t('cancel') }}
</v-button>
<v-button @click="saveDuplicate" :loading="duplicating">
{{ $t('duplicate') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-divider />
<v-list-item @click="setWidth('half')" :disabled="hidden || field.width === 'half'">
<v-list-item-icon><v-icon name="border_vertical" /></v-list-item-icon>
<v-list-item-content>{{ $t('half_width') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="setWidth('full')" :disabled="hidden || field.width === 'full'">
<v-list-item-icon><v-icon name="border_right" /></v-list-item-icon>
<v-list-item-content>{{ $t('full_width') }}</v-list-item-content>
</v-list-item>
<v-list-item @click="setWidth('fill')" :disabled="hidden || field.width === 'fill'">
<v-list-item-icon><v-icon name="aspect_ratio" /></v-list-item-icon>
<v-list-item-content>{{ $t('fill_width') }}</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item @click="$emit('toggle-visibility', field)">
<template v-if="field.hidden_detail === false">
<v-list-item-icon><v-icon name="visibility_off" /></v-list-item-icon>
<v-list-item-content>{{ $t('hide_field_on_detail') }}</v-list-item-content>
</template>
<template v-else>
<v-list-item-icon><v-icon name="visibility" /></v-list-item-icon>
<v-list-item-content>{{ $t('show_field_on_detail') }}</v-list-item-content>
</template>
</v-list-item>
<v-dialog v-model="deleteActive">
<template #activator="{ on }">
<v-list-item @click="on">
<v-list-item-icon><v-icon name="delete" /></v-list-item-icon>
<v-list-item-content>
{{ $t('delete_field') }}
</v-list-item-content>
</v-list-item>
</template>
<v-card>
<v-card-title>Are you sure you want to delete this field?</v-card-title>
<v-card-actions>
<v-button @click="deleteActive = false" secondary>Cancel</v-button>
<v-button :loading="deleting" @click="deleteField">Delete</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</v-list>
</v-menu>
<v-list-item @click="deleteActive = true">
<v-list-item-icon><v-icon name="delete" /></v-list-item-icon>
<v-list-item-content>
{{ $t('delete_field') }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="deleteActive">
<v-card>
<v-card-title>Are you sure you want to delete this field?</v-card-title>
<v-card-actions>
<v-button @click="deleteActive = false" secondary>Cancel</v-button>
<v-button :loading="deleting" @click="deleteField">Delete</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">

View File

@@ -1,34 +1,29 @@
<template>
<v-tab-item value="advanced">
<h2 class="title" v-if="isNew">{{ $t('advanced_options_title') }}</h2>
<div>
<h2 class="type-title" v-if="isNew">{{ $t('advanced_options_title') }}</h2>
<v-form :initial-values="existingField" v-model="_edits" :fields="fields" primary-key="+" />
</v-tab-item>
<v-form :edits="value" @input="$emit('input', $event)" :fields="fields" primary-key="+" />
</div>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
import i18n from '@/lang';
import { FormField } from '@/components/v-form/types';
import { useSync } from '@/composables/use-sync';
import { i18n } from '@/lang';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
existingField: {
type: Object as PropType<Field>,
default: null,
},
edits: {
type: Object as PropType<Partial<Field>>,
default: null,
},
isNew: {
type: Boolean,
default: false,
},
value: {
type: Object as PropType<Field>,
required: true,
},
},
setup(props, { emit }) {
setup(props) {
const fields = computed(() => {
const fields: FormField[] = [
{
@@ -56,7 +51,7 @@ export default defineComponent({
options: null,
},
{
field: 'default',
field: 'default_value',
name: i18n.t('default_value'),
interface: 'text-input' /** @TODO base on selected datatype */,
width: 'half',
@@ -74,42 +69,42 @@ export default defineComponent({
{
field: 'required',
name: i18n.t('required'),
interface: 'switch',
interface: 'toggle',
width: 'half',
options: null,
},
{
field: 'readonly',
name: i18n.t('readonly'),
interface: 'switch',
interface: 'toggle',
width: 'half',
options: null,
},
{
field: 'hidden_detail',
name: i18n.t('hide_on_detail'),
interface: 'switch',
interface: 'toggle',
width: 'half',
options: null,
},
{
field: 'hidden_browse',
name: i18n.t('hide_on_browse'),
interface: 'switch',
interface: 'toggle',
width: 'half',
options: null,
},
{
field: 'unique',
name: i18n.t('unique'),
interface: 'switch',
interface: 'toggle',
width: 'half',
options: null,
},
{
field: 'primary_key',
name: i18n.t('primary_key'),
interface: 'switch',
interface: 'toggle',
width: 'half',
options: null,
},
@@ -146,9 +141,7 @@ export default defineComponent({
return fields;
});
const _edits = useSync(props, 'edits', emit);
return { fields, _edits };
return { fields };
},
});
</script>

View File

@@ -1,60 +1,63 @@
<template>
<v-tab-item value="display">
<h2 class="title" v-if="isNew">{{ $t('display_setup_title') }}</h2>
<v-fancy-select :items="items" v-model="_display" />
<transition-expand>
<v-form
v-if="
selectedDisplay &&
selectedDisplay.options &&
Array.isArray(selectedDisplay.options)
"
:fields="selectedDisplay.options"
primary-key="+"
v-model="_options"
/>
</transition-expand>
</v-tab-item>
<div>
<h2 class="type-title" v-if="isNew">{{ $t('display_setup_title') }}</h2>
<v-fancy-select
:items="items"
:value="value.display"
@input="emitValue('display', $event)"
/>
<v-form
v-if="
selectedDisplay && selectedDisplay.options && Array.isArray(selectedDisplay.options)
"
:fields="selectedDisplay.options"
primary-key="+"
:edits="value.options"
@input="emitValue('options', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import useSync from '@/composables/use-sync/';
import { defineComponent, computed, PropType } from '@vue/composition-api';
import displays from '@/displays/';
import { FancySelectItem } from '@/components/v-fancy-select/types';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
display: {
type: String,
default: null,
},
options: {
type: Object as PropType<any>,
default: null,
},
isNew: {
type: Boolean,
default: false,
},
value: {
type: Object as PropType<Field>,
required: true,
},
},
setup(props, { emit }) {
const _display = useSync(props, 'display', emit);
const _options = useSync(props, 'options', emit);
const items = computed<FancySelectItem[]>(() => {
return displays.map((display) => ({
text: display.name,
value: display.id,
icon: display.icon,
return displays.map((inter) => ({
text: inter.name,
value: inter.id,
icon: inter.icon,
}));
});
const selectedDisplay = computed(() => {
return displays.find((display) => display.id === _display.value) || null;
return displays.find((inter) => inter.id === props.value.display) || null;
});
return { _display, _options, items, selectedDisplay };
return { emitValue, items, selectedDisplay };
function emitValue(key: string, value: any) {
emit('input', {
...props.value,
[key]: value,
});
}
},
});
</script>

View File

@@ -1,46 +1,48 @@
<template>
<v-tab-item value="field">
<h2 class="title" v-if="isNew">{{ $t('field_setup_title') }}</h2>
<div>
<h2 class="type-title" v-if="isNew">{{ $t('field_setup_title') }}</h2>
<label for="name" class="label">{{ $t('name') }}</label>
<div class="type-label">{{ $t('name') }}</div>
<v-input
class="field"
:value="value.field"
@input="emitValue('field', $event)"
db-safe
:disabled="isNew === false"
id="name"
v-model="_field"
:placeholder="$t('enter_field_name')"
/>
<v-fancy-select :disabled="isNew === false" :items="items" v-model="_type" />
</v-tab-item>
<v-fancy-select
:disabled="isNew === false"
:items="items"
:value="localType"
@input="$emit('update:localType', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import useSync from '@/composables/use-sync/';
import { FieldType } from './types';
import { Field } from '@/stores/fields/types';
import { LocalType } from './types';
import i18n from '@/lang';
import { FancySelectItem } from '@/components/v-fancy-select/types';
import { i18n } from '@/lang';
export default defineComponent({
props: {
field: {
type: String,
default: null,
value: {
type: Object as PropType<Field>,
required: true,
},
type: {
type: String as PropType<FieldType>,
localType: {
type: String as PropType<LocalType>,
default: null,
validator: (val: string) => ['standard', 'relational', 'file', 'files'].includes(val),
},
isNew: {
type: Boolean,
default: false,
default: true,
},
},
setup(props, { emit }) {
const _field = useSync(props, 'field', emit);
const _type = useSync(props, 'type', emit);
const items = computed<FancySelectItem[]>(() => [
{
text: i18n.t('standard_field'),
@@ -64,13 +66,22 @@ export default defineComponent({
},
]);
return { _field, _type, items };
return { emitValue, items };
function emitValue(key: string, value: any) {
emit('input', {
...props.value,
[key]: value,
});
}
},
});
</script>
<style lang="scss" scoped>
.v-input {
.field {
--v-input-font-family: var(--family-monospace);
margin-bottom: 48px;
}
</style>

View File

@@ -1,47 +1,45 @@
<template>
<v-tab-item value="interface">
<h2 class="title" v-if="isNew">{{ $t('interface_setup_title') }}</h2>
<v-fancy-select :items="items" v-model="_interface" />
<transition-expand>
<v-form
v-if="
selectedInterface &&
selectedInterface.options &&
Array.isArray(selectedInterface.options)
"
:fields="selectedInterface.options"
primary-key="+"
v-model="_options"
/>
</transition-expand>
</v-tab-item>
<div>
<h2 class="type-title" v-if="isNew">{{ $t('relationship_setup_title') }}</h2>
<v-fancy-select
:items="items"
:value="value.interface"
@input="emitValue('interface', $event)"
/>
<v-form
v-if="
selectedInterface &&
selectedInterface.options &&
Array.isArray(selectedInterface.options)
"
:fields="selectedInterface.options"
primary-key="+"
:edits="value.options"
@input="emitValue('options', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import useSync from '@/composables/use-sync/';
import { defineComponent, computed, PropType } from '@vue/composition-api';
import interfaces from '@/interfaces/';
import { FancySelectItem } from '@/components/v-fancy-select/types';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
interface: {
type: String,
default: null,
},
options: {
type: Object as PropType<any>,
default: null,
},
isNew: {
type: Boolean,
default: false,
},
value: {
type: Object as PropType<Field>,
required: true,
},
},
setup(props, { emit }) {
const _interface = useSync(props, 'interface', emit);
const _options = useSync(props, 'options', emit);
const items = computed<FancySelectItem[]>(() => {
return interfaces.map((inter) => ({
text: inter.name,
@@ -51,10 +49,17 @@ export default defineComponent({
});
const selectedInterface = computed(() => {
return interfaces.find((inter) => inter.id === _interface.value) || null;
return interfaces.find((inter) => inter.id === props.value.interface) || null;
});
return { _interface, _options, items, selectedInterface };
return { emitValue, items, selectedInterface };
function emitValue(key: string, value: any) {
emit('input', {
...props.value,
[key]: value,
});
}
},
});
</script>

View File

@@ -0,0 +1,96 @@
<template>
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('this_collection') }}</div>
<v-input disabled :value="collection" />
</div>
<div class="field">
<div class="type-label">{{ $t('junction_collection') }}</div>
<v-select :items="collectionItems" />
</div>
<div class="field">
<div class="type-label">{{ $t('related_collection') }}</div>
<v-select :items="collectionItems" />
</div>
<v-input disabled :value="field.field" />
<v-select :items="collectionItems" allow-other />
<div class="spacer" />
<div class="spacer" />
<v-select :items="collectionItems" allow-other />
<v-input disabled value="id" />
<v-icon name="arrow_forward" />
<v-icon name="arrow_backward" />
</div>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from '@vue/composition-api';
import useCollectionsStore from '@/stores/collections';
import orderBy from 'lodash/orderBy';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
field: {
type: Object as PropType<Field>,
required: true,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();
const availableCollections = computed(() => {
return orderBy(
collectionsStore.state.collections.filter((collection) => {
return (
collection.collection.startsWith('directus_') === false &&
collection.collection !== props.collection
);
}),
['collection'],
['asc']
);
});
const collectionItems = computed(() =>
availableCollections.value.map((collection) => ({
text: collection.name,
value: collection.collection,
}))
);
return { availableCollections, collectionItems };
},
});
</script>
<style lang="scss" scoped>
.grid {
position: relative;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 48px;
.v-icon {
--v-icon-color: var(--foreground-subdued);
position: absolute;
transform: translateX(-50%);
&:first-of-type {
bottom: 85px;
left: 32.8%;
}
&:last-of-type {
bottom: 14px;
left: 67%;
}
}
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div>
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('this_collection') }}</div>
<v-input disabled :value="collection" />
</div>
<div class="field">
<div class="type-label">{{ $t('related_collection') }}</div>
<v-select :items="items" />
</div>
<v-input disabled :value="field.field" />
<v-input disabled value="id" />
<v-icon name="arrow_back" />
</div>
<v-divider />
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('create_corresponding_field') }}</div>
<v-checkbox block :label="$t('add_field_related')" />
</div>
<div class="field">
<div class="type-label">{{ $t('corresponding_field_name') }}</div>
<v-input />
</div>
<v-icon name="arrow_forward" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from '@vue/composition-api';
import useCollectionsStore from '@/stores/collections';
import orderBy from 'lodash/orderBy';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
field: {
type: Object as PropType<Field>,
required: true,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();
const availableCollections = computed(() => {
return orderBy(
collectionsStore.state.collections.filter((collection) => {
return (
collection.collection.startsWith('directus_') === false &&
collection.collection !== props.collection
);
}),
['collection'],
['asc']
);
});
const items = computed(() =>
availableCollections.value.map((collection) => ({
text: collection.name,
value: collection.collection,
}))
);
return { availableCollections, items };
},
});
</script>
<style lang="scss" scoped>
.grid {
position: relative;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px 32px;
margin-top: 48px;
.v-icon {
--v-icon-color: var(--foreground-subdued);
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
}
}
.v-divider {
margin: 48px 0;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('this_collection') }}</div>
<v-input disabled :value="collection" />
</div>
<div class="field">
<div class="type-label">{{ $t('related_collection') }}</div>
<v-select :items="collectionItems" />
</div>
<v-input disabled :value="field.field" />
<v-select :items="collectionItems" allow-other />
<v-icon name="arrow_forward" />
</div>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from '@vue/composition-api';
import useCollectionsStore from '@/stores/collections';
import orderBy from 'lodash/orderBy';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
collection: {
type: String,
required: true,
},
field: {
type: Object as PropType<Field>,
required: true,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();
const availableCollections = computed(() => {
return orderBy(
collectionsStore.state.collections.filter((collection) => {
return (
collection.collection.startsWith('directus_') === false &&
collection.collection !== props.collection
);
}),
['collection'],
['asc']
);
});
const collectionItems = computed(() =>
availableCollections.value.map((collection) => ({
text: collection.name,
value: collection.collection,
}))
);
return { availableCollections, collectionItems };
},
});
</script>
<style lang="scss" scoped>
.grid {
position: relative;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px 32px;
margin-top: 48px;
.v-icon {
--v-icon-color: var(--foreground-subdued);
position: absolute;
bottom: 14px;
left: 50%;
transform: translateX(-50%);
}
}
</style>

View File

@@ -1,32 +1,83 @@
<template>
<v-tab-item value="relationship">
<h2 class="title" v-if="isNew">{{ $t('relationship_setup_title') }}</h2>
<!-- <v-fancy-select :items="items" v-model="_interface" /> -->
<div>
<h2 class="type-title" v-if="isNew">{{ $t('relationship_setup_title') }}</h2>
<v-fancy-select :items="items" :value="value.type" @input="emitValue('type', $event)" />
<transition-expand>
relationship
</transition-expand>
</v-tab-item>
<many-to-one
v-if="value.type === 'm2o'"
:collection="value.collection"
:field="value"
@update:field="emit('input', $event)"
/>
<one-to-many
v-else-if="value.type === 'o2m'"
:collection="value.collection"
:field="value"
@update:field="emit('input', $event)"
/>
<many-to-many
v-else-if="value.type === 'm2m'"
:collection="value.collection"
:field="value"
@update:field="emit('input', $event)"
/>
</div>
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { FancySelectItem } from '@/components/v-fancy-select/types';
import { Field } from '@/stores/fields/types';
import i18n from '@/lang';
import ManyToOne from './field-setup-relationship-m2o.vue';
import OneToMany from './field-setup-relationship-o2m.vue';
import ManyToMany from './field-setup-relationship-m2m.vue';
export default defineComponent({
components: {
ManyToOne,
OneToMany,
ManyToMany,
},
props: {
isNew: {
type: Boolean,
default: false,
},
value: {
type: Object as PropType<Field>,
required: true,
},
},
setup() {
return {};
setup(props, { emit }) {
const items = computed<FancySelectItem[]>(() => {
return [
{
text: i18n.t('many_to_one'),
value: 'm2o',
icon: 'call_merge',
},
{
text: i18n.t('one_to_many'),
value: 'o2m',
icon: 'call_split',
},
{
text: i18n.t('many_to_many'),
value: 'm2m',
icon: 'compare_arrows',
},
];
});
return { emitValue, items };
function emitValue(key: string, value: any) {
emit('input', {
...props.value,
[key]: value,
});
}
},
});
</script>
<style lang="scss" scoped>
.v-fancy-select {
margin-bottom: 48px;
}
</style>

View File

@@ -1,93 +1,77 @@
<template>
<v-modal :active="active" :title="modalTitle" persistent>
<v-dialog :active="saveError !== null" @toggle="saveError = null">
<v-card class="selectable">
<v-card-title>
{{ saveError && saveError.message }}
</v-card-title>
<v-card-text>
{{ saveError && saveError.response.data.error.message }}
</v-card-text>
<v-card-actions>
<v-button @click="saveError = null">{{ $t('dismiss') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-modal :active="active" title="Field" persistent>
<template #sidebar>
<v-tabs vertical v-model="currentTab">
<v-tab
v-for="tab in tabs"
:key="tab.value"
:disabled="tab.disabled"
:value="tab.value"
>
{{ tab.text }}
</v-tab>
</v-tabs>
<setup-tabs
:current-tab.sync="currentTab"
:tabs="tabs"
:field="field"
:local-type="localType"
:is-new="existingField === null"
/>
</template>
<v-tabs-items v-model="currentTab" class="content">
<field-setup-field :is-new="isNew" :field.sync="field" :type.sync="localType" />
<field-setup-relationship v-if="needsRelationalSetup" :is-new="isNew" />
<div class="content">
<field-setup-field
v-if="currentTab[0] === 'field'"
v-model="field"
:local-type.sync="localType"
:is-new="existingField === null"
/>
<field-setup-relationship
v-if="currentTab[0] === 'relationship'"
v-model="field"
:local-type.sync="localType"
:is-new="existingField === null"
/>
<field-setup-interface
:is-new="isNew"
:interface.sync="interfaceKey"
:options.sync="interfaceOptions"
v-if="currentTab[0] === 'interface'"
v-model="field"
:local-type.sync="localType"
:is-new="existingField === null"
/>
<field-setup-display
:is-new="isNew"
:display.sync="displayKey"
:options.sync="displayOptions"
v-if="currentTab[0] === 'display'"
v-model="field"
:local-type.sync="localType"
:is-new="existingField === null"
/>
<field-setup-advanced
:is-new="isNew"
:existing-field="existingField"
:edits.sync="edits"
v-if="currentTab[0] === 'advanced'"
v-model="field"
:local-type.sync="localType"
:is-new="existingField === null"
/>
</v-tabs-items>
</div>
<template #footer>
<v-button secondary outlined @click="$emit('toggle', false)">
{{ $t('cancel') }}
</v-button>
<div class="spacer" />
<v-button secondary @click="previous" :disabled="previousDisabled">
{{ $t('previous') }}
</v-button>
<v-button
@click="next"
:disabled="nextDisabled"
v-if="currentTab[0] !== tabs.length - 1"
>
{{ $t('next') }}
</v-button>
<v-button
@click="save"
:disabled="saveDisabled"
v-if="currentTab[0] === tabs.length - 1"
:loading="saving"
>
{{ $t('finish_setup') }}
</v-button>
<setup-actions
:current-tab.sync="currentTab"
:tabs="tabs"
:field="field"
:local-type="localType"
:is-new="existingField === null"
:saving="saving"
@cancel="$emit('toggle', false)"
@save="save"
/>
</template>
</v-modal>
</template>
<script lang="ts">
import { defineComponent, PropType, ref, computed, watch } from '@vue/composition-api';
import { defineComponent, PropType, watch, ref, computed } from '@vue/composition-api';
import { Field } from '@/stores/fields/types';
import i18n from '@/lang';
import FieldSetupField from './field-setup-field.vue';
import FieldSetupRelationship from './field-setup-relationship.vue';
import FieldSetupInterface from './field-setup-interface.vue';
import FieldSetupDisplay from './field-setup-display.vue';
import FieldSetupAdvanced from './field-setup-advanced.vue';
import { i18n } from '@/lang';
import { TranslateResult } from 'vue-i18n';
import { isEmpty } from '@/utils/is-empty';
import { useFieldsStore } from '@/stores/fields/';
import SetupTabs from './setup-tabs.vue';
import SetupActions from './setup-actions.vue';
import useFieldsStore from '@/stores/fields/';
import { LocalType } from './types';
export default defineComponent({
components: {
@@ -96,6 +80,8 @@ export default defineComponent({
FieldSetupInterface,
FieldSetupDisplay,
FieldSetupAdvanced,
SetupTabs,
SetupActions,
},
model: {
prop: 'active',
@@ -118,166 +104,51 @@ export default defineComponent({
setup(props, { emit }) {
const fieldsStore = useFieldsStore();
const isNew = computed(() => props.existingField === null);
const { field, localType } = usefield();
const { tabs, currentTab } = useTabs();
const { save, saving } = useSave();
const edits = ref<Partial<Field>>({});
return { field, tabs, currentTab, localType, save, saving };
watch(
() => props.active,
() => (edits.value = {})
);
function usefield() {
const defaults = {
id: null,
collection: props.collection,
field: null,
datatype: null,
unique: false,
primary_key: false,
auto_increment: false,
default_value: null,
note: null,
signed: false,
type: null,
sort: null,
interface: null,
options: null,
display: null,
display_options: null,
hidden_detail: false,
hidden_browse: false,
required: false,
locked: false,
translation: null,
readonly: false,
width: 'full',
validation: null,
group: null,
length: null,
};
const modalTitle = computed<TranslateResult>(() => {
if (isNew.value === true) {
return i18n.t('creating_field');
}
return i18n.t('editing_field', { field: props.existingField.name });
});
const { field, localType } = useFieldSetup();
const { needsRelationalSetup, relationships } = useRelationshipSetup();
const { interfaceKey, interfaceOptions } = useInterfaceSetup();
const { displayKey, displayOptions } = useDisplaySetup();
const { tabs, currentTab, previousDisabled, nextDisabled, previous, next } = useTabs();
const { save, saveDisabled, saveError, saving } = useSave();
return {
currentTab,
displayKey,
displayOptions,
edits,
field,
interfaceKey,
interfaceOptions,
isNew,
localType,
modalTitle,
needsRelationalSetup,
next,
nextDisabled,
previous,
previousDisabled,
relationships,
save,
saveDisabled,
saveError,
saving,
tabs,
};
function useTabs() {
const fieldIncomplete = computed<boolean>(() => {
return isEmpty(field.value) || isEmpty(localType.value);
});
const relationshipIncomplete = computed<boolean>(() => {
return false;
});
const interfaceIncomplete = computed<boolean>(() => {
/** @NOTE this is where we can check for required fields */
return isEmpty(interfaceKey.value);
});
const displayIncomplete = computed<boolean>(() => {
/** @NOTE this is where we can check for required fields */
return isEmpty(displayKey.value);
});
const tabs = computed(() => {
const tabs = [
{
text: i18n.t('field_setup'),
value: 'field',
},
{
text: i18n.t('interface_setup'),
disabled: isNew.value && fieldIncomplete.value,
value: 'interface',
},
{
text: i18n.t('display_setup'),
disabled: isNew.value && interfaceIncomplete.value,
value: 'display',
},
{
text: i18n.t('advanced_options'),
disabled: isNew.value && displayIncomplete.value,
value: 'advanced',
},
];
if (needsRelationalSetup.value === true) {
tabs.splice(1, 0, {
text: i18n.t('relationship_setup'),
disabled: isNew.value && fieldIncomplete.value,
value: 'relationship',
});
tabs[2].disabled = isNew.value && relationshipIncomplete.value;
tabs[3].disabled = isNew.value && interfaceIncomplete.value;
tabs[4].disabled = isNew.value && displayIncomplete.value;
}
return tabs;
});
const currentTab = ref(['field']);
const currentTabIndex = computed(() =>
tabs.value.findIndex((tab) => tab.value === currentTab.value[0])
);
watch(
() => props.active,
() => (currentTab.value = ['field'])
);
const previousDisabled = computed(() => {
return currentTabIndex.value === 0;
});
const nextDisabled = computed(() => {
const nextTabIndex = currentTabIndex.value + 1;
const nextTabIsDisabled = tabs.value[nextTabIndex]?.disabled === true;
return nextTabIsDisabled;
});
return { tabs, currentTab, previous, next, nextDisabled, previousDisabled };
function previous() {
const previousTabValue = tabs.value[currentTabIndex.value - 1].value;
currentTab.value = [previousTabValue];
}
function next() {
const nextTabValue = tabs.value[currentTabIndex.value + 1].value;
currentTab.value = [nextTabValue];
}
}
function useFieldSetup() {
const field = computed({
get() {
return edits.value.field || props.existingField?.field;
},
set(newField: string) {
edits.value = {
...edits.value,
field: newField,
};
},
});
const localType = ref<string>(null);
const field = ref<any>({ ...defaults });
const localType = ref<LocalType>(null);
watch(
() => props.existingField,
(existingField: Field) => {
if (existingField) {
field.value = existingField;
if (existingField.type === 'file') {
localType.value = 'file';
} else if (existingField.type === 'files') {
@@ -288,6 +159,7 @@ export default defineComponent({
localType.value = 'standard';
}
} else {
field.value = { ...defaults };
localType.value = null;
}
}
@@ -296,105 +168,66 @@ export default defineComponent({
return { field, localType };
}
function useRelationshipSetup() {
const needsRelationalSetup = computed(
() => localType.value && ['relational', 'file', 'files'].includes(localType.value)
);
function useTabs() {
const currentTab = ref(['field']);
const tabs = computed(() => {
const tabs = [
{
text: i18n.t('field_setup'),
value: 'field',
},
{
text: i18n.t('interface_setup'),
value: 'interface',
},
{
text: i18n.t('display_setup'),
value: 'display',
},
{
text: i18n.t('advanced_options'),
value: 'advanced',
},
];
const relationships: any[] = [];
if (localType.value === 'relational') {
tabs.splice(1, 0, {
text: i18n.t('relationship_setup'),
value: 'relationship',
});
}
return { needsRelationalSetup, relationships };
}
function useInterfaceSetup() {
const interfaceKey = computed({
get() {
return edits.value.interface || props.existingField?.interface;
},
set(newInterface: string | null) {
edits.value = {
...edits.value,
interface: newInterface,
};
},
return tabs;
});
const interfaceOptions = computed({
get() {
return edits.value.options || props.existingField?.options;
},
set(newOptions: { [key: string]: any } | null) {
edits.value = {
...edits.value,
options: newOptions,
};
},
});
return { interfaceKey, interfaceOptions };
}
function useDisplaySetup() {
const displayKey = computed({
get() {
return edits.value.display || props.existingField?.display;
},
set(newDisplay: string | null) {
edits.value = {
...edits.value,
display: newDisplay,
};
},
});
const displayOptions = computed({
get() {
return edits.value.display_options || props.existingField?.display_options;
},
set(newOptions: { [key: string]: any } | null) {
edits.value = {
...edits.value,
display_options: newOptions,
};
},
});
return { displayKey, displayOptions };
return { currentTab, tabs };
}
function useSave() {
const saveDisabled = computed(() => {
return edits.value === null || Object.keys(edits.value).length === 0;
});
const saving = ref(false);
const saveError = ref(null);
return { save, saving, saveDisabled, saveError };
return { save, saving, saveError };
async function save() {
saving.value = true;
try {
if (isNew.value === true) {
await fieldsStore.createField(props.collection, edits.value);
if (field.value.id === null) {
await fieldsStore.createField(props.collection, field.value);
} else {
await fieldsStore.updateField(
props.existingField.collection,
props.existingField.field,
edits.value
field.value
);
}
emit('toggle', false);
} catch (error) {
console.log(error);
saveError.value = error;
} finally {
saving.value = false;
}
saving.value = false;
}
}
},
@@ -408,22 +241,12 @@ export default defineComponent({
.content {
::v-deep {
.title {
.type-title {
margin-bottom: 48px;
font-weight: normal;
font-size: 24px;
font-style: normal;
line-height: 29px;
letter-spacing: -0.8px;
}
.label {
.type-label {
margin-bottom: 8px;
font-weight: 600;
font-size: 16px;
font-style: normal;
line-height: 19px;
letter-spacing: -0.32px;
}
}
}

View File

@@ -0,0 +1,123 @@
<template>
<div class="setup-actions">
<v-button secondary @click="$emit('cancel')">
{{ $t('cancel') }}
</v-button>
<div class="spacer" />
<v-button @click="previous" secondary :disabled="previousDisabled">
{{ $t('previous') }}
</v-button>
<v-button v-if="currentTabIndex < tabs.length - 1" @click="next" :disabled="nextDisabled">
{{ $t('next') }}
</v-button>
<v-button v-else :disabled="saveDisabled" @click="$emit('save')" :loading="saving">
{{ $t('save') }}
</v-button>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, toRefs } from '@vue/composition-api';
import { LocalType, Tab } from './types';
import useSync from '@/composables/use-sync';
import useValidation from './use-validation';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
tabs: {
type: Array as PropType<Tab[]>,
required: true,
},
currentTab: {
type: Array as PropType<string[]>,
required: true,
},
saving: {
type: Boolean,
default: false,
},
localType: {
type: String as PropType<LocalType>,
default: null,
},
field: {
type: Object as PropType<Field>,
required: true,
},
isNew: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const _currentTab = useSync(props, 'currentTab', emit);
const { field, localType } = toRefs(props);
const {
fieldComplete,
relationComplete,
interfaceComplete,
displayComplete,
advancedComplete,
} = useValidation(field, localType);
const currentTabIndex = computed(() =>
props.tabs.findIndex((tab) => tab.value === props.currentTab[0])
);
const previousDisabled = computed(() => {
return currentTabIndex.value === 0;
});
const nextDisabled = computed(() => {
if (props.isNew === false) return false;
switch (props.currentTab[0]) {
case 'field':
return fieldComplete.value === false;
case 'relationship':
return relationComplete.value === false;
case 'interface':
return interfaceComplete.value === false;
case 'display':
return displayComplete.value === false;
default:
return false;
}
});
const saveDisabled = computed(() => {
return advancedComplete.value === false;
});
return { previous, next, currentTabIndex, previousDisabled, nextDisabled, saveDisabled };
function previous() {
const previousTabValue = props.tabs[currentTabIndex.value - 1].value;
_currentTab.value = [previousTabValue];
}
function next() {
const nextTabValue = props.tabs[currentTabIndex.value + 1].value;
_currentTab.value = [nextTabValue];
}
},
});
</script>
<style lang="scss" scoped>
.setup-actions {
display: flex;
align-items: center;
width: 100%;
.v-button:not(:last-child) {
margin-right: 8px;
}
}
.spacer {
flex-grow: 1;
}
</style>

View File

@@ -0,0 +1,85 @@
<template>
<v-tabs vertical v-model="_currentTab">
<v-tab
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
:disabled="tabEnabled(tab) === false"
>
{{ tab.text }}
</v-tab>
</v-tabs>
</template>
<script lang="ts">
import { defineComponent, PropType, toRefs, computed } from '@vue/composition-api';
import useSync from '@/composables/use-sync';
import { LocalType, Tab } from './types';
import useValidation from './use-validation';
import { Field } from '@/stores/fields/types';
export default defineComponent({
props: {
tabs: {
type: Array as PropType<Tab[]>,
required: true,
},
currentTab: {
type: Array as PropType<string[]>,
default: null,
},
field: {
type: Object as PropType<Field>,
required: true,
},
localType: {
type: String as PropType<LocalType>,
default: null,
},
isNew: {
type: Boolean,
required: true,
},
},
setup(props, { emit }) {
const _currentTab = useSync(props, 'currentTab', emit);
const { field, localType } = toRefs(props);
const {
fieldComplete,
relationComplete,
interfaceComplete,
displayComplete,
} = useValidation(field, localType);
const hasRelationshipTab = computed(() => {
const relationshipTab = props.tabs.find((tab) => tab.value === 'relationship');
return relationshipTab !== undefined;
});
return { _currentTab, fieldComplete, tabEnabled };
function tabEnabled(tab: Tab) {
if (props.isNew === false) return true;
switch (tab.value) {
case 'field':
return true;
case 'relationship':
return fieldComplete.value === true;
case 'interface':
return hasRelationshipTab.value
? relationComplete.value === true
: fieldComplete.value === true;
case 'display':
return interfaceComplete.value === true;
case 'advanced':
return displayComplete.value === true;
default:
return true;
}
}
},
});
</script>

View File

@@ -1 +1,8 @@
export type FieldType = 'field' | 'relationship' | 'file' | 'files';
import { TranslateResult } from 'vue-i18n';
export type LocalType = 'standard' | 'relational' | 'file' | 'files';
export type Tab = {
text: string | TranslateResult;
value: string;
};

View File

@@ -0,0 +1,34 @@
import { computed, Ref } from '@vue/composition-api';
import { notEmpty } from '@/utils/is-empty';
import { LocalType } from './types';
import { Field } from '@/stores/fields/types';
export default function useValidation(field: Ref<Field>, localType: Ref<LocalType>) {
const fieldComplete = computed<boolean>(() => {
return notEmpty(field.value.field) && notEmpty(localType.value);
});
const relationComplete = computed<boolean>(() => {
return true;
});
const interfaceComplete = computed<boolean>(() => {
return notEmpty(field.value.interface);
});
const displayComplete = computed<boolean>(() => {
return notEmpty(field.value.display);
});
const advancedComplete = computed<boolean>(() => {
return true;
});
return {
fieldComplete,
relationComplete,
interfaceComplete,
displayComplete,
advancedComplete,
};
}