mirror of
https://github.com/directus/directus.git
synced 2026-01-28 10:08:03 -05:00
v-field-template (#680)
* Add use-field-tree composable * Add v-field-template component * Remove console.log
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
37
src/components/v-field-template/field-list-item.vue
Normal file
37
src/components/v-field-template/field-list-item.vue
Normal 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>
|
||||
4
src/components/v-field-template/index.ts
Normal file
4
src/components/v-field-template/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VFieldTemplate from './v-field-template.vue';
|
||||
|
||||
export default VFieldTemplate;
|
||||
export { VFieldTemplate };
|
||||
1
src/components/v-field-template/readme.md
Normal file
1
src/components/v-field-template/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
# Field Template
|
||||
7
src/components/v-field-template/types.ts
Normal file
7
src/components/v-field-template/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
export type FieldTree = {
|
||||
field: string;
|
||||
name: string | TranslateResult;
|
||||
children?: FieldTree[];
|
||||
};
|
||||
48
src/components/v-field-template/v-field-template.story.ts
Normal file
48
src/components/v-field-template/v-field-template.story.ts
Normal file
File diff suppressed because one or more lines are too long
197
src/components/v-field-template/v-field-template.vue
Normal file
197
src/components/v-field-template/v-field-template.vue
Normal 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>
|
||||
4
src/composables/use-field-tree/index.ts
Normal file
4
src/composables/use-field-tree/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import useFieldTree from './use-field-tree';
|
||||
|
||||
export default useFieldTree;
|
||||
export { useFieldTree };
|
||||
21
src/composables/use-field-tree/readme.md
Normal file
21
src/composables/use-field-tree/readme.md
Normal 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);
|
||||
```
|
||||
7
src/composables/use-field-tree/types.ts
Normal file
7
src/composables/use-field-tree/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
export type FieldTree = {
|
||||
field: string;
|
||||
name: string | TranslateResult;
|
||||
children?: FieldTree[];
|
||||
};
|
||||
57
src/composables/use-field-tree/use-field-tree.ts
Normal file
57
src/composables/use-field-tree/use-field-tree.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user