v-field-template (#680)

* Add use-field-tree composable

* Add v-field-template component

* Remove console.log
This commit is contained in:
Rijk van Zanten
2020-06-05 16:46:18 -04:00
committed by GitHub
parent d9316d1a88
commit f59c44b443
11 changed files with 404 additions and 19 deletions

View File

@@ -2,8 +2,8 @@ import Vue from 'vue';
import VAvatar from './v-avatar/';
import VBadge from './v-badge/';
import VButton from './v-button/';
import VBreadcrumb from './v-breadcrumb';
import VButton from './v-button/';
import VCard, { VCardActions, VCardTitle, VCardSubtitle, VCardText } from './v-card';
import VCheckbox from './v-checkbox/';
import VChip from './v-chip/';
@@ -11,28 +11,29 @@ import VDetail from './v-detail';
import VDialog from './v-dialog';
import VDivider from './v-divider';
import VFancySelect from './v-fancy-select';
import VFieldTemplate from './v-field-template';
import VForm from './v-form';
import VHover from './v-hover/';
import VModal from './v-modal/';
import VIcon from './v-icon/';
import VInfo from './v-info/';
import VInput from './v-input/';
import VItemGroup, { VItem } from './v-item-group';
import VList, {
VListGroup,
VListItem,
VListItemContent,
VListItemIcon,
VListItemHint,
VListItemIcon,
VListItemSubtitle,
VListItemTitle,
VListGroup,
} from './v-list/';
import VMenu from './v-menu/';
import VModal from './v-modal/';
import VNotice from './v-notice/';
import VOverlay from './v-overlay/';
import VPagination from './v-pagination/';
import VProgressLinear from './v-progress/linear/';
import VProgressCircular from './v-progress/circular/';
import VProgressLinear from './v-progress/linear/';
import VRadio from './v-radio/';
import VSelect from './v-select/';
import VSheet from './v-sheet/';
@@ -46,52 +47,53 @@ import VUpload from './v-upload';
Vue.component('v-avatar', VAvatar);
Vue.component('v-badge', VBadge);
Vue.component('v-button', VButton);
Vue.component('v-breadcrumb', VBreadcrumb);
Vue.component('v-card', VCard);
Vue.component('v-card-title', VCardTitle);
Vue.component('v-button', VButton);
Vue.component('v-card-actions', VCardActions);
Vue.component('v-card-subtitle', VCardSubtitle);
Vue.component('v-card-text', VCardText);
Vue.component('v-card-actions', VCardActions);
Vue.component('v-card-title', VCardTitle);
Vue.component('v-card', VCard);
Vue.component('v-checkbox', VCheckbox);
Vue.component('v-chip', VChip);
Vue.component('v-detail', VDetail);
Vue.component('v-dialog', VDialog);
Vue.component('v-divider', VDivider);
Vue.component('v-fancy-select', VFancySelect);
Vue.component('v-field-template', VFieldTemplate);
Vue.component('v-form', VForm);
Vue.component('v-hover', VHover);
Vue.component('v-modal', VModal);
Vue.component('v-icon', VIcon);
Vue.component('v-info', VInfo);
Vue.component('v-input', VInput);
Vue.component('v-item-group', VItemGroup);
Vue.component('v-item', VItem);
Vue.component('v-list', VList);
Vue.component('v-list-item', VListItem);
Vue.component('v-list-group', VListGroup);
Vue.component('v-list-item-content', VListItemContent);
Vue.component('v-list-item-icon', VListItemIcon);
Vue.component('v-list-item-hint', VListItemHint);
Vue.component('v-list-item-icon', VListItemIcon);
Vue.component('v-list-item-subtitle', VListItemSubtitle);
Vue.component('v-list-item-title', VListItemTitle);
Vue.component('v-list-group', VListGroup);
Vue.component('v-list-item', VListItem);
Vue.component('v-list', VList);
Vue.component('v-menu', VMenu);
Vue.component('v-modal', VModal);
Vue.component('v-notice', VNotice);
Vue.component('v-overlay', VOverlay);
Vue.component('v-pagination', VPagination);
Vue.component('v-progress-linear', VProgressLinear);
Vue.component('v-progress-circular', VProgressCircular);
Vue.component('v-progress-linear', VProgressLinear);
Vue.component('v-radio', VRadio);
Vue.component('v-select', VSelect);
Vue.component('v-sheet', VSheet);
Vue.component('v-skeleton-loader', VSkeletonLoader);
Vue.component('v-slider', VSlider);
Vue.component('v-switch', VSwitch);
Vue.component('v-table', VTable);
Vue.component('v-tabs', VTabs);
Vue.component('v-tab', VTab);
Vue.component('v-tabs-items', VTabsItems);
Vue.component('v-tab-item', VTabItem);
Vue.component('v-tab', VTab);
Vue.component('v-table', VTable);
Vue.component('v-tabs-items', VTabsItems);
Vue.component('v-tabs', VTabs);
Vue.component('v-textarea', VTextarea);
Vue.component('v-upload', VUpload);

View File

@@ -0,0 +1,37 @@
<template>
<v-list-item
v-if="field.children === undefined"
@click="$emit('add', `${parent ? parent + '.' : ''}${field.field}`)"
>
<v-list-item-content>{{ field.name }}</v-list-item-content>
</v-list-item>
<v-list-group v-else>
<template #activator>{{ field.name }}</template>
<field-list-item
v-for="childField in field.children"
:key="childField.field"
:parent="`${parent ? parent + '.' : ''}${field.field}`"
:field="childField"
@add="$emit('add', $event)"
/>
</v-list-group>
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { FieldTree } from './types';
export default defineComponent({
name: 'field-list-item',
props: {
field: {
type: Object as PropType<FieldTree>,
required: true,
},
parent: {
type: String,
default: null,
},
},
});
</script>

View File

@@ -0,0 +1,4 @@
import VFieldTemplate from './v-field-template.vue';
export default VFieldTemplate;
export { VFieldTemplate };

View File

@@ -0,0 +1 @@
# Field Template

View File

@@ -0,0 +1,7 @@
import { TranslateResult } from 'vue-i18n';
export type FieldTree = {
field: string;
name: string | TranslateResult;
children?: FieldTree[];
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,197 @@
<template>
<v-menu attached close-on-content-click v-model="menuActive">
<template #activator="{ toggle }">
<v-input>
<template #input>
<span
ref="contentEl"
class="content"
contenteditable
@keydown="onKeyDown"
@input="onInput"
@click="onClick"
/>
</template>
<template #append>
<v-icon name="add_box" @click="toggle" />
</template>
</v-input>
</template>
<v-list dense>
<field-list-item @add="addField" v-for="field in tree" :key="field.field" :field="field" />
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, toRefs, ref, watch, onMounted } from '@vue/composition-api';
import FieldListItem from './field-list-item.vue';
import useFieldsStore from '@/stores/fields';
import { Field } from '@/stores/fields/types';
import useFieldTree from '@/composables/use-field-tree';
export default defineComponent({
components: { FieldListItem },
props: {
disabled: {
type: Boolean,
default: false,
},
value: {
type: String,
default: null,
},
collection: {
type: String,
required: true,
},
},
setup(props, { emit }) {
const fieldsStore = useFieldsStore();
const contentEl = ref<HTMLElement>(null);
const menuActive = ref(false);
const { collection } = toRefs(props);
const { tree } = useFieldTree(collection);
watch(() => props.value, setContent);
onMounted(setContent);
return { tree, addField, onInput, contentEl, onClick, onKeyDown, menuActive };
function onInput() {
if (!contentEl.value) return;
const valueString = getInputValue();
emit('input', valueString);
}
function onClick(event: MouseEvent) {
const target = event.target as HTMLElement;
if (target.tagName.toLowerCase() !== 'button') return;
const field = target.dataset.field;
emit('input', props.value.replace(`{{${field}}}`, ''));
// A button is wrapped in two empty `<span></span>` elements
target.previousElementSibling?.remove();
target.nextElementSibling?.remove();
target.remove();
onInput();
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === '{' || event.key === '}') {
event.preventDefault();
menuActive.value = true;
}
}
function addField(fieldKey: string) {
if (!contentEl.value) return;
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
if (!field) return;
const button = document.createElement('button');
button.dataset.field = fieldKey;
button.setAttribute('contenteditable', 'false');
button.innerText = String(field.name);
const range = window.getSelection()?.getRangeAt(0);
range?.deleteContents();
range?.insertNode(button);
window.getSelection()?.removeAllRanges();
onInput();
}
function getInputValue() {
if (!contentEl.value) return null;
return Array.from(contentEl.value.childNodes).reduce((acc, node) => {
if (node.nodeType === Node.TEXT_NODE) return (acc += node.textContent);
const el = node as HTMLElement;
const tag = el.tagName;
if (tag.toLowerCase() === 'button') return (acc += `{{${el.dataset.field}}}`);
return (acc += '');
}, '');
}
function setContent() {
if (!contentEl.value) return;
if (props.value === null) {
contentEl.value.innerHTML = '';
return;
}
if (props.value !== getInputValue()) {
const regex = /({{.*?}})/g;
const newInnerHTML = props.value
.split(regex)
.map((part) => {
if (part.startsWith('{{') === false) return part;
const fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim();
const field: Field | null = fieldsStore.getField(props.collection, fieldKey);
// Instead of crashing when the field doesn't exist, we'll render a couple question
// marks to indicate it's absence
if (!field) return '???';
return `<button contenteditable="false" data-field="${field.field}">${field.name}</button>`;
})
.join('');
contentEl.value.innerHTML = newInnerHTML;
}
}
},
});
</script>
<style lang="scss" scoped>
.content {
display: block;
flex-grow: 1;
height: 100%;
overflow: hidden;
font-family: var(--family-monospace);
white-space: nowrap;
::v-deep {
> * {
display: inline;
white-space: nowrap;
}
br {
display: none;
}
button {
margin: 0;
padding: 0 4px;
color: var(--primary);
background-color: var(--primary-alt);
border-radius: var(--border-radius);
transition: var(--fast) var(--transition);
transition-property: background-color, color;
user-select: none;
&:hover {
color: var(--white);
background-color: var(--danger);
}
}
}
}
</style>

View File

@@ -0,0 +1,4 @@
import useFieldTree from './use-field-tree';
export default useFieldTree;
export { useFieldTree };

View File

@@ -0,0 +1,21 @@
# `useFieldTree`
Generate out a field tree based on the given collection.
```ts
type FieldTree = {
field: string;
name: string | TranslateResult;
children?: FieldTree[];
};
useFieldTree(collection: Ref<string>): { tree: FieldTree }
```
## Usage
```ts
const collection = ref('articles');
const { tree } = useFieldTree(collection);
```

View File

@@ -0,0 +1,7 @@
import { TranslateResult } from 'vue-i18n';
export type FieldTree = {
field: string;
name: string | TranslateResult;
children?: FieldTree[];
};

View File

@@ -0,0 +1,57 @@
import { Ref, computed } from '@vue/composition-api';
import { FieldTree } from './types';
import useFieldsStore from '@/stores/fields';
import useRelationsStore from '@/stores/relations';
import { Field } from '@/stores/fields/types';
import { Relation } from '@/stores/relations/types';
export default function useFieldTree(collection: Ref<string>) {
const fieldsStore = useFieldsStore();
const relationsStore = useRelationsStore();
const tree = computed<FieldTree[]>(() => {
return fieldsStore
.getFieldsForCollection(collection.value)
.filter((field: Field) => field.hidden_browse === false && field.type.toLowerCase() !== 'alias')
.map((field: Field) => parseField(field, []));
function parseField(field: Field, parents: Field[]) {
const fieldInfo: FieldTree = {
field: field.field,
name: field.name,
};
if (parents.length === 2) {
return fieldInfo;
}
const relations = relationsStore.getRelationsForField(field.collection, field.field);
if (relations.length > 0) {
const relatedFields = relations
.map((relation: Relation) => {
const relatedCollection =
relation.collection_many === field.collection
? relation.collection_one
: relation.collection_many;
if (relation.junction_field === field.field) return [];
return fieldsStore
.getFieldsForCollection(relatedCollection)
.filter(
(field: Field) => field.hidden_browse === false && field.type.toLowerCase() !== 'alias'
);
})
.flat()
.map((childField: Field) => parseField(childField, [...parents, field]));
fieldInfo.children = relatedFields;
}
return fieldInfo;
}
});
return { tree };
}