mirror of
https://github.com/directus/directus.git
synced 2026-01-29 07:27:57 -05:00
Merge pull request #282 from directus/v-field-select
Adds a v-field-select component
This commit is contained in:
@@ -13,6 +13,7 @@ import VDivider from './v-divider';
|
||||
import VError from './v-error';
|
||||
import VFancySelect from './v-fancy-select';
|
||||
import VFieldTemplate from './v-field-template';
|
||||
import VFieldSelect from './v-field-select';
|
||||
import VForm from './v-form';
|
||||
import VHover from './v-hover/';
|
||||
import VIcon from './v-icon/';
|
||||
@@ -64,6 +65,7 @@ Vue.component('v-divider', VDivider);
|
||||
Vue.component('v-error', VError);
|
||||
Vue.component('v-fancy-select', VFancySelect);
|
||||
Vue.component('v-field-template', VFieldTemplate);
|
||||
Vue.component('v-field-select', VFieldSelect);
|
||||
Vue.component('v-form', VForm);
|
||||
Vue.component('v-hover', VHover);
|
||||
Vue.component('v-icon', VIcon);
|
||||
|
||||
@@ -75,8 +75,8 @@ export default defineComponent({
|
||||
|
||||
<style>
|
||||
body {
|
||||
--v-chip-color: var(--white);
|
||||
--v-chip-background-color: var(--primary);
|
||||
--v-chip-color: var(--black);
|
||||
--v-chip-background-color: var(--background-normal-alt);
|
||||
--v-chip-color-hover: var(--white);
|
||||
--v-chip-background-color-hover: var(--primary-125);
|
||||
--v-chip-close-color: var(--danger);
|
||||
@@ -89,7 +89,7 @@ body {
|
||||
.v-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
color: var(--v-chip-color);
|
||||
font-weight: var(--weight-normal);
|
||||
|
||||
4
app/src/components/v-field-select/index.ts
Normal file
4
app/src/components/v-field-select/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import VFieldSelect from './v-field-select.vue';
|
||||
|
||||
export default VFieldSelect;
|
||||
export { VFieldSelect };
|
||||
1
app/src/components/v-field-select/readme.md
Normal file
1
app/src/components/v-field-select/readme.md
Normal file
@@ -0,0 +1 @@
|
||||
# Field Select
|
||||
167
app/src/components/v-field-select/v-field-select.vue
Normal file
167
app/src/components/v-field-select/v-field-select.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<v-notice v-if="!availableFields || availableFields.length === 0">
|
||||
{{ $t('no_fields_in_collection', { collection: (collectionInfo && collectionInfo.name) || collection }) }}
|
||||
</v-notice>
|
||||
<draggable v-else v-model="selectedFields" draggable=".draggable" :set-data="hideDragImage" class="v-field-select">
|
||||
<v-chip
|
||||
v-for="(field, index) in selectedFields"
|
||||
:key="index"
|
||||
class="field draggable"
|
||||
v-tooltip="field.field"
|
||||
@click="removeField(field.field)"
|
||||
>
|
||||
{{ field.name }}
|
||||
</v-chip>
|
||||
|
||||
<template #footer>
|
||||
<v-menu show-arrow v-model="menuActive" class="add" placement="bottom">
|
||||
<template #activator="{ toggle }">
|
||||
<v-button @click="toggle" small>
|
||||
{{ $t('add_field') }}
|
||||
<v-icon small name="add" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-list dense>
|
||||
<field-list-item
|
||||
v-for="field in availableFields"
|
||||
:key="field.field"
|
||||
:field="field"
|
||||
:depth="depth"
|
||||
@add="addField"
|
||||
/>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted, PropType, computed } from '@vue/composition-api';
|
||||
import FieldListItem from '../v-field-template/field-list-item.vue';
|
||||
import { useFieldsStore } from '@/stores';
|
||||
import { Field } from '@/types/';
|
||||
import Draggable from 'vuedraggable';
|
||||
import useFieldTree from '@/composables/use-field-tree';
|
||||
import useCollection from '@/composables/use-collection';
|
||||
import { FieldTree } from '../v-field-template/types';
|
||||
import hideDragImage from '@/utils/hide-drag-image';
|
||||
|
||||
export default defineComponent({
|
||||
components: { FieldListItem, Draggable },
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: null,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
|
||||
const menuActive = ref(false);
|
||||
const { collection } = toRefs(props);
|
||||
|
||||
const { info, primaryKeyField, fields: fieldsInCollection, sortField } = useCollection(collection);
|
||||
const { tree } = useFieldTree(collection, true);
|
||||
|
||||
const _value = computed({
|
||||
get() {
|
||||
return props.value || [];
|
||||
},
|
||||
set(newVal: string[]) {
|
||||
emit('input', newVal);
|
||||
},
|
||||
});
|
||||
|
||||
const selectedFields = computed({
|
||||
get() {
|
||||
return _value.value.map((field) => ({
|
||||
field,
|
||||
name: findTree(tree.value, field.split('.'))?.name as string,
|
||||
}));
|
||||
},
|
||||
set(newVal: { field: string; name: string }[]) {
|
||||
_value.value = newVal.map((field) => field.field);
|
||||
},
|
||||
});
|
||||
|
||||
const availableFields = computed(() => {
|
||||
return filterTree(tree.value);
|
||||
});
|
||||
|
||||
return {
|
||||
menuActive,
|
||||
addField,
|
||||
removeField,
|
||||
availableFields,
|
||||
selectedFields,
|
||||
hideDragImage,
|
||||
tree,
|
||||
collectionInfo: info,
|
||||
};
|
||||
|
||||
function findTree(tree: FieldTree[] | undefined, fieldSections: string[]): FieldTree | undefined {
|
||||
if (tree === undefined) return undefined;
|
||||
|
||||
const fieldObject = tree.find((f) => f.field === fieldSections[0]);
|
||||
|
||||
if (fieldObject === undefined) return undefined;
|
||||
if (fieldSections.length === 1) return fieldObject;
|
||||
return findTree(fieldObject.children, fieldSections.slice(1));
|
||||
}
|
||||
|
||||
function filterTree(tree: FieldTree[] | undefined, prefix = '') {
|
||||
if (tree === undefined) return undefined;
|
||||
|
||||
const newTree: FieldTree[] = tree.map((field) => {
|
||||
return {
|
||||
name: field.name,
|
||||
field: field.field,
|
||||
disabled: _value.value.includes(prefix + field.field),
|
||||
children: filterTree(field.children, prefix + field.field + '.'),
|
||||
};
|
||||
});
|
||||
|
||||
return newTree.length === 0 ? undefined : newTree;
|
||||
}
|
||||
|
||||
function removeField(field: string) {
|
||||
_value.value = _value.value.filter((f) => f !== field);
|
||||
}
|
||||
|
||||
function addField(field: string) {
|
||||
const newArray = _value.value;
|
||||
newArray.push(field);
|
||||
_value.value = [...new Set(newArray)];
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.v-field-select {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.v-chip.field {
|
||||
margin-right: 5px;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<v-list-item
|
||||
v-if="field.children === undefined"
|
||||
:disabled="field.disabled"
|
||||
@click="$emit('add', `${parent ? parent + '.' : ''}${field.field}`)"
|
||||
>
|
||||
<v-list-item-content>{{ field.name }}</v-list-item-content>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Field } from '@/types';
|
||||
import { TranslateResult } from 'vue-i18n';
|
||||
|
||||
export type FieldTree = {
|
||||
field: string;
|
||||
name: string | TranslateResult;
|
||||
disabled?: boolean;
|
||||
children?: FieldTree[];
|
||||
};
|
||||
|
||||
@@ -3,17 +3,22 @@ import { FieldTree } from './types';
|
||||
import { useFieldsStore, useRelationsStore } from '@/stores/';
|
||||
import { Field, Relation } from '@/types';
|
||||
|
||||
export default function useFieldTree(collection: Ref<string>) {
|
||||
export default function useFieldTree(collection: Ref<string>, showHidden = false) {
|
||||
const fieldsStore = useFieldsStore();
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const tree = computed<FieldTree[]>(() => {
|
||||
return fieldsStore
|
||||
.getFieldsForCollection(collection.value)
|
||||
.filter(
|
||||
(field: Field) =>
|
||||
field.meta?.hidden === false && (field.meta?.special || []).includes('alias') === false
|
||||
)
|
||||
.filter((field: Field) => {
|
||||
let shown = (field.meta?.special || []).includes('alias') === false;
|
||||
|
||||
if (showHidden === false && field.meta?.hidden === false) {
|
||||
shown = false;
|
||||
}
|
||||
|
||||
return shown;
|
||||
})
|
||||
.map((field: Field) => parseField(field, []));
|
||||
|
||||
function parseField(field: Field, parents: Field[]) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceManyToMany from './many-to-many.vue';
|
||||
import Options from './options.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'many-to-many',
|
||||
@@ -9,19 +10,6 @@ export default defineInterface(({ i18n }) => ({
|
||||
component: InterfaceManyToMany,
|
||||
relationship: 'm2m',
|
||||
types: ['alias'],
|
||||
options: [
|
||||
{
|
||||
field: 'fields',
|
||||
type: 'json',
|
||||
name: i18n.tc('field', 0),
|
||||
meta: {
|
||||
interface: 'tags',
|
||||
width: 'full',
|
||||
options: {
|
||||
placeholder: i18n.t('readable_fields_copy'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
options: Options,
|
||||
recommendedDisplays: ['related-values'],
|
||||
}));
|
||||
|
||||
73
app/src/interfaces/many-to-many/options.vue
Normal file
73
app/src/interfaces/many-to-many/options.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="junctionCollection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select :collection="junctionCollection" v-model="fields" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Field } from '@/types';
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
import { Relation } from '@/types';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field>,
|
||||
default: null,
|
||||
},
|
||||
relations: {
|
||||
type: Array as PropType<Relation[]>,
|
||||
default: () => [],
|
||||
},
|
||||
value: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const fields = computed({
|
||||
get() {
|
||||
return props.value?.fields;
|
||||
},
|
||||
set(newFields: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
fields: newFields,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const junctionCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
const { field } = props.fieldData;
|
||||
const junctionRelation = props.relations.find(
|
||||
(relation) => relation.one_collection === props.collection && relation.one_field === field
|
||||
);
|
||||
return junctionRelation?.many_collection || null;
|
||||
});
|
||||
|
||||
return { fields, junctionCollection };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid';
|
||||
|
||||
.form-grid {
|
||||
@include form-grid;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineInterface } from '../define';
|
||||
import InterfaceOneToMany from './one-to-many.vue';
|
||||
import Options from './options.vue';
|
||||
|
||||
export default defineInterface(({ i18n }) => ({
|
||||
id: 'one-to-many',
|
||||
@@ -9,19 +10,6 @@ export default defineInterface(({ i18n }) => ({
|
||||
component: InterfaceOneToMany,
|
||||
types: ['alias'],
|
||||
relationship: 'o2m',
|
||||
options: [
|
||||
{
|
||||
field: 'fields',
|
||||
type: 'json',
|
||||
name: i18n.tc('field', 0),
|
||||
meta: {
|
||||
interface: 'tags',
|
||||
width: 'full',
|
||||
options: {
|
||||
placeholder: i18n.t('interfaces.one-to-many.readable_fields_copy'),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
options: Options,
|
||||
recommendedDisplays: ['related-values'],
|
||||
}));
|
||||
|
||||
72
app/src/interfaces/one-to-many/options.vue
Normal file
72
app/src/interfaces/one-to-many/options.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<v-notice type="warning" v-if="relatedCollection === null">
|
||||
{{ $t('interfaces.one-to-many.no_collection') }}
|
||||
</v-notice>
|
||||
<div v-else class="form-grid">
|
||||
<div class="field full">
|
||||
<p class="type-label">{{ $t('select_fields') }}</p>
|
||||
<v-field-select :collection="relatedCollection" v-model="fields" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Field, Relation } from '@/types';
|
||||
import { defineComponent, PropType, computed } from '@vue/composition-api';
|
||||
import { useRelationsStore } from '@/stores/';
|
||||
|
||||
export default defineComponent({
|
||||
props: {
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fieldData: {
|
||||
type: Object as PropType<Field>,
|
||||
default: null,
|
||||
},
|
||||
relations: {
|
||||
type: Array as PropType<Relation[]>,
|
||||
default: () => [],
|
||||
},
|
||||
value: {
|
||||
type: Object as PropType<any>,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const relationsStore = useRelationsStore();
|
||||
|
||||
const fields = computed({
|
||||
get() {
|
||||
return props.value?.fields;
|
||||
},
|
||||
set(newFields: string) {
|
||||
emit('input', {
|
||||
...(props.value || {}),
|
||||
fields: newFields,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const relatedCollection = computed(() => {
|
||||
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
|
||||
const { field } = props.fieldData;
|
||||
const relatedRelation = props.relations.find(
|
||||
(relation) => relation.one_collection === props.collection && relation.one_field === field
|
||||
);
|
||||
return relatedRelation?.many_collection || null;
|
||||
});
|
||||
|
||||
return { fields, relatedCollection };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins/form-grid.scss';
|
||||
|
||||
.form-grid {
|
||||
@include form-grid;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,8 @@
|
||||
"only_show_the_file_extension": "Only show the file extension",
|
||||
"textarea": "Textarea",
|
||||
|
||||
"add_field": "Add Field",
|
||||
|
||||
"role_name": "Role Name",
|
||||
|
||||
"db_only_click_to_configure": "Database Only: Click to Configure ",
|
||||
@@ -1036,6 +1038,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"no_fields_in_collection": "There are no fields in \"{collection}\" yet",
|
||||
|
||||
"do_nothing": "Do Nothing",
|
||||
"generate_and_save_uuid": "Generate and Save UUID",
|
||||
"save_current_user_id": "Save Current User ID",
|
||||
|
||||
@@ -131,7 +131,8 @@
|
||||
"one-to-many": {
|
||||
"one-to-many": "One to Many",
|
||||
"description": "Select multiple related items",
|
||||
"readable_fields_copy": "Select the fields that the user can view"
|
||||
"readable_fields_copy": "Select the fields that the user can view",
|
||||
"no_collection": "The collection could not be found"
|
||||
},
|
||||
"radio-buttons": {
|
||||
"radio-buttons": "Radio Buttons",
|
||||
|
||||
@@ -24,7 +24,9 @@
|
||||
|
||||
<component
|
||||
v-model="fieldData.meta.options"
|
||||
:collection="collection"
|
||||
:field-data="fieldData"
|
||||
:relations="relations"
|
||||
:is="`interface-options-${selectedInterface.id}`"
|
||||
v-else
|
||||
/>
|
||||
@@ -33,7 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, computed, watch } from '@vue/composition-api';
|
||||
import { defineComponent, computed, watch, toRefs } from '@vue/composition-api';
|
||||
import { getInterfaces } from '@/interfaces';
|
||||
import { FancySelectItem } from '@/components/v-fancy-select/types';
|
||||
|
||||
@@ -45,6 +47,10 @@ export default defineComponent({
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
collection: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const interfaces = getInterfaces();
|
||||
@@ -109,7 +115,9 @@ export default defineComponent({
|
||||
return interfaces.value.find((inter) => inter.id === state.fieldData.meta.interface);
|
||||
});
|
||||
|
||||
return { fieldData: state.fieldData, selectItems, selectedInterface };
|
||||
const { fieldData, relations } = toRefs(state);
|
||||
|
||||
return { fieldData, relations, selectItems, selectedInterface };
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user