Tweak relational interfaces

Squashed commit of the following:

commit ade7ce72e7dac9908504eacf420875baaae1cc47
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 13:16:03 2021 -0400

    Add no-items notice

commit e47dd5ac1f28300a33478a2be3c50496859b09fc
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 13:13:58 2021 -0400

    Remove files interface

commit 2925fb9c86719c48006f7b2619df7fd26bf7b523
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 13:10:44 2021 -0400

    Fix sort field in m2m

commit 009e2b1fd99f7a31f20fba04cd9980eaa3566ac8
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 13:06:45 2021 -0400

    Add dense at item count

commit 83b088f4da3ea4a1d7e030f34a07aa1cb2235b43
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 12:05:40 2021 -0400

    Tweak rendering of thumbnails inside relational interfaces

commit 06770a0f16e344ab62c0228b87824a6c00ad39bc
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 11:36:07 2021 -0400

    Rename $file->$thumbnail, render properly in render-template

commit 954fd725629ce055459a7925be4aaddf3fb723c2
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 11:35:53 2021 -0400

    Fix injection on v-field-select

commit 83073dea2fc26af61a5155adddd5d4e3afa5cb14
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 11:35:39 2021 -0400

    Adjust for virtual $thumbnail field on files

commit ee57b8316479204c0a5c931c86807afde55423a1
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 10:49:35 2021 -0400

    Don't hardcode file/user relations

commit 31ed92c5a785f20b7dc58bb62f35f6e31c95cfc6
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 10:49:22 2021 -0400

    Allow injecting temporary fields in field template

commit 9d98d4fe4def7bdba12d1613bd08bdb9bd9e1431
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 10:36:53 2021 -0400

    Render collection level template in placeholder

commit 0e0dda1e9f5a930ce3c73c2f8003d98853d58bc0
Merge: 65fa8084f 1e3b64bf9
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 10:35:21 2021 -0400

    Merge branch 'main' into relational-tweaks

commit 65fa8084f84aa1a90686fe6407a6d54ca47d1371
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 10:29:03 2021 -0400

    Make input container relative

commit 0674a0a00faa5df2208b466114721ba5d5116bf7
Author: rijkvanzanten <rijkvanzanten@me.com>
Date:   Thu Apr 22 10:28:44 2021 -0400

    Add placeholder option to v-field-template
This commit is contained in:
rijkvanzanten
2021-04-22 13:16:20 -04:00
parent 1e3b64bf99
commit cb4bf88e66
31 changed files with 239 additions and 430 deletions

View File

@@ -110,14 +110,15 @@ router.post(
}
try {
if (Array.isArray(keys)) {
if (Array.isArray(keys) && keys.length > 1) {
const records = await service.readMany(keys, req.sanitizedQuery);
res.locals.payload = {
data: records,
};
} else {
const record = await service.readOne(keys, req.sanitizedQuery);
const key = Array.isArray(keys) ? keys[0] : keys;
const record = await service.readOne(key, req.sanitizedQuery);
res.locals.payload = {
data: record,

View File

@@ -0,0 +1,7 @@
import { Knex } from 'knex';
export async function up(knex: Knex) {
await knex('directus_fields').update({ interface: 'many-to-many' }).where({ interface: 'files' });
}
export async function down(knex: Knex) {}

View File

@@ -81,13 +81,10 @@ export default defineComponent({
},
setup(props, { emit }) {
const menuActive = ref(false);
const { collection } = toRefs(props);
const { collection, inject } = toRefs(props);
const { info } = useCollection(collection);
const { tree } = useFieldTree(collection, {
fields: props.inject?.fields.filter((field) => field.collection === props.collection) || [],
relations: props.inject?.relations || [],
});
const { tree } = useFieldTree(collection, false, inject);
const _value = computed({
get() {

View File

@@ -6,6 +6,7 @@
<span ref="contentEl" class="content" contenteditable @keydown="onKeyDown" @input="onInput" @click="onClick">
<span class="text" />
</span>
<span class="placeholder" v-if="placeholder && !value">{{ placeholder }}</span>
</template>
<template #append>
@@ -21,12 +22,11 @@
</template>
<script lang="ts">
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted } from '@vue/composition-api';
import { defineComponent, toRefs, ref, watch, onMounted, onUnmounted, PropType } from '@vue/composition-api';
import FieldListItem from './field-list-item.vue';
import { useFieldsStore } from '@/stores';
import { Field } from '@/types/';
import useFieldTree from '@/composables/use-field-tree';
import { FieldTree } from './types';
import { Field, Relation } from '@/types';
export default defineComponent({
components: { FieldListItem },
@@ -51,15 +51,22 @@ export default defineComponent({
type: Number,
default: 2,
},
placeholder: {
type: String,
default: null,
},
inject: {
type: Object as PropType<{ fields: Field[]; relations: Relation[] }>,
default: null,
},
},
setup(props, { emit }) {
const fieldsStore = useFieldsStore();
const contentEl = ref<HTMLElement | null>(null);
const menuActive = ref(false);
const { collection } = toRefs(props);
const { tree } = useFieldTree(collection);
const { collection, inject } = toRefs(props);
const { tree } = useFieldTree(collection, true, inject);
watch(() => props.value, setContent, { immediate: true });
@@ -323,5 +330,15 @@ export default defineComponent({
}
}
}
.placeholder {
position: absolute;
top: 50%;
left: 14px;
color: var(--foreground-subdued);
transform: translateY(-50%);
user-select: none;
pointer-events: none;
}
}
</style>

View File

@@ -283,6 +283,7 @@ body {
}
.input {
position: relative;
display: flex;
flex-grow: 1;
align-items: center;

View File

@@ -190,9 +190,10 @@ body {
}
&.block {
--v-list-item-min-height: 44px;
position: relative;
display: flex;
height: var(--input-height);
padding: 8px;
background-color: var(--background-subdued);
border: 2px solid var(--border-subdued);
border-radius: var(--border-radius);
@@ -228,7 +229,8 @@ body {
}
&.dense {
--v-list-item-min-height: 34px;
height: 34px;
padding: 4px 8px;
& + & {
margin-top: 4px;

View File

@@ -91,7 +91,6 @@ import uploadFiles from '@/utils/upload-files';
import uploadFile from '@/utils/upload-file';
import DrawerCollection from '@/views/private/components/drawer-collection';
import api from '@/api';
import useItem from '@/composables/use-item';
import { unexpectedError } from '@/utils/unexpected-error';
export default defineComponent({

View File

@@ -4,9 +4,13 @@ import { Field, Relation } from '@/types';
import { cloneDeep } from 'lodash';
import { getRelationType } from '@/utils/get-relation-type';
type FieldOption = { name: string; field: string; key: string; children?: FieldOption[] };
export default function useFieldTree(
collection: Ref<string>,
inject?: { fields: Field[]; relations: Relation[] },
/** Only allow m2o relations to be nested */
strict: boolean = false,
inject?: Ref<{ fields: Field[]; relations: Relation[] } | null>,
filter: (field: Field) => boolean = () => true
) {
const fieldsStore = useFieldsStore();
@@ -17,7 +21,10 @@ export default function useFieldTree(
return { tree };
function parseLevel(collection: string, parentPath: string | null, level = 0) {
const fieldsInLevel = cloneDeep(fieldsStore.getFieldsForCollectionAlphabetical(collection))
const fieldsInLevel = [
...cloneDeep(fieldsStore.getFieldsForCollectionAlphabetical(collection)),
...(inject?.value?.fields.filter((field) => field.collection === collection) || []),
]
.filter((field: Field) => {
const shown =
field.meta?.special?.includes('alias') !== true && field.meta?.special?.includes('no-data') !== true;
@@ -28,18 +35,29 @@ export default function useFieldTree(
name: field.name,
field: field.field,
key: parentPath ? `${parentPath}.${field.field}` : field.field,
}));
})) as FieldOption[];
if (level >= 3) return fieldsInLevel;
for (const field of fieldsInLevel) {
const relations = relationsStore.getRelationsForField(collection, field.field);
const relations = [
...relationsStore.getRelationsForField(collection, field.field),
...(inject?.value?.relations.filter((relation: Relation) => {
return (
(relation.many_collection === collection && relation.many_field === field.field) ||
(relation.one_collection === collection && relation.one_field === field.field)
);
}) || []),
];
const relation = relations.find(
(relation: Relation) =>
(relation.many_collection === collection && relation.many_field === field.field) ||
(relation.one_collection === collection && relation.one_field === field.field)
);
if (!relation) continue;
const relationType = getRelationType({ relation, collection, field: field.field });
if (relationType === 'm2o') {
@@ -48,7 +66,7 @@ export default function useFieldTree(
parentPath ? `${parentPath}.${field.field}` : field.field,
level + 1
);
} else {
} else if (strict === false) {
field.children = parseLevel(
relation.many_collection,
parentPath ? `${parentPath}.${field.field}` : field.field,

View File

@@ -159,7 +159,7 @@ export function useItems(collection: Ref<string>, query: Query) {
}
}
// Filter out fake internal columns. This is (among other things) for a fake $file m2o field
// Filter out fake internal columns. This is (among other things) for a fake $thumbnail m2o field
// on directus_files
fieldsToFetch = fieldsToFetch.filter((field) => field.startsWith('$') === false);
@@ -184,13 +184,13 @@ export function useItems(collection: Ref<string>, query: Query) {
* able to render out the directus_files collection (file library) using regular layouts
*
* Layouts expect the file to be a m2o of a `file` type, however, directus_files is the
* only collection that doesn't have this (obviously). This fake $file field is used to
* only collection that doesn't have this (obviously). This fake $thumbnail field is used to
* pretend there is a file m2o, so we can use the regular layout logic for files as well
*/
if (collection.value === 'directus_files') {
fetchedItems = fetchedItems.map((file: any) => ({
...file,
$file: file,
$thumbnail: file,
}));
}

View File

@@ -9,5 +9,5 @@ export default defineDisplay({
handler: DisplayFile,
types: ['uuid'],
options: [],
fields: ['data', 'type', 'title'],
fields: ['id', 'type', 'title'],
});

View File

@@ -1,7 +1,7 @@
<template>
<value-null v-if="!relatedCollection" />
<v-menu
v-else-if="['o2m', 'm2m', 'm2a', 'translations'].includes(type.toLowerCase())"
v-else-if="['o2m', 'm2m', 'm2a', 'translations', 'files'].includes(type.toLowerCase())"
show-arrow
:disabled="value.length === 0"
>
@@ -11,7 +11,7 @@
</span>
</template>
<v-list>
<v-list class="links">
<v-list-item v-for="item in value" :key="item[primaryKeyField]" :to="getLinkForItem(item)">
<v-list-item-content>
<render-template :template="_template" :item="item" :collection="relatedCollection" />
@@ -117,4 +117,10 @@ export default defineComponent({
.subdued {
color: var(--foreground-subdued);
}
.links {
.v-list-item-content {
height: var(--v-list-item-min-height);
}
}
</style>

View File

@@ -1,270 +0,0 @@
<template>
<v-notice type="warning" v-if="!junction || !relation">
{{ $t('relationship_not_setup') }}
</v-notice>
<div v-else class="files">
<v-table
inline
:items="sortedItems || items"
:loading="loading"
:headers.sync="tableHeaders"
:item-key="relationInfo.junctionPkField"
:disabled="disabled"
@update:items="sortItems($event)"
@click:row="editItem"
:show-manual-sort="relationInfo.sortField !== null"
:manual-sort-key="relationInfo.sortField"
>
<template #item.$thumbnail="{ item }">
<render-display
:value="get(item, relationInfo.junctionField)"
display="file"
:collection="relationInfo.junctionCollection"
:field="relationInfo.relationPkField"
type="file"
/>
</template>
<template #item-append="{ item }">
<v-icon
name="save_alt"
v-show="!disabled"
v-tooltip="$t('download')"
class="download"
@click.stop="downloadItem(item)"
/>
<v-icon
name="close"
v-show="!disabled"
v-tooltip="$t('deselect')"
class="deselect"
@click.stop="deleteItem(item)"
/>
</template>
</v-table>
<div class="actions" v-if="!disabled">
<v-button class="new" @click="showUpload = true">{{ $t('upload_file') }}</v-button>
<v-button class="existing" @click="selectModalActive = true">
{{ $t('add_existing') }}
</v-button>
</div>
<drawer-item
v-if="!disabled"
:active="editModalActive"
:collection="relationInfo.junctionCollection"
:primary-key="currentlyEditing || '+'"
:edits="editsAtStart"
:related-primary-key="relatedPrimaryKey || '+'"
:junction-field="relationInfo.junctionField"
:circular-field="junction.many_field"
@input="stageEdits"
@update:active="cancelEdit"
/>
<drawer-collection
v-if="!disabled"
:active.sync="selectModalActive"
:collection="relationInfo.relationCollection"
:selection="[]"
:filters="selectionFilters"
@input="stageSelection"
multiple
/>
<v-dialog v-model="showUpload">
<v-card>
<v-card-title>{{ $t('upload_file') }}</v-card-title>
<v-card-text><v-upload @input="onUpload" multiple from-url /></v-card-text>
<v-card-actions>
<v-button @click="showUpload = false">{{ $t('done') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, toRefs, PropType } from '@vue/composition-api';
import { Header as TableHeader } from '@/components/v-table/types';
import DrawerCollection from '@/views/private/components/drawer-collection';
import DrawerItem from '@/views/private/components/drawer-item';
import { get } from 'lodash';
import i18n from '@/lang';
import { getRootPath } from '@/utils/get-root-path';
import { addTokenToURL } from '@/api';
import useActions from '@/interfaces/many-to-many/use-actions';
import useRelation from '@/interfaces/many-to-many/use-relation';
import useSelection from '@/interfaces/many-to-many/use-selection';
import usePreview from '@/interfaces/many-to-many/use-preview';
import useEdit from '@/interfaces/many-to-many/use-edit';
import useSort from '@/interfaces/many-to-many/use-sort';
export default defineComponent({
components: { DrawerCollection, DrawerItem },
props: {
primaryKey: {
type: [Number, String],
required: true,
},
collection: {
type: String,
required: true,
},
field: {
type: String,
required: true,
},
value: {
type: Array as PropType<(string | number | Record<string, any>)[] | null>,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { emit }) {
const { collection, field, value } = toRefs(props);
const { junction, junctionCollection, relation, relationInfo } = useRelation(collection, field);
function emitter(newVal: any[] | null) {
emit('input', newVal);
}
const { deleteItem, getUpdatedItems, getNewItems, getPrimaryKeys, getNewSelectedItems } = useActions(
value,
relationInfo,
emitter
);
const fields = computed(() => {
const { junctionField } = relationInfo.value;
return ['id', 'type', 'title'].map((key) => `${junctionField}.${key}`);
});
const tableHeaders = ref<TableHeader[]>([
{
text: '',
value: '$thumbnail',
align: 'left',
sortable: false,
width: 50,
},
{
text: i18n.t('title'),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
value: relationInfo.value.junctionField + '.' + 'title',
align: 'left',
sortable: true,
width: 250,
},
]);
const { loading, error, items } = usePreview(
value,
fields,
relationInfo,
getNewSelectedItems,
getUpdatedItems,
getNewItems,
getPrimaryKeys
);
const {
cancelEdit,
stageEdits,
editsAtStart,
editItem,
currentlyEditing,
editModalActive,
relatedPrimaryKey,
} = useEdit(value, relationInfo, emitter);
const { stageSelection, selectModalActive, selectionFilters } = useSelection(value, items, relationInfo, emitter);
const { showUpload, onUpload } = useUpload();
const { sort, sortItems, sortedItems } = useSort(relationInfo, fields, items, emitter);
return {
junction,
relation,
tableHeaders,
junctionCollection,
loading,
error,
currentlyEditing,
cancelEdit,
showUpload,
stageEdits,
editsAtStart,
selectModalActive,
stageSelection,
selectionFilters,
deleteItem,
items,
get,
onUpload,
relationInfo,
editItem,
editModalActive,
relatedPrimaryKey,
sort,
sortItems,
sortedItems,
downloadItem,
};
function downloadItem(item: any) {
const filePath = addTokenToURL(getRootPath() + `assets/${item.directus_files_id.id}?download`);
window.open(filePath, '_blank');
}
function useUpload() {
const showUpload = ref(false);
return { showUpload, onUpload };
function onUpload(files: Record<string, any>[]) {
showUpload.value = false;
if (files.length === 0) return;
const { junctionField } = relationInfo.value;
const filesAsJunctionRows = files.map((file) => {
return {
[junctionField]: file.id,
};
});
emit('input', [...(props.value || []), ...filesAsJunctionRows]);
}
}
},
});
</script>
<style lang="scss" scoped>
.actions {
margin-top: 12px;
}
.existing {
margin-left: 12px;
}
.download {
--v-icon-color: var(--foreground-subdued);
margin-right: 8px;
}
.deselect {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--danger);
}
</style>

View File

@@ -1,15 +0,0 @@
import { defineInterface } from '../define';
import InterfaceFiles from './files.vue';
export default defineInterface({
id: 'files',
name: '$t:interfaces.files.files',
description: '$t:interfaces.files.description',
icon: 'note_add',
component: InterfaceFiles,
types: ['alias'],
groups: ['files'],
relational: true,
options: [],
recommendedDisplays: ['files'],
});

View File

@@ -6,7 +6,7 @@
{{ $t('disabled') }}
</v-notice>
<div class="image-preview" v-else-if="image" :class="{ 'is-svg': image.type.includes('svg') }">
<div class="image-preview" v-else-if="image" :class="{ 'is-svg': image.type && image.type.includes('svg') }">
<img :src="src" alt="" role="presentation" />
<div class="shadow" />

View File

@@ -5,6 +5,10 @@
</div>
<v-list v-else>
<v-notice v-if="previewValues.length === 0">
{{ $t('no_items') }}
</v-notice>
<draggable
:force-fallback="true"
:value="previewValues"

View File

@@ -10,7 +10,7 @@ export default defineInterface({
component: InterfaceManyToMany,
relational: true,
types: ['alias'],
groups: ['m2m'],
groups: ['m2m', 'files'],
options: Options,
recommendedDisplays: ['related-values'],
});

View File

@@ -3,17 +3,27 @@
{{ $t('relationship_not_setup') }}
</v-notice>
<div class="many-to-many" v-else>
<v-notice v-if="sortedItems.length === 0">
{{ $t('no_items') }}
</v-notice>
<v-list>
<draggable
:force-fallback="true"
:value="sortedItems || items"
:value="sortedItems"
@input="sortItems($event)"
handler=".drag-handle"
:disabled="!relation.sort_field"
:disabled="!junction.sort_field"
>
<v-list-item v-for="item in sortedItems || items" :key="item.id" block @click="editItem(item)">
<v-icon v-if="relation.sort_field" name="drag_handle" class="drag-handle" left @click.stop="() => {}" />
<render-template :item="item" :template="templateWithDefaults" />
<v-list-item
:dense="sortedItems.length > 4"
v-for="item in sortedItems"
:key="item.id"
block
@click="editItem(item)"
>
<v-icon v-if="junction.sort_field" name="drag_handle" class="drag-handle" left @click.stop="() => {}" />
<render-template :collection="junctionCollection.collection" :item="item" :template="templateWithDefaults" />
<div class="spacer" />
<v-icon name="close" @click.stop="deleteItem(item)" />
</v-list-item>
@@ -66,6 +76,7 @@ import useEdit from './use-edit';
import useSelection from './use-selection';
import useSort from './use-sort';
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
export default defineComponent({
components: { DrawerItem, DrawerCollection, Draggable },
@@ -104,7 +115,9 @@ export default defineComponent({
() => props.template || junctionCollection.value.meta?.display_template || `{{${junction.value.many_primary}}}`
);
const fields = computed(() => getFieldsFromTemplate(templateWithDefaults.value));
const fields = computed(() =>
adjustFieldsForDisplays(getFieldsFromTemplate(templateWithDefaults.value), junctionCollection.value.collection)
);
const { deleteItem, getUpdatedItems, getNewItems, getPrimaryKeys, getNewSelectedItems } = useActions(
value,

View File

@@ -4,11 +4,15 @@
</v-notice>
<div v-else class="form-grid">
<div class="field full">
<p class="type-label">{{ $t('select_fields') }}</p>
<p class="type-label">{{ $t('display_template') }}</p>
<v-field-template
:collection="junctionCollection"
v-model="template"
:inject="junctionCollectionExists ? null : { fields: newFields, collections: newCollections, relations }"
:collection="junctionCollection"
:depth="2"
:inject="!!junctionCollectionInfo ? null : { fields: newFields, collections: newCollections, relations }"
:placeholder="
junctionCollectionInfo && junctionCollectionInfo.meta && junctionCollectionInfo.meta.display_template
"
/>
</div>
</div>
@@ -17,7 +21,6 @@
<script lang="ts">
import { Field } from '@/types';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { useRelationsStore } from '@/stores/';
import { Relation, Collection } from '@/types';
import { useCollectionsStore } from '@/stores';
export default defineComponent({
@@ -71,13 +74,13 @@ export default defineComponent({
return junctionRelation?.many_collection || null;
});
const junctionCollectionExists = computed(() => {
return !!collectionsStore.state.collections.find(
(collection) => collection.collection === junctionCollection.value
);
const junctionCollectionInfo = computed(() => {
if (!junctionCollection.value) return null;
return collectionsStore.getCollection(junctionCollection.value);
});
return { template, junctionCollection, junctionCollectionExists };
return { template, junctionCollection, junctionCollectionInfo };
},
});
</script>

View File

@@ -13,7 +13,7 @@ export default function useSort(
const sortedItems = computed(() => {
const sField = relation.value.sortField;
if (sField === null || sort.value.by !== sField) return null;
if (sField === null || sort.value.by !== sField) return items.value;
const desc = sort.value.desc;
const sorted = sortBy(items.value, [sField]);

View File

@@ -3,6 +3,10 @@
{{ $t('relationship_not_setup') }}
</v-notice>
<div class="one-to-many" v-else>
<v-notice v-if="sortedItems.length === 0">
{{ $t('no_items') }}
</v-notice>
<v-list>
<draggable
:force-fallback="true"
@@ -11,7 +15,13 @@
handler=".drag-handle"
:disabled="!relation.sort_field"
>
<v-list-item v-for="item in sortedItems" :key="item.id" block @click="editItem(item)">
<v-list-item
:dense="sortedItems.length > 4"
v-for="item in sortedItems"
:key="item.id"
block
@click="editItem(item)"
>
<v-icon v-if="relation.sort_field" name="drag_handle" class="drag-handle" left @click.stop="() => {}" />
<render-template :collection="relation.many_collection" :item="item" :template="templateWithDefaults" />
<div class="spacer" />
@@ -64,6 +74,7 @@ import { get } from 'lodash';
import { unexpectedError } from '@/utils/unexpected-error';
import { getFieldsFromTemplate } from '@/utils/get-fields-from-template';
import Draggable from 'vuedraggable';
import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
export default defineComponent({
components: { DrawerItem, DrawerCollection, Draggable },
@@ -103,7 +114,10 @@ export default defineComponent({
const templateWithDefaults = computed(
() => props.template || relatedCollection.value.meta?.display_template || `{{${relation.value.many_primary}}}`
);
const fields = computed(() => getFieldsFromTemplate(templateWithDefaults.value));
const fields = computed(() =>
adjustFieldsForDisplays(getFieldsFromTemplate(templateWithDefaults.value), relatedCollection.value.collection)
);
const { tableHeaders, items, loading } = useTable();
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit } = useEdits();

View File

@@ -3,19 +3,18 @@
{{ $t('interfaces.one-to-many.no_collection') }}
</v-notice>
<div v-else class="form-grid">
<div class="field half-left">
<p class="type-label">{{ $t('select_fields') }}</p>
<div class="field full">
<p class="type-label">{{ $t('display_template') }}</p>
<v-field-template
:collection="relatedCollection"
v-model="template"
:inject="relatedCollectionExists ? null : { fields: newFields, collections: newCollections, relations }"
:collection="relatedCollection"
:depth="2"
:inject="!!relatedCollectionInfo ? null : { fields: newFields, collections: newCollections, relations }"
:placeholder="
relatedCollectionInfo && relatedCollectionInfo.meta && relatedCollectionInfo.meta.display_template
"
/>
</div>
<div class="field half-right">
<p class="type-label">{{ $t('order') }}</p>
<v-field-select v-model="order" :collection="relatedCollection" />
</div>
</div>
</template>
@@ -66,18 +65,6 @@ export default defineComponent({
},
});
const order = computed({
get() {
return props.value?.order;
},
set(newTemplate: string) {
emit('input', {
...(props.value || {}),
order: newTemplate,
});
},
});
const relatedCollection = computed(() => {
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
const { field } = props.fieldData;
@@ -87,13 +74,12 @@ export default defineComponent({
return relatedRelation?.many_collection || null;
});
const relatedCollectionExists = computed(() => {
return !!collectionsStore.state.collections.find(
(collection) => collection.collection === relatedCollection.value
);
const relatedCollectionInfo = computed(() => {
if (!relatedCollection.value) return null;
return collectionsStore.getCollection(relatedCollection.value);
});
return { template, order, relatedCollection, relatedCollectionExists };
return { template, relatedCollection, relatedCollectionInfo };
},
});
</script>

View File

@@ -1,8 +1,18 @@
<template>
<div class="repeater">
<v-notice v-if="!value || value.length === 0">
{{ $t('no_items') }}
</v-notice>
<v-list>
<draggable :force-fallback="true" :value="value" @input="$emit('input', $event)" handler=".drag-handle">
<v-list-item v-for="(item, index) in value" :key="item.id" block @click="active = index">
<v-list-item
:dense="value.length > 4"
v-for="(item, index) in value"
:key="item.id"
block
@click="active = index"
>
<v-icon name="drag_handle" class="drag-handle" left @click.stop="() => {}" />
<render-template :fields="fields" :item="item" :template="templateWithDefaults" />
<div class="spacer" />

View File

@@ -4,8 +4,15 @@
</v-notice>
<div v-else class="form-grid">
<div class="field full">
<p class="type-label">{{ $t('interfaces.translations.display_template') }}</p>
<v-field-template :collection="relatedCollection" v-model="template" :depth="2" />
<p class="type-label">{{ $t('display_template') }}</p>
<v-field-template
:collection="relatedCollection"
v-model="template"
:depth="2"
:placeholder="
relatedCollectionInfo && relatedCollectionInfo.meta && relatedCollectionInfo.meta.display_template
"
/>
</div>
</div>
</template>
@@ -13,8 +20,9 @@
<script lang="ts">
import { Field } from '@/types';
import { defineComponent, PropType, computed } from '@vue/composition-api';
import { useRelationsStore } from '@/stores/';
import { Relation } from '@/types/relations';
import { useCollectionsStore } from '@/stores/';
export default defineComponent({
props: {
collection: {
@@ -35,7 +43,8 @@ export default defineComponent({
},
},
setup(props, { emit }) {
const relationsStore = useRelationsStore();
const collectionsStore = useCollectionsStore();
const template = computed({
get() {
return props.value?.template;
@@ -52,12 +61,17 @@ export default defineComponent({
if (!props.fieldData || !props.relations || props.relations.length === 0) return null;
const { field } = props.fieldData;
const relation = props.relations.find(
(relation) => relation.one_collection !== props.collection && relation.one_field !== field
(relation) => relation.one_collection === props.collection && relation.one_field === field
);
return relation?.one_collection || null;
return relation?.many_collection || null;
});
return { template, relatedCollection };
const relatedCollectionInfo = computed(() => {
if (!relatedCollection.value) return null;
return collectionsStore.getCollection(relatedCollection.value);
});
return { template, relatedCollection, relatedCollectionInfo };
},
});
</script>

View File

@@ -691,6 +691,7 @@ fields:
sort_field: Sort Field
accountability: Activity & Revision Tracking
directus_files:
$thumbnail: Thumbnail
title: Title
description: Description
tags: Tags

View File

@@ -229,7 +229,7 @@ export default defineComponent({
const fileFields = computed(() => {
return fieldsInCollection.value.filter((field) => {
if (field.field === '$file') return true;
if (field.field === '$thumbnail') return true;
const relation = relationsStore.state.relations.find((relation) => {
return (
@@ -419,7 +419,7 @@ export default defineComponent({
fields.push(`${imageSource.value}.id`);
}
if (props.collection === 'directus_files' && imageSource.value === '$file') {
if (props.collection === 'directus_files' && imageSource.value === '$thumbnail') {
fields.push('modified_on');
fields.push('type');
}

View File

@@ -10,16 +10,23 @@ import { merge, orderBy } from 'lodash';
import { nanoid } from 'nanoid';
import { unexpectedError } from '@/utils/unexpected-error';
/**
* directus_files is a special case. For it to play nice with interfaces/layouts/displays, we need
* to treat the actual image thumbnail as a separate available field, instead of part of the regular
* item (normally all file related info is nested within a separate column). This allows layouts to
* render out files as it if were a "normal" collection, where the actual file is a fake m2o to
* itself.
*/
const fakeFilesField: Field = {
collection: 'directus_files',
field: '$file',
field: '$thumbnail',
schema: null,
name: i18n.t('file'),
name: '$thumbnail',
type: 'integer',
meta: {
id: -1,
collection: 'directus_files',
field: '$file',
field: '$thumbnail',
sort: null,
special: null,
interface: null,
@@ -55,16 +62,6 @@ export const useFieldsStore = createStore({
const fields: FieldRaw[] = fieldsResponse.data.data;
/**
* @NOTE
*
* directus_files is a special case. For it to play nice with layouts, we need to
* treat the actual image as a separate available field, instead of part of the regular
* item (normally all file related info is nested within a separate column). This allows
* layouts to render out files as it if were a "normal" collection, where the actual file
* is a fake m2o to itself.
*/
this.state.fields = [...fields.map(this.parseField), fakeFilesField];
this.translateFields();

View File

@@ -27,30 +27,6 @@ export const useRelationsStore = createStore({
if (!fieldInfo) return [];
if (fieldInfo.type === 'file') {
return [
{
many_collection: collection,
many_field: field,
one_collection: 'directus_files',
one_field: null,
junction_field: null,
},
] as Relation[];
}
if (['user', 'user_created', 'user_updated', 'owner'].includes(fieldInfo.type)) {
return [
{
many_collection: collection,
many_field: field,
one_collection: 'directus_users',
one_field: null,
junction_field: null,
},
] as Relation[];
}
const relations: Relation[] = this.getRelationsForCollection(collection).filter((relation: Relation) => {
return (
(relation.many_collection === collection && relation.many_field === field) ||

View File

@@ -18,12 +18,14 @@ export default function adjustFieldsForDisplays(fields: readonly string[], paren
if (!display) return fieldKey;
if (!display?.fields) return fieldKey;
let fieldKeys: string[] | null = null;
if (Array.isArray(display.fields)) {
return display.fields.map((relatedFieldKey: string) => `${fieldKey}.${relatedFieldKey}`);
fieldKeys = display.fields.map((relatedFieldKey: string) => `${fieldKey}.${relatedFieldKey}`);
}
if (typeof display.fields === 'function') {
return display
fieldKeys = display
.fields(field.meta?.display_options, {
collection: field.collection,
field: field.field,
@@ -32,6 +34,24 @@ export default function adjustFieldsForDisplays(fields: readonly string[], paren
.map((relatedFieldKey: string) => `${fieldKey}.${relatedFieldKey}`);
}
if (fieldKeys) {
return fieldKeys.map((fieldKey) => {
/**
* This is for the special case where you want to show a thumbnail in a relation to
* directus_files. The thumbnail itself isn't a real field, but shows the thumbnail based
* on the other available fields (like ID, title, and type).
*/
if (fieldKey.includes('$thumbnail') && field.collection === 'directus_files') {
return fieldKey
.split('.')
.filter((part) => part !== '$thumbnail')
.join('.');
}
return fieldKey;
});
}
return fieldKey;
})
.flat();

View File

@@ -11,5 +11,6 @@ export function getFieldsFromTemplate(template: string | null) {
fields = fields.map((field) => {
return field.replace(/{{/g, '').replace(/}}/g, '').trim();
});
return fields as string[];
}

View File

@@ -11,7 +11,7 @@ export default function getRelatedCollection(collection: string, field: string)
const type = fieldInfo.type.toLowerCase();
// o2m | m2m
if (['o2m', 'm2m', 'm2a', 'alias', 'translations'].includes(type)) {
if (['o2m', 'm2m', 'm2a', 'alias', 'translations', 'files'].includes(type)) {
return relations[0].many_collection;
}

View File

@@ -3,13 +3,15 @@
<template v-for="(part, index) in parts">
<value-null :key="index" v-if="part === null || part.value === null" />
<component
v-else-if="typeof part === 'object'"
v-else-if="typeof part === 'object' && part.component"
:is="`display-${part.component}`"
:key="index"
:value="part.value"
:interface="part.interface"
:interface-options="part.interfaceOptions"
:type="part.type"
:collection="part.collection"
:field="part.field"
v-bind="part.options"
/>
<span :key="index" v-else>{{ part }}</span>
@@ -51,20 +53,6 @@ export default defineComponent({
const regex = /({{.*?}})/g;
const fields = computed(() => {
const fields: Field[] = [];
if (props.collection) {
fields.push(...fieldsStore.getFieldsForCollection(props.collection));
}
if (props.fields) {
fields.push(...props.fields);
}
return fields;
});
const parts = computed(() =>
props.template
.split(regex)
@@ -72,15 +60,31 @@ export default defineComponent({
.map((part) => {
if (part.startsWith('{{') === false) return part;
const fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim();
const field: Field | undefined = fields.value.find((field) => field.field === fieldKey);
let fieldKey = part.replace(/{{/g, '').replace(/}}/g, '').trim();
const field: Field | undefined =
fieldsStore.getField(props.collection, fieldKey) || props.fields?.find((field) => field.field === fieldKey);
/**
* This is for cases where you are rendering a display template directly on
* directus_files. The $thumbnail fields doesn't exist, but instead renders a
* thumbnail based on the other fields in the file info. In that case, the value
* should be the whole related file object, not just the fake "thumbnail" field. By
* stripping out the thumbnail part in the field key path, the rest of the function
* will extract the value correctly.
*/
if (field && field.collection === 'directus_files' && field.field === '$thumbnail') {
fieldKey = fieldKey
.split('.')
.filter((part) => part !== '$thumbnail')
.join('.');
}
// Try getting the value from the item, return some question marks if it doesn't exist
const value = get(props.item, fieldKey);
if (value === undefined) return null;
// If no display is configured, we can render the raw value
if (!field || field.meta?.display === null) return value;
if (!field || !field.meta?.display) return value;
const displayInfo = displays.value.find((display) => display.id === field.meta?.display);
@@ -100,6 +104,8 @@ export default defineComponent({
interface: field.meta?.interface,
interfaceOptions: field.meta?.options,
type: field.type,
collection: field.collection,
field: field.field,
};
})
.map((p) => p || null)
@@ -116,14 +122,15 @@ export default defineComponent({
.render-template {
position: relative;
max-width: 100%;
height: 100%;
padding-right: 8px;
line-height: normal;
& > * {
@include no-wrap;
> * {
vertical-align: middle;
}
@include no-wrap;
}
.subdued {