Add dedicated "files" interface (#8110)

* add download button for m2m

* create files interface

* Add migration to use new files interface

* Fix linter warnings

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Nitwel
2021-09-28 00:27:03 +02:00
committed by GitHub
parent 636ccf0503
commit cf05527f0e
7 changed files with 385 additions and 84 deletions

View File

@@ -0,0 +1,19 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex('directus_fields')
.update({
interface: 'files',
})
.where('interface', '=', 'list-m2m')
.andWhere('special', '=', 'files');
}
export async function down(knex: Knex): Promise<void> {
await knex('directus_fields')
.update({
interface: 'list-m2m',
})
.where('interface', '=', 'files')
.andWhere('special', '=', 'files');
}

View File

@@ -0,0 +1,304 @@
<template>
<v-notice v-if="!junction || !relation" type="warning">
{{ t('relationship_not_setup') }}
</v-notice>
<div v-else class="many-to-many">
<template v-if="loading">
<v-skeleton-loader
v-for="n in (value || []).length || 3"
:key="n"
:type="(value || []).length > 4 ? 'block-list-item-dense' : 'block-list-item'"
/>
</template>
<v-notice v-else-if="sortedItems.length === 0">
{{ t('no_items') }}
</v-notice>
<v-list v-else>
<draggable
:force-fallback="true"
:model-value="sortedItems"
item-key="id"
handle=".drag-handle"
:disabled="!junction.meta.sort_field"
@update:model-value="sortItems($event)"
>
<template #item="{ element }">
<v-list-item :dense="sortedItems.length > 4" block clickable @click="editItem(element)">
<v-icon
v-if="junction.meta.sort_field"
name="drag_handle"
class="drag-handle"
left
@click.stop="() => {}"
/>
<render-template
:collection="junctionCollection.collection"
:item="element"
:template="templateWithDefaults"
/>
<div class="spacer" />
<v-icon v-if="!disabled" name="close" @click.stop="deleteItem(element)" />
</v-list-item>
</template>
</draggable>
</v-list>
<div v-if="!disabled" class="actions">
<v-button v-if="enableCreate && createAllowed" @click="showUpload = true">{{ t('upload_file') }}</v-button>
<v-button v-if="enableSelect && selectAllowed" @click="selectModalActive = true">
{{ t('add_existing') }}
</v-button>
</div>
<drawer-item
v-if="!disabled"
:active="editModalActive"
:collection="relationInfo.junctionCollection"
:primary-key="currentlyEditing || '+'"
:related-primary-key="relatedPrimaryKey || '+'"
:junction-field="relationInfo.junctionField"
:edits="editsAtStart"
:circular-field="junction.field"
@input="stageEdits"
@update:active="cancelEdit"
>
<template #actions>
<v-button
v-if="currentlyEditing !== '+' && relationCollection.collection === 'directus_files'"
secondary
rounded
icon
download
:href="downloadUrl"
>
<v-icon name="download" />
</v-button>
</template>
</drawer-item>
<drawer-collection
v-if="!disabled"
v-model:active="selectModalActive"
:collection="relationCollection.collection"
:selection="[]"
:filters="selectionFilters"
multiple
@input="stageSelection"
/>
<v-dialog v-if="!disabled" v-model="showUpload">
<v-card>
<v-card-title>{{ t('upload_file') }}</v-card-title>
<v-card-text><v-upload multiple from-url @input="onUpload" /></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 { useI18n } from 'vue-i18n';
import { defineComponent, computed, PropType, toRefs, ref } from 'vue';
import DrawerItem from '@/views/private/components/drawer-item';
import DrawerCollection from '@/views/private/components/drawer-collection';
import { get } from 'lodash';
import Draggable from 'vuedraggable';
import useActions from '../list-m2m/use-actions';
import useRelation from '../list-m2m/use-relation';
import usePreview from '../list-m2m/use-preview';
import useEdit from '../list-m2m/use-edit';
import useSelection from '../list-m2m/use-selection';
import useSort from '../list-m2m/use-sort';
import usePermissions from '../list-m2m/use-permissions';
import { getFieldsFromTemplate } from '@directus/shared/utils';
import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
import { getRootPath } from '@/utils/get-root-path';
import { addTokenToURL } from '@/api';
export default defineComponent({
components: { DrawerItem, DrawerCollection, Draggable },
props: {
value: {
type: Array as PropType<(number | string | Record<string, any>)[] | null>,
default: null,
},
primaryKey: {
type: [Number, String],
required: true,
},
collection: {
type: String,
required: true,
},
field: {
type: String,
required: true,
},
template: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
enableCreate: {
type: Boolean,
default: true,
},
enableSelect: {
type: Boolean,
default: true,
},
},
emits: ['input'],
setup(props, { emit }) {
const { t } = useI18n();
const { value, collection, field } = toRefs(props);
const { junction, junctionCollection, relation, relationCollection, relationInfo } = useRelation(collection, field);
const templateWithDefaults = computed(() => {
if (props.template) return props.template;
if (junctionCollection.value.meta?.display_template) return junctionCollection.value.meta.display_template;
let relatedDisplayTemplate = relationCollection.value.meta?.display_template;
if (relatedDisplayTemplate) {
const regex = /({{.*?}})/g;
const parts = relatedDisplayTemplate.split(regex).filter((p) => p);
for (const part of parts) {
if (part.startsWith('{{') === false) continue;
const key = part.replace(/{{/g, '').replace(/}}/g, '').trim();
const newPart = `{{${relation.value.field}.${key}}}`;
relatedDisplayTemplate = relatedDisplayTemplate.replace(part, newPart);
}
return relatedDisplayTemplate;
}
return `{{${relation.value.field}.${relationInfo.value.relationPkField}}}`;
});
const fields = computed(() =>
adjustFieldsForDisplays(getFieldsFromTemplate(templateWithDefaults.value), junctionCollection.value.collection)
);
const { deleteItem, getUpdatedItems, getNewItems, getPrimaryKeys, getNewSelectedItems } = useActions(
value,
relationInfo,
emitter
);
const { tableHeaders, items, loading } = usePreview(
value,
fields,
relationInfo,
getNewSelectedItems,
getUpdatedItems,
getNewItems,
getPrimaryKeys
);
const { currentlyEditing, editItem, editsAtStart, stageEdits, cancelEdit, relatedPrimaryKey, editModalActive } =
useEdit(value, relationInfo, emitter);
const { stageSelection, selectModalActive, selectionFilters } = useSelection(value, items, relationInfo, emitter);
const { sort, sortItems, sortedItems } = useSort(relationInfo, fields, items, emitter);
const { createAllowed, selectAllowed } = usePermissions(junctionCollection, relationCollection);
const { showUpload, onUpload } = useUpload();
const downloadUrl = computed(() => {
if (relatedPrimaryKey.value === null || relationCollection.value.collection !== 'directus_files') return;
return addTokenToURL(getRootPath() + `assets/${relatedPrimaryKey.value}`);
});
return {
t,
junction,
relation,
tableHeaders,
loading,
currentlyEditing,
editItem,
junctionCollection,
relationCollection,
editsAtStart,
stageEdits,
cancelEdit,
stageSelection,
selectModalActive,
deleteItem,
selectionFilters,
items,
relationInfo,
relatedPrimaryKey,
get,
editModalActive,
sort,
sortItems,
sortedItems,
templateWithDefaults,
createAllowed,
selectAllowed,
onUpload,
showUpload,
downloadUrl,
};
function emitter(newVal: any[] | null) {
emit('input', newVal);
}
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>
.v-list {
--v-list-padding: 0 0 4px;
}
.actions {
margin-top: 8px;
.v-button + .v-button {
margin-left: 8px;
}
}
.deselect {
--v-icon-color: var(--foreground-subdued);
&:hover {
--v-icon-color: var(--danger);
}
}
</style>

View File

@@ -0,0 +1,16 @@
import { defineInterface } from '@directus/shared/utils';
import InterfaceFiles from './files.vue';
import Options from '../list-m2m/options.vue';
export default defineInterface({
id: 'files',
name: '$t:interfaces.files.files',
description: '$t:interfaces.files.description',
icon: 'note_add',
component: InterfaceFiles,
relational: true,
types: ['alias'],
groups: ['files'],
options: Options,
recommendedDisplays: ['related-values'],
});

View File

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

View File

@@ -25,7 +25,7 @@
@update:model-value="sortItems($event)"
>
<template #item="{ element }">
<v-list-item :dense="sortedItems.length > 4" block @click="editItem(element)">
<v-list-item :dense="sortedItems.length > 4" block clickable @click="editItem(element)">
<v-icon
v-if="junction.meta.sort_field"
name="drag_handle"
@@ -46,7 +46,7 @@
</v-list>
<div v-if="!disabled" class="actions">
<v-button v-if="enableCreate && createAllowed" @click="showEditModal">{{ t('create_new') }}</v-button>
<v-button v-if="enableCreate && createAllowed" @click="editModalActive = true">{{ t('create_new') }}</v-button>
<v-button v-if="enableSelect && selectAllowed" @click="selectModalActive = true">
{{ t('add_existing') }}
</v-button>
@@ -73,22 +73,12 @@
multiple
@input="stageSelection"
/>
<v-dialog v-if="!disabled" v-model="showUpload">
<v-card>
<v-card-title>{{ t('upload_file') }}</v-card-title>
<v-card-text><v-upload multiple from-url @input="onUpload" /></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 { useI18n } from 'vue-i18n';
import { defineComponent, computed, PropType, toRefs, ref } from 'vue';
import { defineComponent, computed, PropType, toRefs } from 'vue';
import DrawerItem from '@/views/private/components/drawer-item';
import DrawerCollection from '@/views/private/components/drawer-collection';
import { get } from 'lodash';
@@ -98,11 +88,11 @@ import useActions from './use-actions';
import useRelation from './use-relation';
import usePreview from './use-preview';
import useEdit from './use-edit';
import usePermissions from './use-permissions';
import useSelection from './use-selection';
import useSort from './use-sort';
import { getFieldsFromTemplate } from '@directus/shared/utils';
import adjustFieldsForDisplays from '@/utils/adjust-fields-for-displays';
import { usePermissionsStore, useUserStore } from '@/stores';
export default defineComponent({
components: { DrawerItem, DrawerCollection, Draggable },
@@ -144,9 +134,6 @@ export default defineComponent({
setup(props, { emit }) {
const { t } = useI18n();
const permissionsStore = usePermissionsStore();
const userStore = useUserStore();
const { value, collection, field } = toRefs(props);
const { junction, junctionCollection, relation, relationCollection, relationInfo } = useRelation(collection, field);
@@ -201,9 +188,7 @@ export default defineComponent({
const { sort, sortItems, sortedItems } = useSort(relationInfo, fields, items, emitter);
const { createAllowed, selectAllowed } = usePermissions();
const { showUpload, onUpload } = useUpload();
const { createAllowed, selectAllowed } = usePermissions(junctionCollection, relationCollection);
return {
t,
@@ -233,74 +218,11 @@ export default defineComponent({
templateWithDefaults,
createAllowed,
selectAllowed,
showEditModal,
onUpload,
showUpload,
};
function emitter(newVal: any[] | null) {
emit('input', newVal);
}
function usePermissions() {
const createAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === junctionCollection.value.collection
);
const hasRelatedPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === relationCollection.value.collection
);
return hasJunctionPermissions && hasRelatedPermissions;
});
const selectAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) =>
permission.action === 'create' && permission.collection === junctionCollection.value.collection
);
return hasJunctionPermissions;
});
return { createAllowed, selectAllowed };
}
function showEditModal() {
if (relationCollection.value.collection === 'directus_files') {
showUpload.value = true;
} else {
editModalActive.value = true;
}
}
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>

View File

@@ -0,0 +1,39 @@
import { usePermissionsStore, useUserStore } from '@/stores';
import { Collection } from '@directus/shared/types';
import { computed, Ref, ComputedRef } from 'vue';
export default function usePermissions(
junctionCollection: Ref<Collection>,
relationCollection: Ref<Collection>
): { createAllowed: ComputedRef<boolean>; selectAllowed: ComputedRef<boolean> } {
const permissionsStore = usePermissionsStore();
const userStore = useUserStore();
const createAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) => permission.action === 'create' && permission.collection === junctionCollection.value.collection
);
const hasRelatedPermissions = !!permissionsStore.permissions.find(
(permission) => permission.action === 'create' && permission.collection === relationCollection.value.collection
);
return hasJunctionPermissions && hasRelatedPermissions;
});
const selectAllowed = computed(() => {
const admin = userStore.currentUser?.role.admin_access === true;
if (admin) return true;
const hasJunctionPermissions = !!permissionsStore.permissions.find(
(permission) => permission.action === 'create' && permission.collection === junctionCollection.value.collection
);
return hasJunctionPermissions;
});
return { createAllowed, selectAllowed };
}

View File

@@ -13,6 +13,7 @@
</template>
<template #actions>
<slot name="actions" />
<v-button v-tooltip.bottom="t('save')" icon rounded @click="save">
<v-icon name="check" />
</v-button>