Merge pull request #79 from directus/v-upload-multiple

Bunch of things
This commit is contained in:
Rijk van Zanten
2020-08-07 17:29:34 -04:00
committed by GitHub
34 changed files with 249 additions and 793 deletions

View File

@@ -35,7 +35,7 @@ router.get(
asyncHandler(async (req, res) => {
const service = new FieldsService({ accountability: req.accountability });
const fields = await service.readAll(req.collection);
const fields = await service.readAll(req.params.collection);
return res.json({ data: fields || null });
})
);
@@ -50,7 +50,7 @@ router.get(
const exists = await schemaInspector.hasColumn(req.collection, req.params.field);
if (exists === false) throw new FieldNotFoundException(req.collection, req.params.field);
const field = await service.readOne(req.collection, req.params.field);
const field = await service.readOne(req.params.collection, req.params.field);
return res.json({ data: field || null });
})
);

View File

@@ -100,7 +100,8 @@ router.post(
}
const record = await service.readByKey(keys as any, req.sanitizedQuery);
return res.json({ data: record || null });
return res.json({ data: res.locals.savedFiles.length === 1 ? record[0] : record || null });
})
);

View File

@@ -81,10 +81,16 @@ export default class FieldsService {
return data as Field;
});
let aliasFields = await this.knex
const aliasQuery = this.knex
.select<FieldMeta[]>('*')
.from('directus_fields')
.whereIn('special', ['alias', 'o2m']);
.whereIn('special', ['alias', 'o2m', 'm2m']);
if (collection) {
aliasQuery.andWhere('collection', collection);
}
let aliasFields = await aliasQuery;
aliasFields = (await this.payloadService.processValues('read', aliasFields)) as FieldMeta[];

View File

@@ -102,7 +102,9 @@ export default class ItemsService implements AbstractService {
return payload;
});
await payloadService.processO2M(payloads);
for (const key of primaryKeys) {
await payloadService.processO2M(payloads, key);
}
if (this.accountability) {
const activityRecords = primaryKeys.map((key) => ({
@@ -238,18 +240,23 @@ export default class ItemsService implements AbstractService {
});
payload = await payloadService.processM2O(payload);
payload = await payloadService.processValues('update', payload);
const payloadWithoutAliases = pick(
let payloadWithoutAliases = pick(
payload,
columns.map(({ column }) => column)
);
await trx(this.collection)
.update(payloadWithoutAliases)
.whereIn(primaryKeyField, keys);
payloadWithoutAliases = await payloadService.processValues('update', payloadWithoutAliases);
await payloadService.processO2M(payload);
if (Object.keys(payloadWithoutAliases).length > 0) {
await trx(this.collection)
.update(payloadWithoutAliases)
.whereIn(primaryKeyField, keys);
}
for (const key of keys) {
await payloadService.processO2M(payload, key);
}
if (this.accountability) {
const activityRecords = keys.map((key) => ({

View File

@@ -195,7 +195,12 @@ export default class PayloadService {
if (hasPrimaryKey) {
relatedPrimaryKey = relatedRecord[relation.one_primary];
await itemsService.update(relatedRecord, relatedPrimaryKey);
if (relatedRecord.hasOwnProperty('$delete') && relatedRecord.$delete) {
await itemsService.delete(relatedPrimaryKey);
} else {
await itemsService.update(relatedRecord, relatedPrimaryKey);
}
} else {
relatedPrimaryKey = await itemsService.create(relatedRecord);
}
@@ -211,7 +216,7 @@ export default class PayloadService {
/**
* Recursively save/update all nested related o2m items
*/
async processO2M(payload: Partial<Item> | Partial<Item>[]) {
async processO2M(payload: Partial<Item> | Partial<Item>[], parent?: PrimaryKey) {
const relations = await this.knex
.select<Relation[]>('*')
.from('directus_relations')
@@ -234,7 +239,7 @@ export default class PayloadService {
const relatedRecords: Partial<Item>[] = payload[relation.one_field].map(
(record: Partial<Item>) => ({
...record,
[relation.many_field]: payload[relation.one_primary],
[relation.many_field]: parent || payload[relation.one_primary],
})
);
@@ -246,12 +251,18 @@ export default class PayloadService {
const toBeCreated = relatedRecords.filter(
(record) => record.hasOwnProperty(relation.many_primary) === false
);
const toBeUpdated = relatedRecords.filter(
(record) => record.hasOwnProperty(relation.many_primary) === true
(record) => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') === false
);
const toBeDeleted = relatedRecords
.filter(record => record.hasOwnProperty(relation.many_primary) === true && record.hasOwnProperty('$delete') && record.$delete === true)
.map(record => record[relation.many_primary]);
await itemsService.create(toBeCreated);
await itemsService.update(toBeUpdated);
await itemsService.delete(toBeDeleted);
}
}
}

78
app/package-lock.json generated
View File

@@ -7396,6 +7396,12 @@
"integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=",
"dev": true
},
"compare-versions": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-3.6.0.tgz",
"integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==",
"dev": true
},
"component-emitter": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
@@ -10305,6 +10311,15 @@
}
}
},
"find-versions": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/find-versions/-/find-versions-3.2.0.tgz",
"integrity": "sha512-P8WRou2S+oe222TOCHitLy8zj+SIsVJh52VP4lvXkaFVnOFFdoWv1H1Jjvel1aI6NCFOAaeAVm8qrI0odiLcww==",
"dev": true,
"requires": {
"semver-regex": "^2.0.0"
}
},
"flat-cache": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-2.0.1.tgz",
@@ -11607,6 +11622,57 @@
"integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
"dev": true
},
"husky": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/husky/-/husky-4.2.5.tgz",
"integrity": "sha512-SYZ95AjKcX7goYVZtVZF2i6XiZcHknw50iXvY7b0MiGoj5RwdgRQNEHdb+gPDPCXKlzwrybjFjkL6FOj8uRhZQ==",
"dev": true,
"requires": {
"chalk": "^4.0.0",
"ci-info": "^2.0.0",
"compare-versions": "^3.6.0",
"cosmiconfig": "^6.0.0",
"find-versions": "^3.2.0",
"opencollective-postinstall": "^2.0.2",
"pkg-dir": "^4.2.0",
"please-upgrade-node": "^3.2.0",
"slash": "^3.0.0",
"which-pm-runs": "^1.0.0"
},
"dependencies": {
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"ci-info": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz",
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
"dev": true
},
"pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
"integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
"dev": true,
"requires": {
"find-up": "^4.0.0"
}
},
"slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
"dev": true
}
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -19583,6 +19649,12 @@
}
}
},
"semver-regex": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/semver-regex/-/semver-regex-2.0.0.tgz",
"integrity": "sha512-mUdIBBvdn0PLOeP3TEkMH7HHeUP3GjsXCwKarjv/kGmUFOYg1VqEemKhoQpWMu6X2I8kHeuVdGibLGkVK+/5Qw==",
"dev": true
},
"send": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/send/-/send-0.17.1.tgz",
@@ -23896,6 +23968,12 @@
"integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
"dev": true
},
"which-pm-runs": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.0.0.tgz",
"integrity": "sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs=",
"dev": true
},
"wide-align": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz",

View File

@@ -112,6 +112,7 @@
"eslint-plugin-prettier": "^3.1.4",
"eslint-plugin-vue": "^6.2.2",
"html-loader": "^1.1.0",
"husky": "^4.2.5",
"jest-sonar": "^0.2.10",
"lint-staged": "^10.2.11",
"mockdate": "^3.0.2",
@@ -137,8 +138,10 @@
"webpack-assets-manifest": "^3.1.1",
"webpack-merge": "^5.0.9"
},
"gitHooks": {
"pre-commit": "lint-staged"
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{js,ts}": [

View File

@@ -15,7 +15,7 @@
<template v-else-if="uploading">
<p class="type-label">{{ progress }}%</p>
<p class="type-text">{{ $t('upload_file_indeterminate') }}</p>
<p class="type-text">{{ multiple && numberOfFiles > 1 ? $t('upload_files_indeterminate', { done: done, total: numberOfFiles }) : $t('upload_file_indeterminate') }}</p>
<v-progress-linear :value="progress" rounded />
</template>
@@ -29,12 +29,17 @@
<script lang="ts">
import { defineComponent, ref } from '@vue/composition-api';
import uploadFile from '@/utils/upload-file';
import uploadFiles from '@/utils/upload-files';
export default defineComponent({
props: {},
props: {
multiple: {
type: Boolean,
default: false,
}
},
setup(props, { emit }) {
const { uploading, progress, error, upload, onBrowseSelect } = useUpload();
const { uploading, progress, error, upload, onBrowseSelect, done, numberOfFiles } = useUpload();
const { onDragEnter, onDragLeave, onDrop, dragging } = useDragging();
return {
@@ -46,40 +51,50 @@ export default defineComponent({
onDrop,
dragging,
onBrowseSelect,
done,
numberOfFiles
};
function useUpload() {
const uploading = ref(false);
const progress = ref(0);
const numberOfFiles = ref(0);
const done = ref(0);
const error = ref(null);
return { uploading, progress, error, upload, onBrowseSelect };
return { uploading, progress, error, upload, onBrowseSelect, numberOfFiles, done };
async function upload(file: File) {
async function upload(files: FileList) {
uploading.value = true;
progress.value = 0;
error.value = null;
try {
const response = await uploadFile(file, (percentage) => {
progress.value = percentage;
numberOfFiles.value = files.length;
const uploadedFiles = await uploadFiles(Array.from(files), (percentage) => {
progress.value = Math.round(percentage.reduce((acc, cur) => acc += cur) / files.length);
done.value = percentage.filter((p) => p === 100).length;
});
if (response) {
emit('upload', response.data.data);
if (uploadedFiles) {
emit('upload', props.multiple ? uploadedFiles : uploadedFiles[0]);
}
} catch (err) {
console.error(err);
error.value = err;
} finally {
uploading.value = false;
done.value = 0;
numberOfFiles.value = 0;
}
}
function onBrowseSelect(event: InputEvent) {
const file = (event.target as HTMLInputElement)?.files?.[0];
const files = (event.target as HTMLInputElement)?.files;
if (file) {
upload(file);
if (files) {
upload(files);
}
}
}
@@ -111,10 +126,10 @@ export default defineComponent({
dragCounter = 0;
dragging.value = false;
const file = event.dataTransfer?.files[0];
const files = event.dataTransfer?.files;
if (file) {
upload(file);
if (files) {
upload(files);
}
}
}
@@ -141,7 +156,7 @@ export default defineComponent({
color: inherit;
}
&:hover {
&:not(.uploading):hover {
color: var(--primary);
border-color: var(--primary);
}

View File

@@ -18,17 +18,12 @@
import { defineComponent, PropType, computed, ref } from '@vue/composition-api';
import readableMimeType from '@/utils/readable-mime-type';
import useElementSize from '@/composables/use-element-size';
import getRootPath from '@/utils/get-root-path';
type File = {
id: string;
type: string;
title: string;
data: {
asset_url: string;
thumbnails: {
key: string;
url: string;
}[];
};
};
export default defineComponent({
@@ -47,9 +42,9 @@ export default defineComponent({
const imageThumbnail = computed(() => {
if (!props.value) return null;
if (props.value.type.includes('svg')) return props.value.data.asset_url;
if (props.value.type.includes('svg')) return getRootPath() + `assets/${props.value.id}`;
if (props.value.type.includes('image') === false) return null;
return props.value.data.thumbnails?.find((thumb) => thumb.key === 'system-small-crop')?.url;
return getRootPath() + `assets/${props.value.id}?key=system-small-cover`;
});
const { height } = useElementSize(previewEl);

View File

@@ -6,16 +6,12 @@
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
import ValueNull from '@/views/private/components/value-null';
import getRootPath from '@/utils/get-root-path';
type Image = {
id: string;
type: string;
title: string;
data: {
thumbnails: {
key: string;
url: string;
}[];
};
};
export default defineComponent({
@@ -33,7 +29,7 @@ export default defineComponent({
setup(props) {
const src = computed(() => {
if (props.value === null) return null;
return props.value?.data?.thumbnails?.find((thumb) => thumb.key === 'system-small-crop')?.url || null;
return getRootPath() + `assets/${props.value.id}?key=system-small-cover`;
});
return { src };

View File

@@ -19,5 +19,5 @@ export default defineDisplay(({ i18n }) => ({
}
},
],
fields: ['data', 'type', 'title'],
fields: ['id', 'type', 'title'],
}));

View File

@@ -211,6 +211,7 @@ export default defineComponent({
}
function onUpload(fileInfo: FileInfo) {
console.log(fileInfo);
file.value = fileInfo;
activeDialog.value = null;
emit('input', fileInfo.id);

View File

@@ -59,7 +59,7 @@
<v-dialog v-model="showUpload">
<v-card>
<v-card-title>{{ $t('upload_file') }}</v-card-title>
<v-card-text><v-upload @upload="onUpload" /></v-card-text>
<v-card-text><v-upload @upload="onUpload" multiple /></v-card-text>
<v-card-actions>
<v-button @click="showUpload = false">{{ $t('done') }}</v-button>
</v-card-actions>
@@ -124,7 +124,7 @@ export default defineComponent({
const jf = relationCurrentToJunction.value.junction_field;
return ['id', 'data', 'type', 'title'].map((key) => `${jf}.${key}`);
return ['id', 'type', 'title'].map((key) => `${jf}.${key}`);
});
const tableHeaders = ref<TableHeader[]>([

View File

@@ -18,7 +18,7 @@
<v-button
icon
rounded
:href="image.data.full_url"
:href="downloadSrc"
:download="image.filename_download"
v-tooltip="$t('download')"
>
@@ -57,16 +57,11 @@ import i18n from '@/lang';
import FileLightbox from '@/views/private/components/file-lightbox';
import ImageEditor from '@/views/private/components/image-editor';
import { nanoid } from 'nanoid';
import getRootPath from '@/utils/get-root-path';
type Image = {
id: string; // uuid
type: string;
data: {
full_url: string;
thumbnails: {
key: string;
url: string;
}[];
};
filesize: number;
width: number;
height: number;
@@ -77,7 +72,7 @@ export default defineComponent({
components: { FileLightbox, ImageEditor },
props: {
value: {
type: Number,
type: String,
default: null,
},
disabled: {
@@ -98,18 +93,21 @@ export default defineComponent({
if (!image.value) return null;
if (image.value.type.includes('svg')) {
return image.value.data.full_url;
return getRootPath() + `assets/${image.value.id}`;
}
const url = image.value.data.thumbnails.find((thumb) => thumb.key === 'system-large-crop')?.url;
if (url) {
return `${url}&cache-buster=${cacheBuster.value}`;
if (image.value.type.includes('image')) {
return getRootPath() + `assets/${image.value.id}?key=system-large-cover&cache-buster=${cacheBuster.value}`;
}
return null;
});
const downloadSrc = computed(() => {
if (!image.value) return null;
return getRootPath() + `assets/${image.value.id}`;
});
const meta = computed(() => {
if (!image.value) return null;
const { filesize, width, height, type } = image.value;
@@ -143,6 +141,7 @@ export default defineComponent({
changeCacheBuster,
setImage,
deselect,
downloadSrc,
};
async function fetchImage() {
@@ -151,7 +150,7 @@ export default defineComponent({
try {
const response = await api.get(`/files/${props.value}`, {
params: {
fields: ['id', 'data', 'title', 'width', 'height', 'filesize', 'type', 'filename_download'],
fields: ['id', 'title', 'width', 'height', 'filesize', 'type', 'filename_download'],
},
});
@@ -169,6 +168,7 @@ export default defineComponent({
function setImage(data: Image) {
image.value = data;
emit('input', data.id);
}
function deselect() {

View File

@@ -6,6 +6,7 @@ export default defineInterface(({ i18n }) => ({
name: i18n.t('image'),
icon: 'insert_photo',
component: InterfaceImage,
types: ['string'],
types: ['uuid'],
relationship: 'm2o',
options: [],
}));

View File

@@ -42,7 +42,7 @@ export default function usePreview({
// Every time the value changes, we'll reset the preview values. This ensures that we'll
// almost show the most up to date information in the preview table, regardless of if this
// is the first load or a subsequent edit.
watch(value, setPreview);
watch(value, setPreview, { immediate: true });
return { loading, previewItems, error };
@@ -127,7 +127,7 @@ export default function usePreview({
const response = await api.get(`/items/${junctionTable}`, {
params: {
fields: adjustFieldsForDisplay(fieldsToFetch, junctionCollection.value),
[`filter[${currentInJunction}][eq]`]: primaryKey.value,
[`filter[${currentInJunction}][_eq]`]: primaryKey.value,
},
});

View File

@@ -217,9 +217,7 @@ export default defineComponent({
const { collection, searchQuery } = toRefs(props);
const { info, primaryKeyField, fields: fieldsInCollection } = useCollection(collection);
const availableFields = computed(() =>
fieldsInCollection.value.filter((field) => field.meta.hidden !== true)
);
const availableFields = computed(() => fieldsInCollection.value.filter((field) => field.meta.hidden !== true));
const fileFields = computed(() => {
return availableFields.value.filter((field) => {
@@ -366,6 +364,10 @@ export default defineComponent({
fields.push(`${imageSource.value}.id`);
}
if (props.collection === 'directus_files' && imageSource.value === '$file') {
fields.push('type');
}
const sortField = sort.value.startsWith('-') ? sort.value.substring(1) : sort.value;
if (fields.includes(sortField) === false) {

View File

@@ -3,7 +3,7 @@
<v-card>
<v-card-title>{{ $t('add_new_file') }}</v-card-title>
<v-card-text>
<v-upload @upload="onUpload" />
<v-upload multiple @upload="close" />
</v-card-text>
<v-card-actions>
<v-button secondary @click="close">{{ $t('done') }}</v-button>
@@ -24,15 +24,11 @@ export default defineComponent({
},
},
setup(props, { emit }) {
return { onUpload, close };
return { close };
function close() {
router.push('/files');
}
function onUpload() {
emit('upload');
}
},
});
</script>

View File

@@ -1,6 +1,6 @@
<template>
<v-button
v-if="collection.system === null && collection.collection.startsWith('directus_') === false"
v-if="collection.meta === null && collection.collection.startsWith('directus_') === false"
x-small
outlined
class="manage"

View File

@@ -2,15 +2,15 @@
<div>
<h2 class="type-title">{{ $t('display_setup_title') }}</h2>
<v-fancy-select class="select" :items="selectItems" v-model="fieldData.system.display" />
<v-fancy-select class="select" :items="selectItems" v-model="fieldData.meta.display" />
<v-notice class="not-found" type="danger" v-if="fieldData.system.display && !selectedDisplay">
{{ $t('display_not_found', { display: fieldData.system.display }) }}
<v-notice class="not-found" type="danger" v-if="fieldData.meta.display && !selectedDisplay">
{{ $t('display_not_found', { display: fieldData.meta.display }) }}
<div class="spacer" />
<button @click="fieldData.system.display = null">{{ $t('reset_display') }}</button>
<button @click="fieldData.meta.display = null">{{ $t('reset_display') }}</button>
</v-notice>
<template v-if="fieldData.system.display && !selectedDisplay">
<template v-if="fieldData.meta.display && !selectedDisplay">
<v-form
v-if="
selectedDisplay.options &&
@@ -19,7 +19,7 @@
"
:fields="selectedDisplay.options"
primary-key="+"
v-model="fieldData.system.options"
v-model="fieldData.meta.options"
/>
<v-notice v-else>
@@ -71,7 +71,7 @@ export default defineComponent({
);
const selectedDisplay = computed(() => {
return displays.find((display) => display.id === state.fieldData.system.display);
return displays.find((display) => display.id === state.fieldData.meta.display);
});
return { fieldData: state.fieldData, selectItems, selectedDisplay };

View File

@@ -2,15 +2,15 @@
<div>
<h2 class="type-title">{{ $t('interface_setup_title') }}</h2>
<v-fancy-select class="select" :items="selectItems" v-model="fieldData.system.interface" />
<v-fancy-select class="select" :items="selectItems" v-model="fieldData.meta.interface" />
<v-notice class="not-found" type="danger" v-if="fieldData.system.interface && !selectedInterface">
{{ $t('interface_not_found', { interface: fieldData.system.interface }) }}
<v-notice class="not-found" type="danger" v-if="fieldData.meta.interface && !selectedInterface">
{{ $t('interface_not_found', { interface: fieldData.meta.interface }) }}
<div class="spacer" />
<button @click="fieldData.system.interface = null">{{ $t('reset_interface') }}</button>
<button @click="fieldData.meta.interface = null">{{ $t('reset_interface') }}</button>
</v-notice>
<template v-if="fieldData.system.interface && selectedInterface">
<template v-if="fieldData.meta.interface && selectedInterface">
<v-form
v-if="
selectedInterface.options &&
@@ -19,7 +19,7 @@
"
:fields="selectedInterface.options"
primary-key="+"
v-model="fieldData.system.options"
v-model="fieldData.meta.options"
/>
<v-notice v-else>
@@ -71,7 +71,7 @@ export default defineComponent({
);
const selectedInterface = computed(() => {
return interfaces.find((inter) => inter.id === state.fieldData.system.interface);
return interfaces.find((inter) => inter.id === state.fieldData.meta.interface);
});
return { fieldData: state.fieldData, selectItems, selectedInterface };

View File

@@ -10,7 +10,7 @@
<div class="field">
<div class="label type-label">{{ $t('type') }}</div>
<v-input v-if="!fieldData.database" :value="$t('alias')" disabled />
<v-input v-if="!fieldData.schema" :value="$t('alias')" disabled />
<v-select
v-else
:disabled="typeDisabled || isExisting"
@@ -23,32 +23,32 @@
<div class="field full">
<div class="label type-label">{{ $t('note') }}</div>
<v-input v-model="fieldData.system.comment" :placeholder="$t('add_note')" />
<v-input v-model="fieldData.meta.comment" :placeholder="$t('add_note')" />
</div>
<!-- @todo base default value field type on selected type -->
<div class="field" v-if="fieldData.database">
<div class="field" v-if="fieldData.schema">
<div class="label type-label">{{ $t('default_value') }}</div>
<v-input
class="monospace"
v-model="fieldData.database.default_value"
v-model="fieldData.schema.default_value"
:placeholder="$t('add_a_default_value')"
/>
</div>
<div class="field" v-if="fieldData.database">
<div class="field" v-if="fieldData.schema">
<div class="label type-label">{{ $t('length') }}</div>
<v-input
type="number"
:placeholder="fieldData.type !== 'string' ? $t('not_available_for_type') : '255'"
:disabled="isExisting || fieldData.type !== 'string'"
v-model="fieldData.database.max_length"
v-model="fieldData.schema.max_length"
/>
</div>
<div class="field" v-if="fieldData.database">
<div class="field" v-if="fieldData.schema">
<div class="label type-label">{{ $t('allow_null') }}</div>
<v-checkbox v-model="fieldData.database.is_nullable" :label="$t('allow_null_label')" block />
<v-checkbox v-model="fieldData.schema.is_nullable" :label="$t('allow_null_label')" block />
</div>
<!--
@@ -56,7 +56,7 @@
<div class="field">
<div class="label type-label">{{ $t('unique') }}</div>
<v-input v-model="fieldData.database.unique" />
<v-input v-model="fieldData.schema.unique" />
</div> -->
</div>
</div>
@@ -116,17 +116,17 @@ export default defineComponent({
function setType(value: typeof types[number]) {
if (value === 'uuid') {
state.fieldData.system.special = 'uuid';
state.fieldData.meta.special = 'uuid';
} else {
state.fieldData.system.special = null;
state.fieldData.meta.special = null;
}
// We'll reset the interface/display as they most likely won't work for the newly selected
// type
state.fieldData.system.interface = null;
state.fieldData.system.options = null;
state.fieldData.system.display = null;
state.fieldData.system.display_options = null;
state.fieldData.meta.interface = null;
state.fieldData.meta.options = null;
state.fieldData.meta.display = null;
state.fieldData.meta.display_options = null;
state.fieldData.type = value;
}
},

View File

@@ -25,12 +25,12 @@ function initLocalStore(
fieldData: {
field: '',
type: '',
database: {
schema: {
default_value: undefined,
max_length: undefined,
is_nullable: true,
},
system: {
meta: {
hidden: false,
interface: undefined,
options: undefined,
@@ -52,8 +52,8 @@ function initLocalStore(
state.fieldData.field = existingField.field;
state.fieldData.type = existingField.type;
state.fieldData.database = existingField.schema;
state.fieldData.system = existingField.meta;
state.fieldData.schema = existingField.schema;
state.fieldData.meta = existingField.meta;
state.relations = relationsStore.getRelationsForField(collection, field);
}
@@ -122,11 +122,11 @@ function initLocalStore(
}
if (type === 'o2m') {
delete state.fieldData.database;
delete state.fieldData.schema;
delete state.fieldData.type;
if (!isExisting) {
state.fieldData.system.special = 'o2m';
state.fieldData.meta.special = 'o2m';
state.relations = [
{
@@ -159,11 +159,11 @@ function initLocalStore(
}
if (type === 'm2m' || type === 'files') {
delete state.fieldData.database;
delete state.fieldData.schema;
delete state.fieldData.type;
if (!isExisting) {
state.fieldData.system.special = 'm2m';
state.fieldData.meta.special = 'm2m';
state.relations = [
{

View File

@@ -1,7 +1,6 @@
import VueRouter, { NavigationGuard, RouteConfig, Route } from 'vue-router';
import LoginRoute from '@/routes/login';
import LogoutRoute from '@/routes/logout';
import InstallRoute from '@/routes/install';
import ResetPasswordRoute from '@/routes/reset-password';
import { refresh } from '@/auth';
import { hydrate } from '@/hydrate';
@@ -17,17 +16,6 @@ export const defaultRoutes: RouteConfig[] = [
path: '/',
redirect: '/login',
},
{
name: 'install',
path: '/install',
component: InstallRoute,
/**
* @todo redirect to /login if project is already installed
*/
meta: {
public: true,
},
},
{
name: 'login',
path: '/login',
@@ -117,12 +105,20 @@ export const onBeforeEach: NavigationGuard = async (to, from, next) => {
return next();
};
let trackTimeout: number | null= null;
export const onAfterEach = (to: Route) => {
const userStore = useUserStore();
if (to.meta.public !== true) {
// The timeout gives the page some breathing room to load. No need to clog up the thread with
// this call while more important things are loading
if (trackTimeout) {
clearTimeout(trackTimeout);
trackTimeout = null;
}
setTimeout(() => {
userStore.trackPage(to.fullPath);
}, 2500);

View File

@@ -1,4 +0,0 @@
import InstallRoute from './install.vue';
export { InstallRoute };
export default InstallRoute;

View File

@@ -1,87 +0,0 @@
<template>
<div>
<div class="type-title pane-title">{{ $t('database_connection') }}</div>
<div class="pane-content">
<div class="pane-form">
<div class="field">
<div class="type-label label">{{ $t('host') }}</div>
<v-input v-model="_value.db_host" />
</div>
<div class="field">
<div class="type-label label">{{ $t('port') }}</div>
<v-input type="number" v-model="_value.db_port" />
</div>
<div class="field">
<div class="type-label label">{{ $t('db_user') }}</div>
<v-input v-model="_value.db_user" />
</div>
<div class="field">
<div class="type-label label">{{ $t('db_password') }}</div>
<v-input type="password" v-model="_value.db_password" />
</div>
<div class="field">
<div class="type-label label">{{ $t('db_name') }}</div>
<v-input v-model="_value.db_name" class="db" />
</div>
<div class="field">
<div class="type-label label">{{ $t('db_type') }}</div>
<v-input value="MySQL" disabled />
</div>
</div>
</div>
<div class="pane-buttons">
<v-button secondary @click="$emit('prev')">{{ $t('back') }}</v-button>
<v-button :disabled="nextEnabled === false" @click="$emit('next')">
{{ $t('create_project') }}
</v-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from '@vue/composition-api';
type DatabaseInfo = {
db_host: string | null;
db_name: string | null;
db_port: number | null;
db_user: string | null;
db_password: string | null;
};
export default defineComponent({
props: {
value: {
type: Object as PropType<DatabaseInfo>,
required: true,
},
},
setup(props, { emit }) {
const _value = computed<DatabaseInfo>({
get() {
return props.value;
},
set(newValue) {
emit('input', newValue);
},
});
const nextEnabled = computed<boolean>(() => {
const requiredKeys: (keyof DatabaseInfo)[] = ['db_host', 'db_name', 'db_port', 'db_user', 'db_password'];
return !!requiredKeys.every(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(key) => _value.value[key] && _value.value[key] !== ''
);
});
return { _value, nextEnabled };
},
});
</script>
<style lang="scss" scoped>
.v-input.db {
--v-input-font-family: var(--family-monospace);
}
</style>

View File

@@ -1,105 +0,0 @@
<template>
<div>
<div class="type-title pane-title">
<template v-if="loading">{{ $t('creating_project') }}</template>
<template v-else-if="error">{{ $t('creating_project_failed') }}</template>
<template v-else>{{ $t('creating_project_success') }}</template>
</div>
<div class="pane-content">
<v-progress-linear v-if="loading" indeterminate />
<template v-else>
<v-notice type="danger" v-if="error">
{{ errorFormatted }}
</v-notice>
<template v-else>{{ $t('creating_project_success_copy') }}</template>
<template v-if="first">
<v-notice type="warning">
{{ $t('creating_project_success_super_admin_password') }}
</v-notice>
<v-input readonly :value="_token" />
</template>
</template>
</div>
<div class="pane-buttons" v-if="!loading">
<v-button v-if="error" secondary @click="$emit('prev')">{{ $t('back') }}</v-button>
<v-button v-else @click="$emit('next')">{{ $t('sign_in') }}</v-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import api, { RequestError } from '@/api';
import { translateAPIError } from '@/lang';
import { nanoid } from 'nanoid';
type ProjectInfo = {
db_host: string | null;
db_name: string | null;
db_port: number | null;
db_user: string | null;
db_password: string | null;
};
export default defineComponent({
props: {
first: {
type: Boolean,
default: false,
},
database: {
type: Object,
required: true,
},
project: {
type: Object,
required: true,
},
token: {
type: String,
default: null,
},
},
setup(props) {
const loading = ref(true);
const error = ref<RequestError | null>(null);
const _token = computed(() => {
return props.token || nanoid();
});
const errorFormatted = computed(() => {
return error.value && translateAPIError(error.value);
});
createProject();
return { loading, error, errorFormatted, _token };
async function createProject() {
try {
await api.post('/server/projects', {
...props.database,
...props.project,
super_admin_token: _token.value,
});
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.v-notice {
margin: 24px 0;
}
</style>

View File

@@ -1,91 +0,0 @@
<template>
<div>
<div class="type-title pane-title">{{ $t('project_info') }}</div>
<div class="pane-content">
<div class="pane-form">
<div class="field">
<div class="type-label label">{{ $t('project_name') }}</div>
<v-input v-model="_value.project_name" />
</div>
<div class="field">
<div class="type-label label">{{ $t('project_key') }}</div>
<v-input slug v-model="_value.project" class="key" />
</div>
<div class="field">
<div class="type-label label">{{ $t('admin_email') }}</div>
<v-input type="email" v-model="_value.user_email" />
</div>
<div class="field">
<div class="type-label label">{{ $t('admin_password') }}</div>
<v-input type="password" v-model="_value.user_password" />
</div>
</div>
</div>
<div class="pane-buttons">
<v-button secondary @click="$emit('prev')">{{ $t('back') }}</v-button>
<v-button :disabled="nextEnabled === false" @click="$emit('next')">
{{ $t('next') }}
</v-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, watch } from '@vue/composition-api';
import slugify from '@sindresorhus/slugify';
type ProjectInfo = {
project_name: string | null;
project: string | null;
user_email: string | null;
user_password: string | null;
};
export default defineComponent({
props: {
value: {
type: Object as PropType<ProjectInfo>,
required: true,
},
},
setup(props, { emit }) {
const _value = computed<ProjectInfo>({
get() {
return props.value;
},
set(newValue) {
emit('input', newValue);
},
});
watch(
() => _value.value.project_name,
(newValue) => {
if (newValue) {
_value.value = {
..._value.value,
project: slugify(newValue),
};
}
}
);
const nextEnabled = computed<boolean>(() => {
const requiredKeys: (keyof ProjectInfo)[] = ['project_name', 'project', 'user_email', 'user_password'];
return !!requiredKeys.every(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(key) => _value.value[key] && _value.value[key]!.length > 0
);
});
return { _value, nextEnabled };
},
});
</script>
<style lang="scss" scoped>
.v-input.key {
--v-input-font-family: var(--family-monospace);
}
</style>

View File

@@ -1,147 +0,0 @@
<template>
<div class="requirements">
<div class="pane-title type-title">{{ $t('requirements') }}</div>
<div class="pane-content">
<div class="loader" v-if="loading">
<v-skeleton-loader v-for="n in 5" :key="n" />
</div>
<v-notice
v-else
v-for="requirement in requirements"
:key="requirement.key"
:type="requirement.success ? 'success' : 'warning'"
>
{{ requirement.value }}
</v-notice>
</div>
<div class="pane-buttons">
<v-button secondary @click="$emit('prev')">{{ $t('back') }}</v-button>
<v-button @click="$emit('next')">{{ $t('next') }}</v-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed } from '@vue/composition-api';
import api from '@/api';
import { satisfies } from 'semver';
import i18n from '@/lang';
type ServerInfo = {
server: {
type: string;
};
php: {
version: string;
extensions: { [extension: string]: boolean };
};
permissions: { [folder: string]: string };
directus: string;
};
export default defineComponent({
props: {
token: {
type: String,
default: undefined,
},
},
setup(props) {
const loading = ref(false);
const error = ref(null);
const serverInfo = ref<ServerInfo | null>(null);
const lastTag = ref<string | null>(null);
const requirements = computed(() => {
if (serverInfo.value === null) return null;
const phpVersion = serverInfo.value.php.version.split('-')[0];
const extensions = Object.keys(serverInfo.value.php.extensions).map((key) => ({
key,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
enabled: serverInfo.value!.php.extensions[key],
}));
const permissions = Object.keys(serverInfo.value?.permissions).map((key) => ({
key,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
permission: serverInfo.value!.permissions[key],
}));
const failedPermissions = permissions.filter((p) => +p.permission[1] !== 7);
return [
{
key: 'server',
success: serverInfo.value?.server.type.toLowerCase().includes('apache'),
value: serverInfo.value?.server.type,
},
{
key: 'php',
success: satisfies(phpVersion, '>=7.2.0'),
value: `PHP ${phpVersion}`,
},
{
key: 'extensions',
success: extensions.every((e) => e.enabled),
value: extensions.every((e) => e.enabled)
? i18n.t('php_extensions')
: i18n.t('missing_value', {
value: extensions.filter((e) => e.enabled === false).map((e) => e.key),
}),
},
{
key: 'permissions',
success: failedPermissions.length === 0,
value:
failedPermissions.length === 0
? i18n.t('write_access')
: i18n.t('value_not_writeable', {
value: failedPermissions.map((f) => `/${f.key}`).join(', '),
}),
},
{
key: 'version',
success: 'v' + serverInfo.value.directus === lastTag.value,
value: i18n.t('directus_version') + ': v' + serverInfo.value.directus,
},
];
});
getServerInfo();
return { loading, error, requirements };
async function getServerInfo() {
loading.value = true;
try {
const infoResponse = await api.get('/server/info', {
params: props.token
? {
super_admin_token: props.token,
}
: null,
});
const ghResponse = await api.get('https://api.github.com/repos/directus/directus/tags');
serverInfo.value = infoResponse.data.data;
lastTag.value = ghResponse.data[0].name;
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
},
});
</script>
<style lang="scss" scoped>
.v-notice,
.v-skeleton-loader {
margin-bottom: 12px;
}
</style>

View File

@@ -1,95 +0,0 @@
<template>
<div class="install-welcome" v-if="first">
<h1 class="pane-title type-title">{{ $t('welcome_to_directus') }}</h1>
<div class="pane-content">{{ $t('welcome_to_directus_copy') }}</div>
<div class="pane-buttons">
<v-button @click="$emit('next')">{{ $t('next') }}</v-button>
</div>
</div>
<div class="install-welcome" v-else>
<h1 class="pane-title type-title">{{ $t('create_new_project') }}</h1>
<div class="pane-content">
{{ $t('create_new_project_copy') }}
<v-input @input="setToken" :value="token" :placeholder="$t('super_admin_token')" class="token">
<template #append>
<v-progress-circular indeterminate v-if="verifying" />
<v-icon
v-else-if="tokenCorrect !== null"
:name="tokenCorrect ? 'check' : 'error'"
:class="{ correct: tokenCorrect }"
/>
</template>
</v-input>
</div>
<div class="pane-buttons">
<v-button :disabled="nextDisabled" @click="$emit('next')">{{ $t('next') }}</v-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref } from '@vue/composition-api';
import { debounce } from 'lodash';
import api from '@/api';
export default defineComponent({
model: {
prop: 'token',
},
props: {
first: {
type: Boolean,
default: false,
},
token: {
type: String,
default: null,
},
},
setup(props, { emit }) {
const verifying = ref(false);
const tokenCorrect = ref<boolean | null>(null);
const nextDisabled = computed(() => {
return (
props.token === null ||
props.token.length === 0 ||
tokenCorrect.value === false ||
tokenCorrect.value === null
);
});
const verifyToken = debounce(async (token: string) => {
verifying.value = true;
try {
await api.get(`/server/info?super_admin_token=${token}`);
tokenCorrect.value = true;
} catch {
tokenCorrect.value = false;
} finally {
verifying.value = false;
}
}, 500);
return { nextDisabled, verifyToken, tokenCorrect, verifying, setToken };
function setToken(token: string) {
emit('input', token);
verifyToken(token);
}
},
});
</script>
<style lang="scss" scoped>
.v-input {
margin-top: 32px;
}
.v-input.token {
--v-input-font-family: var(--family-monospace);
}
</style>

View File

@@ -1,131 +0,0 @@
<template>
<public-view class="install" :wide="['project', 'database'].includes(currentPane[0])">
<v-tabs-items v-model="currentPane">
<v-tab-item value="welcome">
<install-welcome :first="first" @next="nextPane" v-model="token" />
</v-tab-item>
<v-tab-item value="requirements">
<install-requirements :token="token" :first="first" @prev="prevPane" @next="nextPane" />
</v-tab-item>
<v-tab-item value="project">
<install-project :first="first" v-model="projectInfo" @prev="prevPane" @next="nextPane" />
</v-tab-item>
<v-tab-item value="database">
<install-database v-model="databaseInfo" :first="first" @prev="prevPane" @next="nextPane" />
</v-tab-item>
<v-tab-item value="final">
<install-final
:project="projectInfo"
:database="databaseInfo"
:first="first"
:token="token"
@prev="prevPane"
@next="finish"
/>
</v-tab-item>
</v-tabs-items>
</public-view>
</template>
<script lang="ts">
import { defineComponent, computed, ref, reactive } from '@vue/composition-api';
import InstallWelcome from './install-welcome.vue';
import InstallRequirements from './install-requirements.vue';
import InstallProject from './install-project.vue';
import InstallDatabase from './install-database.vue';
import InstallFinal from './install-final.vue';
import router from '@/router';
export default defineComponent({
components: {
InstallWelcome,
InstallRequirements,
InstallProject,
InstallDatabase,
InstallFinal,
},
setup() {
const first = computed(() => {
/**
* @todo remove difference between first or not (it's always first now)
*/
return true;
});
const panes = ['welcome', 'requirements', 'project', 'database', 'final'];
const currentPane = ref(['welcome']);
const token = ref(null);
const projectInfo = reactive({
project_name: null,
project: null,
user_email: null,
user_password: null,
});
const databaseInfo = reactive({
db_host: 'localhost',
db_name: null,
db_password: null,
db_port: 3306,
db_user: null,
});
return {
currentPane,
first,
prevPane,
nextPane,
projectInfo,
databaseInfo,
token,
finish,
};
function prevPane() {
const currentIndex = panes.findIndex((pane) => currentPane.value[0] === pane);
currentPane.value = [panes[currentIndex - 1]];
}
function nextPane() {
const currentIndex = panes.findIndex((pane) => currentPane.value[0] === pane);
currentPane.value = [panes[currentIndex + 1]];
}
async function finish() {
router.push('/');
}
},
});
</script>
<style lang="scss" scoped>
::v-deep {
.pane-title,
.pane-content {
margin-bottom: 32px;
}
.pane-buttons {
display: flex;
align-items: center;
.v-button {
margin-right: 12px;
}
}
.pane-form {
display: grid;
grid-gap: 32px 48px;
grid-template-columns: repeat(2, 1fr);
.label {
margin-bottom: 8px;
}
}
}
</style>

View File

@@ -3,6 +3,8 @@ import api from '@/api';
import notify from '@/utils/notify';
import i18n from '@/lang';
import emitter, { Events } from '@/events';
export default async function uploadFile(
file: File,
onProgressChange?: (percentage: number) => void,
@@ -24,7 +26,9 @@ export default async function uploadFile(
});
}
return response;
emitter.emit(Events.upload);
return response.data.data;
} catch (error) {
if (showNotifications) {
notify({

View File

@@ -8,7 +8,7 @@ export default async function uploadFiles(files: File[], onProgressChange?: (per
const progressForFiles = files.map(() => 0);
try {
await Promise.all(
const uploadedFiles = await Promise.all(
files.map((file, index) =>
uploadFile(
file,
@@ -20,10 +20,13 @@ export default async function uploadFiles(files: File[], onProgressChange?: (per
)
)
);
notify({
title: i18n.t('upload_files_success', { count: files.length }),
type: 'success',
});
return uploadedFiles;
} catch (error) {
notify({
title: i18n.t('upload_files_failed', { count: files.length }),

View File

@@ -9,6 +9,7 @@
:view-options.sync="options"
@update:selection="onSelect"
select-mode
class="layout"
/>
<template #footer>