Add default-folder option (#3209)

* Allow set folder for imported files

* Allow passing folder in file/files component; Allow pick folder for file/files/image interfaces.

* Added folder system component for picking folders; Move folder picker the field from data to interface (file, files, image).

* Add custom folder interface; use props for interfaces file/files/image in upload component

* Allow set folder for imported files

* Allow passing folder in file/files component; Allow pick folder for file/files/image interfaces.

* Added folder system component for picking folders; Move folder picker the field from data to interface (file, files, image).

* Add custom folder interface; use props for interfaces file/files/image in upload component

* Update after rebase

* Add storage_default_folder setting, use folder when deploy file

* Fix files options; Add default label option for folder interface.

* Fix set folder for file

* UX

* Add migration for column, undo seed change

* Update nomanclature

* Make sure file library always submits folder, cleanup setting retrieval

* Use indexName on down migrate

* Fix import default folder, rename customPresets->folderPreset

* Rename interface import

* Use undefined as default folder

* Remove deprecated lang file

* Fix display of folder interface, treat null as value

* Move shared composable

* Remove unused ref

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
This commit is contained in:
Adrian Dimitrov
2021-07-22 00:29:21 +03:00
committed by GitHub
parent d487f691a3
commit efe7b076a3
25 changed files with 365 additions and 34 deletions

View File

@@ -0,0 +1,22 @@
import { Knex } from 'knex';
import { getDefaultIndexName } from '../../utils/get-default-index-name';
const indexName = getDefaultIndexName('foreign', 'directus_settings', 'storage_default_folder');
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_settings', (table) => {
table
.uuid('storage_default_folder')
.references('id')
.inTable('directus_folders')
.withKeyName(indexName)
.onDelete('SET NULL');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_files', (table) => {
table.dropForeign(['storage_default_folder'], indexName);
table.dropColumn('storage_default_folder');
});
}

View File

@@ -243,6 +243,11 @@ fields:
text: Presets Only
width: half
- field: storage_default_folder
interface: system-folder
width: half
note: Default folder where new files are uploaded
- field: overrides_divider
interface: presentation-divider
options:

View File

@@ -32,6 +32,14 @@ export class FilesService extends ItemsService {
): Promise<PrimaryKey> {
const payload = clone(data);
if ('folder' in payload === false) {
const settings = await this.knex.select('storage_default_folder').from('directus_settings').first();
if (settings?.storage_default_folder) {
payload.folder = settings.storage_default_folder;
}
}
if (primaryKey !== undefined) {
await this.updateOne(primaryKey, payload, { emitEvents: false });

View File

@@ -18,7 +18,7 @@
:autofocus="disabled !== true && autofocus"
:disabled="disabled"
:loading="loading"
:value="modelValue === undefined ? field.schema.default_value : modelValue"
:value="modelValue === undefined ? field.schema?.default_value : modelValue"
:width="(field.meta && field.meta.width) || 'full'"
:type="field.type"
:collection="field.collection"
@@ -62,7 +62,7 @@ export default defineComponent({
},
modelValue: {
type: [String, Number, Object, Array, Boolean],
default: null,
default: undefined,
},
loading: {
type: Boolean,

View File

@@ -129,10 +129,10 @@ export default defineComponent({
});
const defaultValue = computed(() => {
const value = props.field.schema?.default_value;
const value = props.field?.schema?.default_value;
if (value !== undefined) return value;
return null;
return undefined;
});
const internalValue = computed(() => {

View File

@@ -117,6 +117,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
folder: {
type: String,
default: undefined,
},
},
emits: ['input'],
setup(props, { emit }) {
@@ -159,6 +163,12 @@ export default defineComponent({
uploading.value = true;
progress.value = 0;
const folderPreset: { folder?: string } = {};
if (props.folder) {
folderPreset.folder = props.folder;
}
try {
numberOfFiles.value = files.length;
@@ -168,7 +178,10 @@ export default defineComponent({
progress.value = Math.round(percentage.reduce((acc, cur) => (acc += cur)) / files.length);
done.value = percentage.filter((p) => p === 100).length;
},
preset: props.preset,
preset: {
...props.preset,
...folderPreset,
},
});
uploadedFiles && emit('input', uploadedFiles);
@@ -179,7 +192,10 @@ export default defineComponent({
done.value = percentage === 100 ? 1 : 0;
},
fileId: props.fileId,
preset: props.preset,
preset: {
...props.preset,
...folderPreset,
},
});
uploadedFile && emit('input', uploadedFile);
@@ -272,6 +288,9 @@ export default defineComponent({
try {
const response = await api.post(`/files/import`, {
url: url.value,
data: {
folder: props.folder,
},
});
if (props.multiple) {

View File

@@ -0,0 +1,70 @@
<template>
<v-list-item
v-if="!folder.children || folder.children.length === 0"
:active="currentFolder === folder.id"
:disabled="disabled"
clickable
@click="$emit('click', folder.id)"
>
<v-list-item-icon>
<v-icon :name="currentFolder === folder.id ? 'folder_open' : 'folder'" />
</v-list-item-icon>
<v-list-item-content>{{ folder.name }}</v-list-item-content>
</v-list-item>
<v-list-group
v-else
clickable
:active="currentFolder === folder.id"
:disabled="disabled"
@click="$emit('click', folder.id)"
>
<template #activator>
<v-list-item-icon>
<v-icon :name="currentFolder === folder.id ? 'folder_open' : 'folder'" />
</v-list-item-icon>
<v-list-item-content>{{ folder.name }}</v-list-item-content>
</template>
<folder-list-item
v-for="childFolder in folder.children"
:key="childFolder.id"
:folder="childFolder"
:current-folder="currentFolder"
:disabled="disabledFolders.includes(childFolder.id)"
:disabled-folders="disabledFolders"
@click="$emit('click', $event)"
/>
</v-list-group>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
type Folder = {
id: string;
name: string;
children: Folder[];
};
export default defineComponent({
name: 'FolderListItem',
props: {
folder: {
type: Object as PropType<Folder>,
required: true,
},
currentFolder: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
disabledFolders: {
type: Array as PropType<string[]>,
default: () => [],
},
},
emits: ['click'],
});
</script>

View File

@@ -0,0 +1,140 @@
<template>
<v-skeleton-loader v-if="loading" />
<v-menu
v-else
class="v-select"
:attached="true"
:show-arrow="false"
:disabled="disabled"
:close-on-content-click="true"
>
<template #activator="{ toggle, active }">
<v-input
readonly
:active="active"
:model-value="folderPath"
:placeholder="placeholder"
:disabled="disabled"
@click="toggle"
>
<template #prepend><v-icon :name="!value ? 'folder_special' : 'folder_open'" /></template>
<template #append><v-icon name="expand_more" :class="{ active }" /></template>
</v-input>
</template>
<v-list>
<v-list-item clickable :active="!value" @click="emitValue(null)">
<v-list-item-icon>
<v-icon name="folder_special" />
</v-list-item-icon>
<v-list-item-content>{{ t('interfaces.system-folder.root_name') }}</v-list-item-content>
</v-list-item>
<v-divider v-if="nestedFolders && nestedFolders.length > 0" />
<folder-list-item
v-for="folder in nestedFolders"
:key="folder.id"
clickbable
:folder="folder"
:current-folder="value"
:disabled="disabledFolders.includes(folder.id)"
:disabled-folders="disabledFolders"
@click="emitValue"
/>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { computed, defineComponent, PropType, ref } from 'vue';
import FolderListItem from './folder-list-item.vue';
import useFolders, { Folder } from '@/composables/use-folders';
import { useI18n } from 'vue-i18n';
export default defineComponent({
components: { FolderListItem },
props: {
value: {
type: String,
default: undefined,
},
disabledFolders: {
type: Array as PropType<string[]>,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: '',
},
},
emits: ['input'],
setup(props, { emit }) {
const { t } = useI18n();
const { nestedFolders, folders, loading } = useFolders();
const folderPath = computed(() => {
if (!props.value || !folders.value) {
return t('interfaces.system-folder.root_name');
}
const folder = folders.value.find((folder) => folder.id === props.value);
return folder
? folderParentPath(folder as Folder, folders.value)
.map((folder) => folder.name)
.join(' / ')
: props.value;
});
return {
emitValue,
loading,
folderPath,
nestedFolders,
onFolderSelect,
t,
};
function emitValue(id: string | null) {
return emit('input', id);
}
function folderParentPath(folder: Folder, folders: Folder[]) {
const folderMap = new Map(folders.map((folder) => [folder.id, folder]));
const folderParent = (target: Folder): Folder[] =>
(folderMap.has(target.parent) ? folderParent(folderMap.get(target.parent) as Folder) : []).concat(target);
return folderParent(folder);
}
function onFolderSelect(folderId: string | null) {
if (props.disabled) {
return;
}
emit('input', folderId);
}
},
});
</script>
<style lang="scss" scoped>
.v-input {
cursor: pointer;
.v-icon {
transition: transform var(--medium) var(--transition-out);
&.active {
transform: scaleY(-1);
transition-timing-function: var(--transition-in);
}
}
:deep(input) {
cursor: pointer;
}
}
</style>

View File

@@ -0,0 +1,14 @@
import { defineInterface } from '@/interfaces/define';
import InterfaceSystemFolder from './folder.vue';
export default defineInterface({
id: 'system-folder',
name: '$t:interfaces.system-folder.folder',
description: '$t:interfaces.system-folder.description',
icon: 'folder',
component: InterfaceSystemFolder,
types: ['uuid'],
options: [],
system: true,
recommendedDisplays: ['raw'],
});

View File

@@ -42,7 +42,7 @@
<file-lightbox :id="image.id" v-model="lightboxActive" />
</div>
<v-upload v-else from-library from-url @input="setImage" />
<v-upload v-else from-library from-url :folder="folder" @input="setImage" />
</div>
</template>
@@ -78,6 +78,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
folder: {
type: String,
default: undefined,
},
},
emits: ['input'],
setup(props, { emit }) {

View File

@@ -10,6 +10,20 @@ export default defineInterface({
types: ['uuid'],
groups: ['file'],
relational: true,
options: [],
options: [
{
field: 'folder',
name: '$t:interfaces.system-folder.folder',
type: 'uuid',
meta: {
width: 'full',
interface: 'system-folder',
note: '$t:interfaces.system-folder.field_hint',
},
schema: {
default_value: undefined,
},
},
],
recommendedDisplays: ['image'],
});

View File

@@ -95,7 +95,7 @@
<v-card>
<v-card-title>{{ t('upload_from_device') }}</v-card-title>
<v-card-text>
<v-upload from-url @input="onUpload" />
<v-upload from-url :folder="folder" @input="onUpload" />
</v-card-text>
<v-card-actions>
<v-button secondary @click="activeDialog = null">{{ t('cancel') }}</v-button>
@@ -163,6 +163,10 @@ export default defineComponent({
type: Boolean,
default: false,
},
folder: {
type: String,
default: undefined,
},
},
emits: ['input'],
setup(props, { emit }) {
@@ -289,6 +293,9 @@ export default defineComponent({
try {
const response = await api.post(`/files/import`, {
url: url.value,
data: {
folder: props.folder,
},
});
file.value = response.data.data;

View File

@@ -10,6 +10,20 @@ export default defineInterface({
types: ['uuid'],
groups: ['file'],
relational: true,
options: [],
options: [
{
field: 'folder',
name: '$t:interfaces.system-folder.folder',
type: 'uuid',
meta: {
width: 'full',
interface: 'system-folder',
note: '$t:interfaces.system-folder.field_hint',
},
schema: {
default_value: undefined,
},
},
],
recommendedDisplays: ['file'],
});

View File

@@ -1002,6 +1002,12 @@ interfaces:
one-to-many: One to Many
description: Select multiple related items
no_collection: The collection could not be found
system-folder:
folder: Folder
description: Select a folder
field_hint: Puts newly uploaded files in selected folder. Does not affect existing files that are selected.
root_name: File Library Root
system_default: System Defaults
select-radio:
radio-buttons: Radio Buttons
description: Select one of multiple choices

View File

@@ -31,7 +31,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref } from 'vue';
import useFolders from '../composables/use-folders';
import useFolders from '@/composables/use-folders';
import api from '@/api';
import { useRouter } from 'vue-router';
import { unexpectedError } from '@/utils/unexpected-error';

View File

@@ -116,7 +116,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, ref } from 'vue';
import useFolders, { Folder } from '../composables/use-folders';
import useFolders, { Folder } from '@/composables/use-folders';
import api from '@/api';
import FolderPicker from './folder-picker.vue';
import { useRouter } from 'vue-router';

View File

@@ -56,7 +56,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, watch } from 'vue';
import useFolders, { Folder } from '../composables/use-folders';
import useFolders, { Folder } from '@/composables/use-folders';
import NavigationFolder from './navigation-folder.vue';
import arraysAreEqual from '@/utils/arrays-are-equal';

View File

@@ -165,7 +165,7 @@ import emitter, { Events } from '@/events';
import { useRouter } from 'vue-router';
import { useNotificationsStore, useUserStore, usePermissionsStore } from '@/stores';
import { subDays } from 'date-fns';
import useFolders, { Folder } from '../composables/use-folders';
import useFolders, { Folder } from '@/composables/use-folders';
import useEventListener from '@/composables/use-event-listener';
import { useLayout } from '@/composables/use-layout';
import uploadFiles from '@/utils/upload-files';
@@ -603,11 +603,9 @@ export default defineComponent({
});
await uploadFiles(files, {
preset: props.folder
? {
folder: props.folder,
}
: {},
preset: {
folder: props.folder,
},
onProgressChange: (progress) => {
const percentageDone = progress.reduce((val, cur) => (val += cur)) / progress.length;

View File

@@ -9,6 +9,7 @@ export default async function uploadFiles(
onProgressChange?: (percentages: number[]) => void;
notifications?: boolean;
preset?: Record<string, any>;
folder?: string;
}
): Promise<File[] | undefined> {
const progressHandler = options?.onProgressChange || (() => undefined);

View File

@@ -24,9 +24,11 @@ export default {
.theme-code-block {
display: none;
}
.theme-code-block__active {
display: block;
}
.theme-code-block > pre {
background-color: orange;
}

View File

@@ -73,12 +73,14 @@ export default {
border-top-left-radius: 6px;
border-top-right-radius: 6px;
}
.theme-code-group__ul {
display: inline-flex;
margin: auto 0;
padding-left: 0;
list-style: none;
}
.theme-code-group__nav-tab {
padding: 5px;
color: rgba(255, 255, 255, 0.9);
@@ -89,9 +91,11 @@ export default {
border: 0;
cursor: pointer;
}
.theme-code-group__nav-tab-active {
border-bottom: #42b983 1px solid;
}
.pre-blank {
color: #42b983;
}

View File

@@ -22,7 +22,6 @@ Before 9.0.0-rc.84 the Docker tags were prefixed by a "v" - e.g. v9.0.0-rc.83.
:::
### Create admin user using docker
The published Docker image will automatically populate the database, and create a user. To configure the email/password

View File

@@ -51,24 +51,24 @@ All the `DB_POOL_` prefixed options are passed [to `tarn.js`](https://github.com
## Security
| Variable | Description | Default Value |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- |
| `KEY` | Unique identifier for the project. | -- |
| `SECRET` | Secret string for the project. | -- |
| `ACCESS_TOKEN_TTL` | The duration that the access token is valid. | 15m |
| `REFRESH_TOKEN_TTL` | The duration that the refresh token is valid, and also how long users stay logged-in to the App. | 7d |
| `REFRESH_TOKEN_COOKIE_DOMAIN` | Which domain to use for the refresh cookie. Useful for development mode. | -- |
| `REFRESH_TOKEN_COOKIE_SECURE` | Whether or not to use a secure cookie for the refresh token in cookie mode. | `false` |
| `REFRESH_TOKEN_COOKIE_SAME_SITE` | Value for `sameSite` in the refresh token cookie when in cookie mode. | `lax` |
| `REFRESH_TOKEN_COOKIE_NAME` | Name of refresh token cookie . | `directus_refresh_token`|
| `PASSWORD_RESET_URL_ALLOW_LIST` | List of URLs that can be used [as `reset_url` in /password/request](/reference/api/system/authentication/#request-password-reset) | -- |
| `USER_INVITE_URL_ALLOW_LIST` | List of URLs that can be used [as `invite_url` in /users/invite](/reference/api/system/users/#invite-a-new-user) | -- |
| Variable | Description | Default Value |
| -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------ |
| `KEY` | Unique identifier for the project. | -- |
| `SECRET` | Secret string for the project. | -- |
| `ACCESS_TOKEN_TTL` | The duration that the access token is valid. | 15m |
| `REFRESH_TOKEN_TTL` | The duration that the refresh token is valid, and also how long users stay logged-in to the App. | 7d |
| `REFRESH_TOKEN_COOKIE_DOMAIN` | Which domain to use for the refresh cookie. Useful for development mode. | -- |
| `REFRESH_TOKEN_COOKIE_SECURE` | Whether or not to use a secure cookie for the refresh token in cookie mode. | `false` |
| `REFRESH_TOKEN_COOKIE_SAME_SITE` | Value for `sameSite` in the refresh token cookie when in cookie mode. | `lax` |
| `REFRESH_TOKEN_COOKIE_NAME` | Name of refresh token cookie . | `directus_refresh_token` |
| `PASSWORD_RESET_URL_ALLOW_LIST` | List of URLs that can be used [as `reset_url` in /password/request](/reference/api/system/authentication/#request-password-reset) | -- |
| `USER_INVITE_URL_ALLOW_LIST` | List of URLs that can be used [as `invite_url` in /users/invite](/reference/api/system/users/#invite-a-new-user) | -- |
::: tip Cookie Strictness
Browser are pretty strict when it comes to third-party cookies. If you're running into unexpected problems when running
your project and API on different domains, make sure to verify your configuration for `REFRESH_TOKEN_COOKIE_NAME`, `REFRESH_TOKEN_COOKIE_SECURE` and
`REFRESH_TOKEN_COOKIE_SAME_SITE`.
your project and API on different domains, make sure to verify your configuration for `REFRESH_TOKEN_COOKIE_NAME`,
`REFRESH_TOKEN_COOKIE_SECURE` and `REFRESH_TOKEN_COOKIE_SAME_SITE`.
:::

View File

@@ -47,6 +47,10 @@ properties:
description: Authentication password policy.
type: string
nullable: true
storage_default_folder:
description: Default folder to place files
type: uuid
width: full
storage_asset_transform:
description: What transformations are allowed in the assets endpoint.
type: string