mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
@@ -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');
|
||||
}
|
||||
304
app/src/interfaces/files/files.vue
Normal file
304
app/src/interfaces/files/files.vue
Normal 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>
|
||||
16
app/src/interfaces/files/index.ts
Normal file
16
app/src/interfaces/files/index.ts
Normal 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'],
|
||||
});
|
||||
@@ -10,7 +10,7 @@ export default defineInterface({
|
||||
component: InterfaceListM2M,
|
||||
relational: true,
|
||||
types: ['alias'],
|
||||
groups: ['m2m', 'files'],
|
||||
groups: ['m2m'],
|
||||
options: Options,
|
||||
recommendedDisplays: ['related-values'],
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
app/src/interfaces/list-m2m/use-permissions.ts
Normal file
39
app/src/interfaces/list-m2m/use-permissions.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user