Merge pull request #3224 from directus/m2a-settings

Add field-detail settings pane for m2a relationship
This commit is contained in:
Rijk van Zanten
2020-11-27 17:35:23 -05:00
committed by GitHub
11 changed files with 593 additions and 33 deletions

View File

@@ -311,15 +311,13 @@ export class CollectionsService {
for (const relation of relations) {
const isM2O = relation.many_collection === collection;
/** @TODO M2A — Handle m2a case here */
if (isM2O) {
await this.knex('directus_relations')
.delete()
.where({ many_collection: collection, many_field: relation.many_field });
await fieldsService.deleteField(relation.one_collection!, relation.one_field!);
} else {
} else if (!!relation.one_collection) {
await this.knex('directus_relations')
.update({ one_field: null })
.where({ one_collection: collection, one_field: relation.one_field });

View File

@@ -187,6 +187,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
multiplePreviewThreshold: {
type: Number,
default: 3,
},
},
setup(props, { emit }) {
const { _items } = useItems();
@@ -238,7 +242,7 @@ export default defineComponent({
function useDisplayValue() {
const displayValue = computed(() => {
if (Array.isArray(props.value)) {
if (props.value.length < 3) {
if (props.value.length < props.multiplePreviewThreshold) {
return props.value
.map((value) => {
return getTextForValue(value) || value;

View File

@@ -294,7 +294,7 @@ export default defineComponent({
? val[anyRelation.value.many_field][primaryKeys.value[collection]]
: val[anyRelation.value.many_field];
const item = relatedItemValues.value[collection].find(
const item = relatedItemValues.value[collection]?.find(
(item) => item[primaryKeys.value[collection]] == key
);
@@ -317,6 +317,8 @@ export default defineComponent({
return { fetchValues, previewValues, loading, junctionRowMap, relatedItemValues };
async function fetchValues() {
if (props.value === null) return;
loading.value = true;
try {

View File

@@ -180,6 +180,7 @@
"field_m2o": "M2O Relationship",
"field_o2m": "O2M Relationship",
"field_m2m": "M2M Relationship",
"field_m2a": "M2A Relationship",
"field_translations": "Translations",
"languages": "Languages",
@@ -229,6 +230,7 @@
"configure_m2o": "Configure your Many-to-One Relationship...",
"configure_o2m": "Configure your One-to-Many Relationship...",
"configure_m2m": "Configure your Many-to-Many Relationship...",
"configure_m2a": "Configure your Many-to-Any Relationship...",
"configure_translations": "Configure your Translations...",
"configure_languages": "Configure your Languages...",
@@ -264,6 +266,7 @@
"m2o_relationship": "Many to One Relationship",
"o2m_relationship": "One to Many Relationship",
"m2m_relationship": "Many to Many Relationship",
"m2a_relationship": "Many to Any Relationship",
"next": "Next",
"previous": "Previous",
@@ -346,6 +349,7 @@
"item_in": "Item {primaryKey} in {collection} | Items {primaryKey} in {collection}",
"this_collection": "This Collection",
"related_collection": "Related Collection",
"related_collections": "Related Collections",
"translations_collection": "Translations Collection",
"languages_collection": "Languages Collection",
@@ -469,6 +473,8 @@
"replace_from_library": "Replace File from Library",
"replace_from_url": "Replace File from URL",
"collection_key": "Collection Key",
"name": "Name",
"primary_key_field": "Primary Key Field",
"type": "Type",

View File

@@ -0,0 +1,362 @@
<template>
<div>
<v-notice type="info">{{ $t('configure_m2a') }}</v-notice>
<div class="grid">
<div class="field">
<div class="type-label">{{ $t('this_collection') }}</div>
<v-input disabled :value="relations[0].one_collection" />
</div>
<div class="field">
<div class="type-label">{{ $t('junction_collection') }}</div>
<v-input
:class="{ matches: junctionCollectionExists }"
v-model="junctionCollection"
:placeholder="$t('collection') + '...'"
:disabled="autoFill || isExisting"
db-safe
>
<template #append>
<v-menu show-arrow placement="bottom-end">
<template #activator="{ toggle }">
<v-icon
name="list_alt"
@click="toggle"
v-tooltip="$t('select_existing')"
:disabled="autoFill || isExisting"
/>
</template>
<v-list class="monospace">
<v-list-item
v-for="collection in availableCollections"
:key="collection.collection"
:active="relations[0].many_collection === collection.collection"
@click="relations[0].many_collection = collection.collection"
>
<v-list-item-content>
{{ collection.collection }}
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-group>
<template #activator>{{ $t('system') }}</template>
<v-list-item
v-for="collection in systemCollections"
:key="collection.collection"
:active="relations[0].many_collection === collection.collection"
@click="relations[0].many_collection = collection.collection"
>
<v-list-item-content>
{{ collection.collection }}
</v-list-item-content>
</v-list-item>
</v-list-group>
</v-list>
</v-menu>
</template>
</v-input>
</div>
<div class="field">
<div class="type-label">{{ $t('related_collections') }}</div>
<v-select
:disabled="isExisting"
:placeholder="$t('collection') + '...'"
:items="availableCollections"
item-value="collection"
item-text="name"
multiple
v-model="relations[1].one_allowed_collections"
:multiple-preview-threshold="0"
/>
</div>
<v-input disabled :value="relations[0].one_primary" />
<v-input
:class="{ matches: junctionFieldExists(relations[0].many_field) }"
v-model="relations[0].many_field"
:placeholder="$t('foreign_key') + '...'"
:disabled="autoFill || isExisting"
db-safe
>
<template #append v-if="junctionCollectionExists">
<v-menu show-arrow placement="bottom-end">
<template #activator="{ toggle }">
<v-icon
name="list_alt"
@click="toggle"
v-tooltip="$t('select_existing')"
:disabled="autoFill || isExisting"
/>
</template>
<v-list class="monospace">
<v-list-item
v-for="item in junctionFields"
:key="item.value"
:active="relations[0].many_field === item.value"
:disabled="item.disabled"
@click="relations[0].many_field = item.value"
>
<v-list-item-content>
{{ item.text }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-input>
<div class="related-collections-preview">{{ (relations[1].one_allowed_collections || []).join(', ') }}</div>
<div class="spacer" />
<v-input
class="one-collection-field"
:class="{ matches: junctionFieldExists(relations[0].one_collection_field) }"
v-model="relations[1].one_collection_field"
:placeholder="$t('collection_key') + '...'"
:disabled="autoFill || isExisting"
db-safe
>
<template #append v-if="junctionCollectionExists">
<v-menu show-arrow placement="bottom-end">
<template #activator="{ toggle }">
<v-icon
name="list_alt"
@click="toggle"
v-tooltip="$t('select_existing')"
:disabled="autoFill || isExisting"
/>
</template>
<v-list class="monospace">
<v-list-item
v-for="item in junctionFields"
:key="item.value"
:active="relations[0].many_field === item.value"
:disabled="item.disabled"
@click="relations[0].many_field = item.value"
>
<v-list-item-content>
{{ item.text }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-input>
<div class="spacer" />
<v-input
:class="{ matches: junctionFieldExists(relations[1].many_field) }"
v-model="relations[1].many_field"
:placeholder="$t('foreign_key') + '...'"
:disabled="autoFill || isExisting"
db-safe
>
<template #append v-if="junctionCollectionExists">
<v-menu show-arrow placement="bottom-end">
<template #activator="{ toggle }">
<v-icon
name="list_alt"
@click="toggle"
v-tooltip="$t('select_existing')"
:disabled="autoFill || isExisting"
/>
</template>
<v-list class="monospace">
<v-list-item
v-for="item in junctionFields"
:key="item.value"
:active="relations[1].many_field === item.value"
:disabled="item.disabled"
@click="relations[1].many_field = item.value"
>
<v-list-item-content>
{{ item.text }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-input>
<v-input disabled :value="$t('primary_key')" />
<div class="spacer" />
<v-checkbox :disabled="isExisting" block v-model="autoFill" :label="$t('auto_fill')" />
<v-icon class="arrow" name="arrow_forward" />
<v-icon class="arrow" name="arrow_backward" />
<v-icon class="arrow" name="arrow_backward" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import { orderBy } from 'lodash';
import { useCollectionsStore, useFieldsStore } from '@/stores/';
import { Field } from '@/types';
import i18n from '@/lang';
import { state } from '../store';
export default defineComponent({
props: {
type: {
type: String,
required: true,
},
collection: {
type: String,
required: true,
},
isExisting: {
type: Boolean,
default: false,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();
const fieldsStore = useFieldsStore();
const autoFill = computed({
get() {
return state.autoFillJunctionRelation;
},
set(newAuto: boolean) {
state.autoFillJunctionRelation = newAuto;
},
});
const availableCollections = computed(() => {
return orderBy(
collectionsStore.state.collections.filter((collection) => {
return collection.collection.startsWith('directus_') === false;
}),
['collection'],
['asc']
);
});
const systemCollections = computed(() => {
return orderBy(
collectionsStore.state.collections.filter((collection) => {
return collection.collection.startsWith('directus_') === true;
}),
['collection'],
['asc']
);
});
const junctionCollection = computed({
get() {
return state.relations[0].many_collection;
},
set(collection: string) {
state.relations[0].many_collection = collection;
state.relations[1].many_collection = collection;
},
});
const junctionCollectionExists = computed(() => {
return collectionsStore.getCollection(junctionCollection.value) !== null;
});
const junctionFields = computed(() => {
if (!junctionCollection.value) return [];
return fieldsStore.getFieldsForCollection(junctionCollection.value).map((field: Field) => ({
text: field.field,
value: field.field,
disabled:
state.relations[0].many_field === field.field ||
field.schema?.is_primary_key ||
state.relations[1].many_field === field.field,
}));
});
return {
relations: state.relations,
autoFill,
availableCollections,
systemCollections,
junctionCollection,
junctionFields,
junctionCollectionExists,
junctionFieldExists,
};
function junctionFieldExists(fieldKey: string) {
if (!junctionCollection.value || !fieldKey) return false;
return !!fieldsStore.getField(junctionCollection.value, fieldKey);
}
},
});
</script>
<style lang="scss" scoped>
.grid {
--v-select-font-family: var(--family-monospace);
--v-input-font-family: var(--family-monospace);
position: relative;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px 28px;
margin-top: 48px;
.v-input.matches {
--v-input-color: var(--primary);
}
.v-icon.arrow {
--v-icon-color: var(--primary);
position: absolute;
transform: translateX(-50%);
pointer-events: none;
&:first-of-type {
top: 105px;
left: 32.5%;
}
&:nth-of-type(2) {
bottom: 143px;
left: 67.4%;
}
&:last-of-type {
bottom: 76px;
left: 67.4%;
}
}
}
.type-label {
margin-bottom: 8px;
}
.v-list {
--v-list-item-content-font-family: var(--family-monospace);
}
.v-notice {
margin-bottom: 36px;
}
.related-collections-preview {
grid-row: 2 / span 2;
grid-column: 3;
padding: var(--input-padding);
overflow: auto;
color: var(--foreground-subdued);
font-family: var(--family-monospace);
background-color: var(--background-subdued);
border: var(--border-width) solid var(--border-normal);
border-radius: var(--border-radius);
}
.one-collection-field {
align-self: flex-end;
}
</style>

View File

@@ -12,22 +12,24 @@
:type="type"
v-else-if="type === 'm2m' || type === 'files'"
/>
<relationship-m2a :collection="collection" :is-existing="isExisting" :type="type" v-else-if="type === 'm2a'" />
</template>
<script lang="ts">
import { defineComponent, PropType } from '@vue/composition-api';
import { Relation } from '@/types';
import { Field } from '@/types';
import { Relation, Field } from '../../../../../../types';
import RelationshipM2o from './relationship-m2o.vue';
import RelationshipO2m from './relationship-o2m.vue';
import RelationshipM2m from './relationship-m2m.vue';
import RelationshipM2a from './relationship-m2a.vue';
export default defineComponent({
components: {
RelationshipM2o,
RelationshipO2m,
RelationshipM2m,
RelationshipM2a,
},
props: {
type: {

View File

@@ -238,7 +238,7 @@ export default defineComponent({
});
const typeDisabled = computed(() => {
return ['file', 'files', 'o2m', 'm2m', 'm2o', 'translations'].includes(props.type);
return ['file', 'files', 'o2m', 'm2m', 'm2a', 'm2o', 'translations'].includes(props.type);
});
const typePlaceholder = computed(() => {

View File

@@ -150,7 +150,7 @@ export default defineComponent({
},
type: {
type: String as PropType<
'standard' | 'file' | 'files' | 'm2o' | 'o2m' | 'm2m' | 'presentation' | 'translations'
'standard' | 'file' | 'files' | 'm2o' | 'o2m' | 'm2m' | 'm2a' | 'presentation' | 'translations'
>,
default: null,
},
@@ -175,7 +175,7 @@ export default defineComponent({
const localType = computed(() => {
if (props.field === '+') return props.type;
let type: 'standard' | 'file' | 'files' | 'o2m' | 'm2m' | 'm2o' | 'presentation' | 'translations' =
let type: 'standard' | 'file' | 'files' | 'o2m' | 'm2m' | 'm2a' | 'm2o' | 'presentation' | 'translations' =
'standard';
type = getLocalTypeForField(props.collection, props.field);
@@ -232,7 +232,7 @@ export default defineComponent({
});
}
if (['o2m', 'm2o', 'm2m', 'files'].includes(localType.value)) {
if (['o2m', 'm2o', 'm2m', 'm2a', 'files'].includes(localType.value)) {
tabs.splice(1, 0, {
text: i18n.t('relationship'),
value: 'relationship',
@@ -298,6 +298,19 @@ export default defineComponent({
);
}
if (localType.value === 'm2a') {
return (
state.relations.length !== 2 ||
isEmpty(state.relations[0].many_collection) ||
isEmpty(state.relations[0].many_field) ||
isEmpty(state.relations[0].one_field) ||
isEmpty(state.relations[1].many_collection) ||
isEmpty(state.relations[1].many_field) ||
isEmpty(state.relations[1].one_collection_field) ||
isEmpty(state.relations[1].one_allowed_collections)
);
}
if (localType.value === 'presentation') {
return isEmpty(state.fieldData.field);
}

View File

@@ -67,22 +67,9 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
if (inter.system === true) return false;
const matchesType = inter.types.includes(state.fieldData?.type || 'alias');
const matchesLocalType = inter.localTypes?.includes(type);
let matchesRelation = false;
const matchesLocalType = inter.localTypes?.includes(type) || true;
if (type === 'standard' || type === 'presentation') {
matchesRelation = inter.relationship === null || inter.relationship === undefined;
} else if (type === 'file') {
matchesRelation = inter.relationship === 'm2o';
} else if (type === 'files') {
matchesRelation = inter.relationship === 'm2m';
} else if (type === 'translations') {
matchesRelation = inter.relationship === 'translations';
} else {
matchesRelation = inter.relationship === type;
}
return matchesType && matchesRelation && (matchesLocalType === undefined || matchesLocalType);
return matchesType && matchesLocalType;
})
.sort((a, b) => (a.name > b.name ? 1 : -1));
});
@@ -126,7 +113,15 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
);
}
if (type === 'file') {
if (type === 'file') useFile();
else if (type === 'm2o') useM2O();
else if (type === 'm2m' || type === 'files' || type === 'translations') useM2M();
else if (type === 'o2m') useO2M();
else if (type === 'presentation') usePresentation();
else if (type === 'm2a') useM2A();
else useStandard();
function useFile() {
if (!isExisting) {
state.fieldData.type = 'uuid';
@@ -149,7 +144,7 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
);
}
if (type === 'm2o') {
function useM2O() {
const syncNewCollectionsM2O = throttle(() => {
const collectionName = state.relations[0].one_collection;
@@ -224,7 +219,7 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
watch([() => state.relations[0].one_collection, () => state.relations[0].one_primary], syncNewCollectionsM2O);
}
if (type === 'o2m') {
function useO2M() {
delete state.fieldData.schema;
state.fieldData.type = null;
@@ -321,7 +316,7 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
watch([() => state.relations[0].many_collection, () => state.relations[0].many_field], syncNewCollectionsO2M);
}
if (type === 'm2m' || type === 'files' || type === 'translations') {
function useM2M() {
delete state.fieldData.schema;
state.fieldData.type = null;
@@ -660,14 +655,184 @@ function initLocalStore(collection: string, field: string, type: typeof localTyp
}
}
if (type === 'presentation') {
function useM2A() {
delete state.fieldData.schema;
state.fieldData.type = null;
const syncNewCollectionsM2A = throttle(([junctionCollection, manyCurrent, manyRelated, oneCollectionField]) => {
state.newCollections = state.newCollections.filter(
(col: any) => ['junction', 'related'].includes(col.$type) === false
);
state.newFields = state.newFields.filter(
(field: Partial<Field> & { $type: string }) =>
['manyCurrent', 'manyRelated', 'collectionField'].includes(field.$type) === false
);
if (collectionExists(junctionCollection) === false) {
state.newCollections.push({
$type: 'junction',
collection: junctionCollection,
meta: {
hidden: true,
icon: 'import_export',
},
fields: [
{
field: 'id',
type: 'integer',
schema: {
has_auto_increment: true,
},
meta: {
hidden: true,
},
},
],
});
state.relations[0].many_primary = 'id';
state.relations[1].many_primary = 'id';
}
if (fieldExists(junctionCollection, manyCurrent) === false) {
state.newFields.push({
$type: 'manyCurrent',
collection: junctionCollection,
field: manyCurrent,
type: fieldsStore.getPrimaryKeyFieldForCollection(collection)!.type,
schema: {},
meta: {
hidden: true,
},
});
}
if (fieldExists(junctionCollection, manyRelated) === false) {
state.newFields.push({
$type: 'manyRelated',
collection: junctionCollection,
field: manyRelated,
// We'll have to save the foreign key as a string, as that's the only way to safely
// be able to store the PK of multiple typed collections
type: 'string',
schema: {},
meta: {
hidden: true,
},
});
}
if (fieldExists(junctionCollection, oneCollectionField) === false) {
state.newFields.push({
$type: 'collectionField',
collection: junctionCollection,
field: oneCollectionField,
type: 'string', // directus_collections.collection is a string
schema: {},
meta: {
hidden: true,
},
});
}
}, 50);
if (!isExisting) {
state.fieldData.meta.special = [type];
state.relations = [
{
many_collection: '',
many_field: '',
many_primary: '',
one_collection: collection,
one_field: state.fieldData.field,
one_primary: fieldsStore.getPrimaryKeyFieldForCollection(collection)?.field,
},
{
many_collection: '',
many_field: '',
many_primary: '',
one_collection: null,
one_field: null,
one_primary: null,
one_allowed_collections: [],
one_collection_field: '',
},
];
}
watch(
() => state.relations[0].many_collection,
() => {
if (collectionExists(state.relations[0].many_collection)) {
const pkField = fieldsStore.getPrimaryKeyFieldForCollection(state.relations[0].many_collection)
?.field;
state.relations[0].many_primary = pkField;
state.relations[1].many_primary = pkField;
}
}
);
watch(
() => state.relations[0].many_field,
() => {
state.relations[1].junction_field = state.relations[0].many_field;
}
);
watch(
() => state.relations[1].many_field,
() => {
state.relations[0].junction_field = state.relations[1].many_field;
}
);
watch(
[
() => state.relations[0].many_collection,
() => state.relations[0].many_field,
() => state.relations[1].many_field,
() => state.relations[1].one_collection_field,
],
syncNewCollectionsM2A
);
watch(
() => state.fieldData.field,
() => {
state.relations[0].one_field = state.fieldData.field;
if (state.autoFillJunctionRelation) {
state.relations[0].many_collection = `${state.relations[0].one_collection}_${state.fieldData.field}`;
state.relations[1].many_collection = `${state.relations[0].one_collection}_${state.fieldData.field}`;
}
}
);
watch(
() => state.autoFillJunctionRelation,
() => {
if (state.autoFillJunctionRelation === true) {
state.relations[0].many_collection = `${state.relations[0].one_collection}_${state.fieldData.field}`;
state.relations[1].many_collection = `${state.relations[0].one_collection}_${state.fieldData.field}`;
state.relations[0].many_field = `${state.relations[0].one_collection}_${state.relations[0].one_primary}`;
state.relations[1].one_collection_field = 'collection';
state.relations[1].many_field = 'item';
}
},
{ immediate: true }
);
}
function usePresentation() {
delete state.fieldData.schema;
state.fieldData.type = null;
state.fieldData.meta.special = ['alias', 'no-data'];
}
if (type === 'standard') {
function useStandard() {
watch(
() => state.fieldData.type,
() => {

View File

@@ -145,6 +145,11 @@ export default defineComponent({
icon: 'import_export',
text: i18n.t('m2m_relationship'),
},
{
type: 'm2a',
icon: 'gesture',
text: i18n.t('m2a_relationship'),
},
{
divider: true,
},

View File

@@ -4,7 +4,7 @@ import { Relation } from '@/types';
export function getLocalTypeForField(
collection: string,
field: string
): 'standard' | 'file' | 'files' | 'o2m' | 'm2m' | 'm2o' | 'presentation' | 'translations' {
): 'standard' | 'file' | 'files' | 'o2m' | 'm2m' | 'm2a' | 'm2o' | 'presentation' | 'translations' {
const fieldsStore = useFieldsStore();
const relationsStore = useRelationsStore();
@@ -27,6 +27,9 @@ export function getLocalTypeForField(
if ((fieldInfo.meta?.special || []).includes('translations')) {
return 'translations';
}
if ((fieldInfo.meta?.special || []).includes('m2a')) {
return 'm2a';
}
const relationForCurrent = relations.find((relation: Relation) => {
return (