Add improved collection organization setup (#8623)

* Add migrations, start on service

* Dont track TODO files

* Update collection types, add collection type

* Allow drag and drop sorting of collections

* Add tooltip

* Add grouping + collapsed state

* Fix nested closed state

* Tweak active drag styling

* Remove collapsed state

* Add folder creation/editing

* Render collections as nested tree in nav

* Fix open active state

* Add dense when collection count > 5

* Add visible toggle

* Add show-hidden toggle

* Fix css specificity

* Add support for query in v-list-group

* Add missing cascade

* Remove collapsed state

* Finish three-way toggle

* Add custom lock icon

* Fix icon size in non-dense

* Redirect to first & open tree on load

* Dont make prop required

* Fix search

* Only apply archive filter when enabled in settings

* Add archive view

* Add translations

* Hide organization fields

* Show system collections
This commit is contained in:
Rijk van Zanten
2021-10-15 17:19:00 -04:00
committed by GitHub
parent e41dfc1f36
commit 8f00e37daf
60 changed files with 1410 additions and 888 deletions

1
.gitignore vendored
View File

@@ -17,5 +17,6 @@ dist
*.tsbuildinfo
.e2e-containers.json
coverage
TODO
schema.yaml
schema.json

View File

@@ -0,0 +1,17 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_collections', (table) => {
table.integer('sort');
table.string('group', 64).references('collection').inTable('directus_collections').onDelete('SET NULL');
table.string('collapse').defaultTo('open').notNullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_collections', (table) => {
table.dropColumn('sort');
table.dropColumn('group');
table.dropColumn('collapse');
});
}

View File

@@ -62,7 +62,7 @@ fields:
template: '{{ translation }} ({{ language }})'
fields:
- field: language
name: $t:field_options.directus_collections.translation
name: $t:language
type: string
schema:
default_value: en-US
@@ -195,3 +195,12 @@ fields:
interface: system-field-tree
options:
collectionField: collection
- field: sort
hidden: true
- field: group
hidden: true
- field: collapse
hidden: true

View File

@@ -11,10 +11,12 @@ import { ItemsService, MutationOptions } from '../services/items';
import Keyv from 'keyv';
import { AbstractServiceOptions, Collection, CollectionMeta, SchemaOverview } from '../types';
import { Accountability, FieldMeta, RawField } from '@directus/shared/types';
import { Table } from 'knex-schema-inspector/dist/types/table';
export type RawCollection = {
collection: string;
fields?: RawField[];
schema?: Partial<Table> | null;
meta?: Partial<CollectionMeta> | null;
};
@@ -47,83 +49,90 @@ export class CollectionsService {
if (!payload.collection) throw new InvalidPayloadException(`"collection" is required`);
// Directus heavily relies on the primary key of a collection, so we have to make sure that
// every collection that is created has a primary key. If no primary key field is created
// while making the collection, we default to an auto incremented id named `id`
if (!payload.fields)
payload.fields = [
{
field: 'id',
type: 'integer',
meta: {
hidden: true,
interface: 'numeric',
readonly: true,
},
schema: {
is_primary_key: true,
has_auto_increment: true,
},
},
];
if (payload.collection.startsWith('directus_')) {
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
}
// Ensure that every field meta has the field/collection fields filled correctly
payload.fields = payload.fields.map((field) => {
if (field.meta) {
field.meta = {
...field.meta,
field: field.field,
collection: payload.collection!,
};
}
const existingCollections: string[] = [
...((await this.knex.select('collection').from('directus_collections'))?.map(({ collection }) => collection) ??
[]),
...Object.keys(this.schema.collections),
];
return field;
});
if (existingCollections.includes(payload.collection)) {
throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`);
}
// Create the collection/fields in a transaction so it'll be reverted in case of errors or
// permission problems. This might not work reliably in MySQL, as it doesn't support DDL in
// transactions.
await this.knex.transaction(async (trx) => {
const fieldsService = new FieldsService({ knex: trx, schema: this.schema });
if (payload.meta) {
const collectionItemsService = new ItemsService('directus_collections', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
// This operation is locked to admin users only, so we don't have to worry about the order
// of operations here with regards to permissions checks
const collectionItemsService = new ItemsService('directus_collections', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
const fieldItemsService = new ItemsService('directus_fields', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
if (payload.collection.startsWith('directus_')) {
throw new InvalidPayloadException(`Collections can't start with "directus_"`);
await collectionItemsService.createOne({
...payload.meta,
collection: payload.collection,
});
}
if (payload.collection in this.schema.collections) {
throw new InvalidPayloadException(`Collection "${payload.collection}" already exists.`);
}
if (payload.schema) {
const fieldsService = new FieldsService({ knex: trx, schema: this.schema });
await trx.schema.createTable(payload.collection, (table) => {
for (const field of payload.fields!) {
if (field.type && ALIAS_TYPES.includes(field.type) === false) {
fieldsService.addColumnToTable(table, field);
const fieldItemsService = new ItemsService('directus_fields', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
// Directus heavily relies on the primary key of a collection, so we have to make sure that
// every collection that is created has a primary key. If no primary key field is created
// while making the collection, we default to an auto incremented id named `id`
if (!payload.fields)
payload.fields = [
{
field: 'id',
type: 'integer',
meta: {
hidden: true,
interface: 'numeric',
readonly: true,
},
schema: {
is_primary_key: true,
has_auto_increment: true,
},
},
];
// Ensure that every field meta has the field/collection fields filled correctly
payload.fields = payload.fields.map((field) => {
if (field.meta) {
field.meta = {
...field.meta,
field: field.field,
collection: payload.collection!,
};
}
}
});
await collectionItemsService.createOne({
...(payload.meta || {}),
collection: payload.collection,
});
return field;
});
const fieldPayloads = payload.fields!.filter((field) => field.meta).map((field) => field.meta) as FieldMeta[];
await fieldItemsService.createMany(fieldPayloads);
await trx.schema.createTable(payload.collection, (table) => {
for (const field of payload.fields!) {
if (field.type && ALIAS_TYPES.includes(field.type) === false) {
fieldsService.addColumnToTable(table, field);
}
}
});
const fieldPayloads = payload.fields!.filter((field) => field.meta).map((field) => field.meta) as FieldMeta[];
await fieldItemsService.createMany(fieldPayloads);
}
return payload.collection;
});
@@ -183,6 +192,12 @@ export class CollectionsService {
let tablesInDatabase = await this.schemaInspector.tableInfo();
let meta = (await collectionItemsService.readByQuery({
limit: -1,
})) as CollectionMeta[];
meta.push(...systemCollectionRows);
if (this.accountability && this.accountability.admin !== true) {
const collectionsYouHavePermissionToRead: string[] = this.schema.permissions
.filter((permission) => {
@@ -193,36 +208,33 @@ export class CollectionsService {
tablesInDatabase = tablesInDatabase.filter((table) => {
return collectionsYouHavePermissionToRead.includes(table.name);
});
meta = meta.filter((collectionMeta) => {
return collectionsYouHavePermissionToRead.includes(collectionMeta.collection);
});
}
const tablesToFetchInfoFor = tablesInDatabase.map((table) => table.name);
const meta = (await collectionItemsService.readByQuery({
filter: { collection: { _in: tablesToFetchInfoFor } },
limit: -1,
})) as CollectionMeta[];
meta.push(...systemCollectionRows);
const collections: Collection[] = [];
/**
* The collections as known in the schema cache.
*/
const knownCollections = Object.keys(this.schema.collections);
for (const table of tablesInDatabase) {
for (const collectionMeta of meta) {
const collection: Collection = {
collection: table.name,
meta: meta.find((systemInfo) => systemInfo?.collection === table.name) || null,
schema: table,
collection: collectionMeta.collection,
meta: collectionMeta,
schema: tablesInDatabase.find((table) => table.name === collectionMeta.collection) ?? null,
};
// By only returning collections that are known in the schema cache, we prevent weird
// situations where the collections endpoint returns different info from every other
// collection
if (knownCollections.includes(table.name)) {
collections.push(collection);
collections.push(collection);
}
for (const table of tablesInDatabase) {
const exists = !!collections.find(({ collection }) => collection === table.name);
if (!exists) {
collections.push({
collection: table.name,
schema: table,
meta: null,
});
}
}
@@ -241,12 +253,6 @@ export class CollectionsService {
* Read many collections by name
*/
async readMany(collectionKeys: string[]): Promise<Collection[]> {
const collectionItemsService = new ItemsService('directus_collections', {
knex: this.knex,
accountability: this.accountability,
schema: this.schema,
});
if (this.accountability && this.accountability.admin !== true) {
const permissions = this.schema.permissions.filter((permission) => {
return permission.action === 'read' && collectionKeys.includes(permission.collection);
@@ -263,36 +269,8 @@ export class CollectionsService {
}
}
const tablesInDatabase = await this.schemaInspector.tableInfo();
const tables = tablesInDatabase.filter((table) => collectionKeys.includes(table.name));
const meta = (await collectionItemsService.readByQuery({
filter: { collection: { _in: collectionKeys } },
limit: -1,
})) as CollectionMeta[];
meta.push(...systemCollectionRows);
const collections: Collection[] = [];
const knownCollections = Object.keys(this.schema.collections);
for (const table of tables) {
const collection: Collection = {
collection: table.name,
meta: meta.find((systemInfo) => systemInfo?.collection === table.name) || null,
schema: table,
};
// By only returning collections that are known in the schema cache, we prevent weird
// situations where the collections endpoint returns different info from every other
// collection
if (knownCollections.includes(table.name)) {
collections.push(collection);
}
}
return collections;
const collections = await this.readByQuery();
return collections.filter(({ collection }) => collectionKeys.includes(collection));
}
/**
@@ -378,72 +356,82 @@ export class CollectionsService {
throw new ForbiddenException();
}
const tablesInDatabase = Object.keys(this.schema.collections);
const collections = await this.readByQuery();
if (tablesInDatabase.includes(collectionKey) === false) {
const collectionToBeDeleted = collections.find((collection) => collection.collection === collectionKey);
if (!!collectionToBeDeleted === false) {
throw new ForbiddenException();
}
await this.knex.transaction(async (trx) => {
const collectionItemsService = new ItemsService('directus_collections', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
if (collectionToBeDeleted!.meta) {
const collectionItemsService = new ItemsService('directus_collections', {
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
const fieldsService = new FieldsService({
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
await trx('directus_fields').delete().where('collection', '=', collectionKey);
await trx('directus_presets').delete().where('collection', '=', collectionKey);
const revisionsToDelete = await trx.select('id').from('directus_revisions').where({ collection: collectionKey });
if (revisionsToDelete.length > 0) {
const keys = revisionsToDelete.map((record) => record.id);
await trx('directus_revisions').update({ parent: null }).whereIn('parent', keys);
await collectionItemsService.deleteOne(collectionKey);
}
await trx('directus_revisions').delete().where('collection', '=', collectionKey);
if (collectionToBeDeleted!.schema) {
const fieldsService = new FieldsService({
knex: trx,
accountability: this.accountability,
schema: this.schema,
});
await trx('directus_activity').delete().where('collection', '=', collectionKey);
await trx('directus_permissions').delete().where('collection', '=', collectionKey);
await trx('directus_relations').delete().where({ many_collection: collectionKey });
await trx('directus_fields').delete().where('collection', '=', collectionKey);
await trx('directus_presets').delete().where('collection', '=', collectionKey);
const relations = this.schema.relations.filter((relation) => {
return relation.collection === collectionKey || relation.related_collection === collectionKey;
});
const revisionsToDelete = await trx
.select('id')
.from('directus_revisions')
.where({ collection: collectionKey });
for (const relation of relations) {
// Delete related o2m fields that point to current collection
if (relation.related_collection && relation.meta?.one_field) {
await fieldsService.deleteField(relation.related_collection, relation.meta.one_field);
if (revisionsToDelete.length > 0) {
const keys = revisionsToDelete.map((record) => record.id);
await trx('directus_revisions').update({ parent: null }).whereIn('parent', keys);
}
// Delete related m2o fields that point to current collection
if (relation.related_collection === collectionKey) {
await fieldsService.deleteField(relation.collection, relation.field);
await trx('directus_revisions').delete().where('collection', '=', collectionKey);
await trx('directus_activity').delete().where('collection', '=', collectionKey);
await trx('directus_permissions').delete().where('collection', '=', collectionKey);
await trx('directus_relations').delete().where({ many_collection: collectionKey });
const relations = this.schema.relations.filter((relation) => {
return relation.collection === collectionKey || relation.related_collection === collectionKey;
});
for (const relation of relations) {
// Delete related o2m fields that point to current collection
if (relation.related_collection && relation.meta?.one_field) {
await fieldsService.deleteField(relation.related_collection, relation.meta.one_field);
}
// Delete related m2o fields that point to current collection
if (relation.related_collection === collectionKey) {
await fieldsService.deleteField(relation.collection, relation.field);
}
}
const m2aRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => {
return relation.meta?.one_allowed_collections?.includes(collectionKey);
});
for (const relation of m2aRelationsThatIncludeThisCollection) {
const newAllowedCollections = relation
.meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection)
.join(',');
await trx('directus_relations')
.update({ one_allowed_collections: newAllowedCollections })
.where({ id: relation.meta!.id });
}
await trx.schema.dropTable(collectionKey);
}
const m2aRelationsThatIncludeThisCollection = this.schema.relations.filter((relation) => {
return relation.meta?.one_allowed_collections?.includes(collectionKey);
});
for (const relation of m2aRelationsThatIncludeThisCollection) {
const newAllowedCollections = relation
.meta!.one_allowed_collections!.filter((collection) => collectionKey !== collection)
.join(',');
await trx('directus_relations')
.update({ one_allowed_collections: newAllowedCollections })
.where({ id: relation.meta!.id });
}
await collectionItemsService.deleteOne(collectionKey);
await trx.schema.dropTable(collectionKey);
});
if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) {

View File

@@ -16,5 +16,5 @@ export type Collection = {
collection: string;
fields?: Field[];
meta: CollectionMeta | null;
schema: Table;
schema: Table | null;
};

View File

@@ -45,7 +45,8 @@
import { useI18n } from 'vue-i18n';
import { defineComponent, toRefs, ref, PropType, computed } from 'vue';
import FieldListItem from '../v-field-template/field-list-item.vue';
import { Field, Collection, Relation } from '@directus/shared/types';
import { Field, Relation } from '@directus/shared/types';
import { Collection } from '@/types';
import Draggable from 'vuedraggable';
import { useCollection } from '@directus/shared/composables';
import { FieldTree } from '../v-field-template/types';

View File

@@ -0,0 +1,27 @@
<template>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill="none"
fill-rule="evenodd"
clip-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.1875 6L11.1562 8.01562H20.0156V18H3.98438V6H9.1875ZM9.98438 3.98438H3.98438C2.90625 3.98438 2.01562 4.92188 2.01562 6V18C2.01562 19.0781 2.90625 20.0156 3.98438 20.0156H20.0156C21.0938 20.0156 21.9844 19.0781 21.9844 18V8.01562C21.9844 6.89062 21.0938 6 20.0156 6H12L9.98438 3.98438Z"
/>
<rect x="13" y="12" width="6" height="5" rx="1" />
<rect x="17" y="11" width="1" height="1" />
<rect x="14" y="11" width="1" height="1" />
<path
d="M17 11H18C18 9.89543 17.1046 9 16 9C14.8954 9 14 9.89543 14 11H15C15 10.4477 15.4477 10 16 10C16.5523 10 17 10.4477 17 11Z"
/>
</svg>
</template>
<script lang="ts">
export default {};
</script>

View File

@@ -32,6 +32,7 @@ import CustomIconSignalWifi3Bar from './custom-icons/signal_wifi_3_bar.vue';
import CustomIconFlipHorizontal from './custom-icons/flip_horizontal.vue';
import CustomIconFlipVertical from './custom-icons/flip_vertical.vue';
import CustomIconFolderMove from './custom-icons/folder_move.vue';
import CustomIconFolderLock from './custom-icons/folder_lock.vue';
import CustomIconLogout from './custom-icons/logout.vue';
const customIcons: string[] = [
@@ -51,6 +52,7 @@ const customIcons: string[] = [
'flip_horizontal',
'flip_vertical',
'folder_move',
'folder_lock',
'logout',
];
@@ -72,6 +74,7 @@ export default defineComponent({
CustomIconFlipHorizontal,
CustomIconFlipVertical,
CustomIconFolderMove,
CustomIconFolderLock,
CustomIconLogout,
},
props: {

View File

@@ -5,13 +5,14 @@
:active="active"
:to="to"
:exact="exact"
:query="query"
:disabled="disabled"
:dense="dense"
clickable
@click="onClick"
>
<v-list-item-icon
v-if="$slots.default && arrowPlacement === 'before'"
v-if="$slots.default && arrowPlacement && arrowPlacement === 'before'"
class="activator-icon"
:class="{ active: groupActive }"
>
@@ -21,7 +22,7 @@
<slot name="activator" :active="groupActive" />
<v-list-item-icon
v-if="$slots.default && arrowPlacement === 'after'"
v-if="$slots.default && arrowPlacement && arrowPlacement === 'after'"
class="activator-icon"
:class="{ active: groupActive }"
>
@@ -51,12 +52,16 @@ export default defineComponent({
},
active: {
type: Boolean,
default: false,
default: undefined,
},
exact: {
type: Boolean,
default: false,
},
query: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
@@ -82,9 +87,9 @@ export default defineComponent({
default: false,
},
arrowPlacement: {
type: String,
type: [String, Boolean],
default: 'after',
validator: (val: string) => ['before', 'after'].includes(val),
validator: (val: string | boolean) => ['before', 'after', false].includes(val),
},
},
emits: ['click'],
@@ -133,7 +138,7 @@ export default defineComponent({
}
.items {
padding-left: 16px;
padding-left: 18px;
list-style: none;
}
}

View File

@@ -41,8 +41,8 @@ body {
margin-bottom: 2px;
}
.v-list:not(.large) .v-list-item-content,
.v-list-item:not(.large) .v-list-item-content {
.v-list:not(.nav) .v-list-item-content,
.v-list-item:not(.nav) .v-list-item-content {
--v-list-item-content-padding: 4px 0;
}
</style>

View File

@@ -54,7 +54,7 @@ export default defineComponent({
}
}
&.large {
&.nav {
&.three-line,
&.two-line {
#{$this} {

View File

@@ -61,8 +61,12 @@ body {
}
}
&.large #{$this} :slotted(.v-icon) {
&.nav #{$this} :slotted(.v-icon) {
--v-icon-color: none;
&.dense {
--v-icon-size: 18px;
}
}
&.disabled #{$this} :slotted(.v-icon) {

View File

@@ -10,7 +10,8 @@
disabled,
dashed,
block,
large,
nav,
clickable,
}"
:href="href"
:download="download"
@@ -77,7 +78,7 @@ export default defineComponent({
type: [String, Number],
default: undefined,
},
large: {
nav: {
type: Boolean,
default: false,
},
@@ -128,13 +129,13 @@ export default defineComponent({
<style>
body {
--v-list-item-padding-large: 0 8px;
--v-list-item-padding-nav: 0 8px;
--v-list-item-padding: 0 8px 0 calc(8px + var(--v-list-item-indent, 0px));
--v-list-item-margin-large: 2px 0;
--v-list-item-margin-nav: 2px 0;
--v-list-item-margin: 2px 0;
--v-list-item-min-width: none;
--v-list-item-max-width: none;
--v-list-item-min-height-large: 36px;
--v-list-item-min-height-nav: 36px;
--v-list-item-min-height: 32px;
--v-list-item-max-height: auto;
--v-list-item-border-radius: var(--border-radius);
@@ -256,7 +257,7 @@ body {
flex-grow: 1;
}
&:hover {
&.clickable:hover {
background-color: var(--v-list-item-background-color-hover);
border: var(--border-width) solid var(--v-list-item-border-color-hover);
}
@@ -280,13 +281,13 @@ body {
}
@at-root {
.v-list.large {
.v-list.nav {
#{$this}:not(.dense) {
--v-list-item-min-height: var(--v-list-item-min-height-large);
--v-list-item-min-height: var(--v-list-item-min-height-nav);
--v-list-item-border-radius: 4px;
margin: var(--v-list-item-margin-large);
padding: var(--v-list-item-padding-large);
margin: var(--v-list-item-margin-nav);
padding: var(--v-list-item-padding-nav);
&:first-child {
margin-top: 0;
@@ -302,6 +303,12 @@ body {
}
}
}
.v-list.nav.dense {
#{$this}:not(.dense) {
--v-list-item-min-height: 32px;
}
}
}
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<ul class="v-list" :class="{ large }">
<ul class="v-list" :class="{ nav, dense }">
<slot />
</ul>
</template>
@@ -14,7 +14,11 @@ export default defineComponent({
type: Array as PropType<(number | string)[]>,
default: null,
},
large: {
nav: {
type: Boolean,
default: false,
},
dense: {
type: Boolean,
default: false,
},
@@ -26,10 +30,15 @@ export default defineComponent({
type: Boolean,
default: true,
},
scope: {
type: String,
default: 'v-list',
},
},
emits: ['update:modelValue', 'toggle'],
setup(props, { emit }) {
const { modelValue, multiple, mandatory } = toRefs(props);
useGroupableParent(
{
selection: modelValue,
@@ -43,7 +52,8 @@ export default defineComponent({
{
mandatory,
multiple,
}
},
props.scope
);
return {};
@@ -81,7 +91,7 @@ export default defineComponent({
border-radius: var(--v-list-border-radius);
}
.large {
.nav {
--v-list-padding: 12px;
}
@@ -89,8 +99,4 @@ export default defineComponent({
max-width: calc(100% - 16px);
margin: 8px;
}
/* :slotted(*) {
pointer-events: all;
} */
</style>

View File

@@ -1,5 +1,5 @@
<template>
<v-list v-if="vertical" class="v-tabs vertical alt-colors" large>
<v-list v-if="vertical" class="v-tabs vertical alt-colors" nav>
<slot />
</v-list>
<div v-else class="v-tabs horizontal">

View File

@@ -1,6 +1,7 @@
<template>
<div ref="el" v-tooltip="hasEllipsis && text" class="v-text-overflow">
{{ text }}
<v-highlight v-if="highlight" :query="highlight" :text="text" />
<template v-else>{{ text }}</template>
</div>
</template>
@@ -14,6 +15,10 @@ export default defineComponent({
type: [String, Number, Array, Object, Boolean],
required: true,
},
highlight: {
type: String,
default: null,
},
},
setup() {
const el = ref<HTMLElement>();

View File

@@ -48,13 +48,20 @@ export function useGroupable(options?: GroupableOptions): UsableGroupable {
register,
unregister,
toggle,
selection,
}: {
register: (item: GroupableInstance) => void;
unregister: (item: GroupableInstance) => void;
toggle: (item: GroupableInstance) => void;
selection: Ref<(number | string)[]>;
} = parentFunctions;
const active = ref(options?.active?.value === true ? true : false);
let startActive = false;
if (options?.active?.value === true) startActive = true;
if (options?.value && selection.value.includes(options.value)) startActive = true;
const active = ref(startActive);
const item = { active, value: options?.value };
register(item);
@@ -148,11 +155,11 @@ export function useGroupableParent(
// Provide the needed functions to all children groupable components. Note: nested item groups
// will override the item-group namespace, making nested item groups possible.
provide(group, { register, unregister, toggle });
provide(group, { register, unregister, toggle, selection });
// Whenever the value of the selection changes, we have to update all the children's internal
// states. If not, you can have an activated item that's not actually active.
watch(selection, updateChildren);
watch(selection, updateChildren, { immediate: true });
// It takes a tick before all children are rendered, this will make sure the start state of the
// children matches the start selection

View File

@@ -1,6 +1,7 @@
import { useCollection } from '@directus/shared/composables';
import { useCollectionsStore, useRelationsStore } from '@/stores/';
import { Field, Collection, Relation } from '@directus/shared/types';
import { Field, Relation } from '@directus/shared/types';
import { Collection } from '@/types';
import { computed, ComputedRef, Ref } from 'vue';
export type RelationInfo = {

View File

@@ -1,5 +1,5 @@
import api from '@/api';
import { Collection } from '@directus/shared/types';
import { Collection } from '@/types';
import { getFieldsFromTemplate } from '@directus/shared/utils';
import { computed, Ref, ref, watch } from 'vue';

View File

@@ -140,7 +140,8 @@
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, PropType, ref, watch } from 'vue';
import { useRelationsStore, useCollectionsStore, useFieldsStore } from '@/stores';
import { Collection, Relation } from '@directus/shared/types';
import { Relation } from '@directus/shared/types';
import { Collection } from '@/types';
import DrawerCollection from '@/views/private/components/drawer-collection/';
import DrawerItem from '@/views/private/components/drawer-item/';
import api from '@/api';

View File

@@ -30,7 +30,8 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { Field, Collection, Relation } from '@directus/shared/types';
import { Field, Relation } from '@directus/shared/types';
import { Collection } from '@/types';
import { defineComponent, PropType, computed } from 'vue';
import { useCollectionsStore } from '@/stores';
export default defineComponent({

View File

@@ -30,7 +30,8 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { Field, Collection, Relation } from '@directus/shared/types';
import { Field, Relation } from '@directus/shared/types';
import { Collection } from '@/types';
import { defineComponent, PropType, computed } from 'vue';
import { useCollectionsStore } from '@/stores/';

View File

@@ -1,7 +1,7 @@
<template>
<div class="repeater">
<v-notice v-if="!value || value.length === 0">
{{ t('no_items') }}
{{ placeholder }}
</v-notice>
<v-list v-if="value && value.length > 0">
@@ -118,6 +118,10 @@ export default defineComponent({
type: String,
default: null,
},
placeholder: {
type: String,
default: () => i18n.global.t('no_items'),
},
},
emits: ['input'],
setup(props, { emit }) {

View File

@@ -60,11 +60,18 @@ import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css';
import { ButtonControl } from '@/utils/geometry/controls';
import { Geometry } from 'geojson';
import { flatten, getBBox, getParser, getSerializer, getGeometryFormatForType } from '@/utils/geometry';
import { GeoJSONParser, GeoJSONSerializer, SimpleGeometry, MultiGeometry } from '@directus/shared/types';
import {
Field,
GeometryType,
GeometryFormat,
GeoJSONParser,
GeoJSONSerializer,
SimpleGeometry,
MultiGeometry,
} from '@directus/shared/types';
import getSetting from '@/utils/get-setting';
import { snakeCase, isEqual } from 'lodash';
import styles from './style';
import { Field, GeometryType, GeometryFormat } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
import { TranslateResult } from 'vue-i18n';
import { useAppStore } from '@/stores';

View File

@@ -51,7 +51,7 @@ indeterminate: Indeterminate
edit_collection: Edit Collection
exclusive: Exclusive
children: Children
db_only_click_to_configure: 'Database Only: Click to Configure '
db_only_click_to_configure: 'Database Only: Click to Configure'
show_archived_items: Show Archived Items
edited: Value Edited
required: Required
@@ -257,6 +257,7 @@ invalid_item: Invalid Item
next: Next
field_name: Field Name
translations: Translations
no_translations: No Translations
note: Note
enter_a_value: Enter a value...
enter_a_placeholder: Enter a placeholder...
@@ -531,6 +532,8 @@ all_users: All Users
delete_collection: Delete Collection
update_collection_success: Updated Collection
delete_collection_success: Deleted Collection
make_collection_visible: Make Collection Visible
make_collection_hidden: Make Collection Hidden
start_end_of_count_items: '{start}-{end} of {count} items'
start_end_of_count_filtered_items: '{start}-{end} of {count} filtered items'
one_item: '1 item'
@@ -621,7 +624,9 @@ all_files: All Files
my_files: My Files
recent_files: Recent Files
create_folder: Create Folder
edit_folder: Edit Folder
folder_name: Folder Name...
folder_key: Folder Key...
add_file: Add File
replace_file: Replace File
no_results: No Results
@@ -1091,6 +1096,9 @@ generate_and_save_uuid: Generate and Save UUID
save_current_user_id: Save Current User ID
save_current_user_role: Save Current User Role
save_current_datetime: Save Current Date/Time
always_open: Always Open
start_open: Start Open
start_collapsed: Start Collapsed
block: Block
inline: Inline
comment: Comment

View File

@@ -82,7 +82,8 @@ import CardsHeader from './components/header.vue';
import useElementSize from '@/composables/use-element-size';
import { Field, Item } from '@directus/shared/types';
import { useSync } from '@directus/shared/composables';
import { Collection, Filter } from '@directus/shared/types';
import { Collection } from '@/types';
import { Filter } from '@directus/shared/types';
export default defineComponent({
components: { Card, CardsHeader },

View File

@@ -84,7 +84,8 @@ import { useI18n } from 'vue-i18n';
import { ComponentPublicInstance, defineComponent, PropType, ref, inject, Ref, watch } from 'vue';
import { useSync } from '@directus/shared/composables';
import useShortcut from '@/composables/use-shortcut';
import { Field, Item, Collection, Filter } from '@directus/shared/types';
import { Collection } from '@/types';
import { Field, Item, Filter } from '@directus/shared/types';
import { HeaderRaw } from '@/components/v-table/types';
export default defineComponent({

View File

@@ -1,5 +1,5 @@
<template>
<v-list large>
<v-list nav>
<v-list-item clickable :active="!filterField" @click="clearNavFilter">
<v-list-item-icon>
<v-icon name="access_time" />

View File

@@ -1,12 +1,15 @@
<template>
<v-list-item :to="bookmark.to" query class="bookmark" @contextmenu.prevent.stop="activateContextMenu">
<v-list-item
:to="`/collections/${bookmark.collection}?bookmark=${bookmark.id}`"
query
class="bookmark"
clickable
@contextmenu.prevent.stop="activateContextMenu"
>
<v-list-item-icon><v-icon name="bookmark_outline" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="bookmark.bookmark" />
</v-list-item-content>
<v-list-item-icon v-if="bookmark.scope !== 'user'" class="bookmark-scope">
<v-icon :name="bookmark.scope === 'role' ? 'people' : 'public'" />
</v-list-item-icon>
<v-menu ref="contextMenu" show-arrow placement="bottom-start">
<v-list>
@@ -167,17 +170,6 @@ export default defineComponent({
</script>
<style lang="scss" scoped>
.bookmark-scope {
--v-icon-color: var(--foreground-subdued);
opacity: 0;
transition: opacity var(--fast) var(--transition);
}
.bookmark:hover .bookmark-scope {
opacity: 1;
}
.danger {
--v-list-item-color: var(--danger);
--v-list-item-icon-color: var(--danger);

View File

@@ -0,0 +1,31 @@
<template>
<v-list-item-icon>
<v-icon :name="icon ?? 'label'" :color="color" />
</v-list-item-icon>
<v-list-item-content><v-text-overflow :text="name" :highlight="search" /></v-list-item-content>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
props: {
icon: {
type: String,
default: 'label',
},
color: {
type: String,
default: 'var(--foreground-normal)',
},
name: {
type: String,
required: true,
},
search: {
type: String,
default: null,
},
},
});
</script>

View File

@@ -0,0 +1,175 @@
<template>
<v-list-group
v-if="isGroup && matchesSearch"
:to="to"
scope="collections-navigation"
:value="collection.collection"
query
:arrow-placement="collection.meta?.collapse === 'locked' ? false : 'after'"
@contextmenu="activateContextMenu"
>
<template #activator>
<navigation-item-content
:search="search"
:name="collection.name"
:icon="collection.meta?.icon"
:color="collection.meta?.color"
/>
</template>
<navigation-item
v-for="collection in childCollections"
:key="collection.collection"
:collection="collection"
:search="search"
/>
<navigation-bookmark v-for="bookmark in childBookmarks" :key="bookmark.id" :bookmark="bookmark" />
</v-list-group>
<v-list-item
v-else-if="matchesSearch"
:to="to"
:value="collection.collection"
:class="{ hidden: collection.meta?.hidden }"
query
@contextmenu="activateContextMenu"
>
<navigation-item-content
:search="search"
:name="collection.name"
:icon="collection.meta?.icon"
:color="collection.meta?.color"
/>
</v-list-item>
<v-menu ref="contextMenu" show-arrow placement="bottom-start">
<v-list>
<v-list-item clickable :to="`/collections/${collection.collection}?archive`" exact query>
<v-list-item-icon>
<v-icon name="archive" outline />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('show_archived_items')" />
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref } from 'vue';
import { Collection } from '@/types';
import { Preset } from '@directus/shared/types';
import { useCollectionsStore, usePresetsStore } from '@/stores';
import NavigationItemContent from './navigation-item-content.vue';
import NavigationBookmark from './navigation-bookmark.vue';
import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'NavigationItem',
components: { NavigationItemContent, NavigationBookmark },
props: {
collection: {
type: Object as PropType<Collection>,
required: true,
},
showHidden: {
type: Boolean,
default: false,
},
search: {
type: String,
default: null,
},
},
setup(props) {
const { t } = useI18n();
const collectionsStore = useCollectionsStore();
const presetsStore = usePresetsStore();
const childCollections = computed(() => getChildCollections(props.collection));
const childBookmarks = computed(() => getChildBookmarks(props.collection));
const contextMenu = ref();
const hasArchive = computed(
() => props.collection.meta?.archive_field && props.collection.meta?.archive_app_filter
);
const isGroup = computed(() => childCollections.value.length > 0 || childBookmarks.value.length > 0);
const to = computed(() => (props.collection.schema ? `/collections/${props.collection.collection}` : ''));
const matchesSearch = computed(() => {
if (!props.search || props.search.length < 3) return true;
const searchQuery = props.search.toLowerCase();
return matchesSearch(props.collection) || childrenMatchSearch(childCollections.value, childBookmarks.value);
function childrenMatchSearch(collections: Collection[], bookmarks: Preset[]): boolean {
return (
collections.some((collection) => {
const childCollections = getChildCollections(collection);
const childBookmarks = getChildBookmarks(collection);
return matchesSearch(collection) || childrenMatchSearch(childCollections, childBookmarks);
}) || bookmarks.some((bookmark) => bookmarkMatchesSearch(bookmark))
);
}
function matchesSearch(collection: Collection) {
return collection.collection.includes(searchQuery) || collection.name.toLowerCase().includes(searchQuery);
}
function bookmarkMatchesSearch(bookmark: Preset) {
return bookmark.bookmark?.toLowerCase().includes(searchQuery);
}
});
return {
childCollections,
childBookmarks,
isGroup,
to,
matchesSearch,
contextMenu,
activateContextMenu,
t,
hasArchive,
};
function getChildCollections(collection: Collection) {
let collections = collectionsStore.collections.filter(
(childCollection) => childCollection.meta?.group === collection.collection
);
if (props.showHidden === false) {
collections = collections.filter((collection) => collection.meta?.hidden !== true);
}
return collections;
}
function getChildBookmarks(collection: Collection) {
return presetsStore.bookmarks.filter((bookmark) => bookmark.collection === collection.collection);
}
function activateContextMenu(event: PointerEvent) {
if (hasArchive.value) {
contextMenu.value.activate(event);
event.stopPropagation();
event.preventDefault();
}
}
},
});
</script>
<style scoped>
.hidden {
--v-list-item-color: var(--foreground-subdued);
}
</style>

View File

@@ -1,56 +0,0 @@
<template>
<div v-show="searchQuery !== null || visible > 20" class="container">
<v-input v-model="searchQuery" small class="search" :placeholder="t('search_collection')">
<template #prepend><v-icon small name="search" /></template>
</v-input>
</div>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent } from 'vue';
import { useSearch } from '../composables/use-search';
export default defineComponent({
setup() {
const { t } = useI18n();
const { visible, searchQuery } = useSearch();
return { t, visible, searchQuery };
},
});
</script>
<style lang="scss" scoped>
.container {
position: sticky;
top: 0px;
left: 0px;
z-index: 10;
display: flex;
align-items: center;
width: 100%;
height: 64px;
padding: 0 12px;
background-color: var(--background-normal);
&::after {
position: absolute;
bottom: -2px;
left: 12px;
width: calc(100% - 24px);
height: 2px;
background-color: var(--border-normal);
content: '';
}
}
.v-input {
height: 40px;
:deep(.input) {
border: none;
}
}
</style>

View File

@@ -1,211 +1,93 @@
<template>
<v-list
ref="listComponent"
large
class="collections-navigation"
:mandatory="false"
@contextmenu.prevent.stop="activateContextMenu"
>
<template v-if="customNavItems && customNavItems.length > 0">
<template v-for="(group, index) in customNavItems" :key="group.name">
<template
v-if="(group.name === undefined || group.name === null) && group.accordion === 'always_open' && index === 0"
>
<v-list-item v-for="navItem in group.items" :key="navItem.to" :to="navItem.to" query>
<v-list-item-icon><v-icon :name="navItem.icon" :color="navItem.color" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="navItem.name" />
</v-list-item-content>
</v-list-item>
</template>
<template v-else>
<v-detail
:model-value="group.accordion === 'always_open' || isActive(group.name)"
:disabled="group.accordion === 'always_open'"
:start-open="group.accordion === 'start_open'"
:label="group.name || null"
:class="{ empty: group.name === null || group.name === undefined }"
@update:model-value="toggleActive(group.name)"
>
<v-list-item v-for="navItem in group.items" :key="navItem.to" :to="navItem.to" query>
<v-list-item-icon><v-icon :name="navItem.icon" :color="navItem.color" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="navItem.name" />
</v-list-item-content>
</v-list-item>
</v-detail>
</template>
</template>
</template>
<v-list-item
v-for="navItem in navItems"
v-else
:key="navItem.to"
:to="navItem.to"
query
@contextmenu.prevent.stop="activateContextMenu($event, navItem.to)"
>
<v-list-item-icon><v-icon :name="navItem.icon" :color="navItem.color" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="navItem.name" />
</v-list-item-content>
</v-list-item>
<template v-if="bookmarks.length > 0">
<v-divider />
<navigation-bookmark v-for="bookmark of bookmarks" :key="bookmark.id" :bookmark="bookmark" />
</template>
<div v-if="!customNavItems && !navItems.length && !bookmarks.length" class="empty">
<template v-if="searchQuery !== null">
<em>{{ t('no_collections_found') }}</em>
</template>
<template v-else-if="isAdmin">
<v-button full-width outlined dashed to="/settings/data-model/+">{{ t('create_collection') }}</v-button>
</template>
<template v-else>
{{ t('no_collections_copy') }}
</template>
<div class="collections-navigation">
<div v-if="showSearch" class="search-input">
<v-input v-model="search" type="search" :placeholder="t('search_collection')" />
</div>
<template v-if="hiddenShown">
<v-divider />
<v-list
v-model="activeGroups"
scope="collections-navigation"
class="collections-navigation"
nav
:mandatory="false"
:dense="dense"
@contextmenu.prevent.stop="activateContextMenu"
>
<navigation-item
v-for="collection in rootItems"
:key="collection.collection"
:show-hidden="showHidden"
:collection="collection"
:search="search"
/>
<v-list-item
v-for="navItem in hiddenNavItems"
:key="navItem.to"
class="hidden-collection"
:to="navItem.to"
query
@contextmenu.prevent.stop="activateContextMenu($event, navItem.to)"
>
<v-list-item-icon><v-icon :name="navItem.icon" :color="navItem.color" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="navItem.name" />
</v-list-item-content>
</v-list-item>
</template>
<v-menu ref="contextMenu" show-arrow placement="bottom-start">
<v-list>
<v-list-item clickable @click="hiddenShown = !hiddenShown">
<v-menu ref="contextMenu" show-arrow placement="bottom-start">
<v-list-item clickable @click="showHidden = !showHidden">
<v-list-item-icon>
<v-icon :name="hiddenShown ? 'visibility_off' : 'visibility'" />
<v-icon :name="showHidden ? 'visibility_off' : 'visibility'" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="hiddenShown ? t('hide_hidden_collections') : t('show_hidden_collections')" />
<v-text-overflow :text="showHidden ? t('hide_hidden_collections') : t('show_hidden_collections')" />
</v-list-item-content>
</v-list-item>
<v-list-item
v-if="isAdmin && contextMenuTarget && contextMenuTarget.includes('/collections')"
clickable
:to="'/settings/data-model' + contextMenuTarget.replace('/collections', '')"
>
<v-list-item-icon>
<v-icon name="list_alt" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="t('edit_collection')" />
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list>
</v-menu>
</v-list>
</div>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, ref, watchEffect, onMounted, ComponentPublicInstance } from 'vue';
import useNavigation from '../composables/use-navigation';
import { usePresetsStore, useUserStore } from '@/stores/';
import { orderBy } from 'lodash';
import NavigationBookmark from './navigation-bookmark.vue';
import { useSearch } from '../composables/use-search';
import { defineComponent, computed, ref, toRefs } from 'vue';
import { useNavigation } from '../composables/use-navigation';
import { useCollectionsStore } from '@/stores/collections';
import { orderBy, isNil } from 'lodash';
import NavigationItem from './navigation-item.vue';
export default defineComponent({
components: { NavigationBookmark },
setup() {
components: { NavigationItem },
props: {
currentCollection: {
type: String,
default: null,
},
},
setup(props) {
const { t } = useI18n();
const { currentCollection } = toRefs(props);
const { activeGroups, showHidden } = useNavigation(currentCollection);
const { searchQuery, visible } = useSearch();
const listComponent = ref<ComponentPublicInstance>();
const search = ref('');
const collectionsStore = useCollectionsStore();
const contextMenu = ref();
const contextMenuTarget = ref<undefined | string>();
const presetsStore = usePresetsStore();
const userStore = useUserStore();
const isAdmin = computed(() => userStore.currentUser?.role.admin_access === true);
const { hiddenShown, customNavItems, navItems, activeGroups, hiddenNavItems } = useNavigation(searchQuery);
const bookmarks = computed(() => {
const rootItems = computed(() => {
return orderBy(
presetsStore.collectionPresets
.filter((preset) => {
return preset.bookmark !== null && preset.collection.startsWith('directus_') === false;
})
.filter(
(preset) =>
typeof preset.bookmark !== 'string' ||
preset.bookmark.toLocaleLowerCase().includes(searchQuery?.value?.toLocaleLowerCase() || '')
)
.map((preset) => {
let scope = 'global';
if (preset.role) scope = 'role';
if (preset.user) scope = 'user';
return {
...preset,
to: `/collections/${preset.collection}?bookmark=${preset.id}`,
scope,
};
}),
['bookmark'],
['asc']
collectionsStore.visibleCollections.filter((collection) => {
return isNil(collection?.meta?.group);
}),
['meta.sort', 'collection']
);
});
watchEffect(() => {
visible.value = bookmarks.value.length + navItems.value.length;
});
onMounted(() => {
const activeEl = listComponent.value?.$el.querySelector('.v-list-item.active.link');
activeEl?.scrollIntoView({ block: 'center' });
});
const dense = computed(() => collectionsStore.visibleCollections.length > 5);
const showSearch = computed(() => collectionsStore.visibleCollections.length > 20);
return {
t,
navItems,
bookmarks,
customNavItems,
isAdmin,
activeGroups,
isActive,
toggleActive,
contextMenu,
hiddenShown,
hiddenNavItems,
searchQuery,
listComponent,
showHidden,
rootItems,
dense,
activateContextMenu,
contextMenu,
contextMenuTarget,
search,
showSearch,
};
function isActive(name: string) {
return activeGroups.value.includes(name);
}
function toggleActive(name: string) {
if (activeGroups.value.includes(name)) {
activeGroups.value = activeGroups.value.filter((current: string) => current !== name);
} else {
activeGroups.value.push(name);
}
}
function activateContextMenu(event: PointerEvent, target?: string) {
contextMenuTarget.value = target;
contextMenu.value.activate(event);
@@ -249,4 +131,15 @@ export default defineComponent({
.hidden-collection {
--v-list-item-color: var(--foreground-subdued);
}
.search-input {
--input-height: 40px;
position: sticky;
top: 0;
z-index: 2;
padding: 12px;
padding-bottom: 0;
background-color: var(--background-normal);
}
</style>

View File

@@ -1,102 +1,45 @@
import { useCollectionsStore, useUserStore } from '@/stores/';
import { Collection } from '@directus/shared/types';
import { computed, ComputedRef, Ref, ref } from 'vue';
export type NavItem = {
collection: string;
name: string;
to: string;
icon: string;
color: string | null | undefined;
note: string | null;
};
export type NavItemGroup = {
name: string;
accordion: string;
items: NavItem[];
};
import { useCollectionsStore } from '@/stores/';
import { Ref, ref, watch } from 'vue';
let showHidden: Ref<boolean>;
let activeGroups: Ref<string[]>;
let hiddenShown: Ref<boolean>;
function collectionToNavItem(collection: Collection): NavItem {
return {
collection: collection.collection,
name: collection.name,
icon: collection.meta?.icon || 'label',
color: collection.meta?.color,
note: collection.meta?.note || null,
to: `/collections/${collection.collection}`,
};
}
type UsableNavigation = {
customNavItems: ComputedRef<NavItemGroup[] | null>;
navItems: ComputedRef<NavItem[]>;
activeGroups: Ref<string[]>;
hiddenNavItems: ComputedRef<NavItem[]>;
hiddenShown: Ref<boolean>;
search: (item: NavItem) => boolean;
};
export default function useNavigation(searchQuery?: Ref<string | null>): UsableNavigation {
export function useNavigation(currentCollection: Ref<string | null>) {
const collectionsStore = useCollectionsStore();
const userStore = useUserStore();
const customNavItems = computed<NavItemGroup[] | null>(() => {
if (!userStore.currentUser) return null;
if (!userStore.currentUser.role.collection_list) return null;
return userStore.currentUser?.role.collection_list.map((groupRaw) => {
const group: NavItemGroup = {
name: groupRaw.group_name,
accordion: groupRaw.accordion,
items:
groupRaw.collections
?.map(({ collection }) => collectionsStore.getCollection(collection) as Collection)
.filter((collection) => !!collection)
.map(collectionToNavItem)
.filter(search) ?? [],
};
return group;
});
});
const navItems = computed<NavItem[]>(() => {
return collectionsStore.visibleCollections
.map(collectionToNavItem)
.sort((navA: NavItem, navB: NavItem) => {
return navA.name > navB.name ? 1 : -1;
})
.filter(search);
});
const hiddenNavItems = computed<NavItem[]>(() => {
return collectionsStore.hiddenCollections
.map(collectionToNavItem)
.sort((navA: NavItem, navB: NavItem) => {
return navA.name > navB.name ? 1 : -1;
})
.filter(search);
});
if (!activeGroups) {
activeGroups = ref(
customNavItems.value?.filter(({ accordion }) => accordion === 'start_open').map(({ name }) => name) ?? []
collectionsStore.collections
.filter((collection) => collection.meta?.collapse === 'open' || collection.meta?.collapse === 'locked')
.map(({ collection }) => collection)
);
}
if (hiddenShown === undefined) {
hiddenShown = ref(false);
if (showHidden === undefined) {
showHidden = ref(false);
}
return { customNavItems, navItems, activeGroups, hiddenNavItems, hiddenShown, search };
watch(
currentCollection,
(collectionKey) => {
if (collectionKey === null) return;
function search(item: NavItem) {
if (!searchQuery?.value) return true;
if (typeof item.name !== 'string') return true;
return item.name.toLocaleLowerCase().includes(searchQuery.value.toLocaleLowerCase());
}
let collection = collectionsStore.getCollection(collectionKey);
const collectionsToAdd: string[] = [];
while (collection?.meta?.group) {
if (activeGroups.value.includes(collection.meta.group) === false) {
collectionsToAdd.push(collection.meta.group);
}
collection = collectionsStore.getCollection(collection.meta.group);
}
activeGroups.value = [...activeGroups.value, ...collectionsToAdd];
},
{ immediate: true }
);
return { showHidden, activeGroups };
}

View File

@@ -1,8 +0,0 @@
import { ref, Ref } from 'vue';
const searchQuery = ref<string | null>(null);
const visible = ref<number | null>(null);
export function useSearch(): { visible: Ref<number | null>; searchQuery: Ref<string | null> } {
return { visible, searchQuery };
}

View File

@@ -5,7 +5,12 @@ import { NavigationGuard } from 'vue-router';
import CollectionOrItem from './routes/collection-or-item.vue';
import Item from './routes/item.vue';
import ItemNotFound from './routes/not-found.vue';
import Overview from './routes/overview.vue';
import NoCollections from './routes/no-collections.vue';
import { useCollectionsStore } from '@/stores';
import { Collection } from '@directus/shared/types';
import { orderBy, isNil } from 'lodash';
import { useNavigation } from './composables/use-navigation';
import { ref } from 'vue';
const checkForSystem: NavigationGuard = (to, from) => {
if (!to.params?.collection) return;
@@ -58,9 +63,53 @@ export default defineModule({
icon: 'box',
routes: [
{
name: 'collections-overview',
name: 'no-collections',
path: '',
component: Overview,
component: NoCollections,
beforeEnter() {
const collectionsStore = useCollectionsStore();
const { activeGroups } = useNavigation(ref(null));
if (collectionsStore.visibleCollections.length === 0) return;
const rootCollections = orderBy(
collectionsStore.visibleCollections.filter((collection) => {
return isNil(collection?.meta?.group);
}),
['meta.sort', 'collection']
);
let firstCollection = findFirst(rootCollections);
// If the first collection couldn't be found in any open collections, try again but with closed collections
firstCollection = findFirst(rootCollections, { skipClosed: false });
if (!firstCollection) return;
return `/collections/${firstCollection.collection}`;
function findFirst(collections: Collection[], { skipClosed } = { skipClosed: true }): Collection | void {
for (const collection of collections) {
if (collection.schema) {
return collection;
}
// Don't default to a collection in a currently closed folder
if (skipClosed && activeGroups.value.includes(collection.collection) === false) continue;
const children = orderBy(
collectionsStore.visibleCollections.filter((childCollection) => {
return collection.collection === childCollection.meta?.group;
}),
['meta.sort', 'collection']
);
const first = findFirst(children);
if (first) return first;
}
}
},
},
{
path: ':collection',
@@ -73,6 +122,7 @@ export default defineModule({
props: (route) => ({
collection: route.params.collection,
bookmark: route.query.bookmark,
archive: 'archive' in route.query,
}),
beforeEnter: checkForSystem,
},

View File

@@ -4,6 +4,7 @@
:collection="collection"
:bookmark="bookmark"
:singleton="isSingleton ? true : undefined"
:archive="archive"
/>
</template>
@@ -27,6 +28,10 @@ export default defineComponent({
type: String,
default: null,
},
archive: {
type: Boolean,
default: false,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();

View File

@@ -21,8 +21,8 @@
:small-header="currentLayout?.smallHeader"
>
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon secondary disabled>
<v-icon :name="currentCollection.icon" :color="currentCollection.color" />
<v-button class="header-icon" :class="{ archive }" rounded icon secondary disabled>
<v-icon :name="archive ? 'archive' : currentCollection.icon" :color="currentCollection.color" />
</v-button>
</template>
@@ -146,7 +146,7 @@
<v-button secondary @click="confirmArchive = false">
{{ t('cancel') }}
</v-button>
<v-button kind="warning" :loading="archiving" @click="archive">
<v-button kind="warning" :loading="archiving" @click="archiveItems">
{{ t('archive') }}
</v-button>
</v-card-actions>
@@ -177,8 +177,7 @@
</template>
<template #navigation>
<collections-navigation-search />
<collections-navigation />
<collections-navigation :current-collection="collection" />
</template>
<v-info
@@ -264,7 +263,6 @@
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, ref, watch, toRefs } from 'vue';
import CollectionsNavigation from '../components/navigation.vue';
import CollectionsNavigationSearch from '../components/navigation-search.vue';
import api from '@/api';
import CollectionsNotFound from './not-found.vue';
import { useCollection } from '@directus/shared/composables';
@@ -290,7 +288,6 @@ export default defineComponent({
name: 'CollectionsCollection',
components: {
CollectionsNavigation,
CollectionsNavigationSearch,
CollectionsNotFound,
LayoutSidebarDetail,
SearchInput,
@@ -308,7 +305,7 @@ export default defineComponent({
type: String,
default: null,
},
showArchive: {
archive: {
type: Boolean,
default: false,
},
@@ -356,7 +353,7 @@ export default defineComponent({
deleting,
batchDelete,
confirmArchive,
archive,
archive: archiveItems,
archiving,
error: deleteError,
batchEditActive,
@@ -380,6 +377,7 @@ export default defineComponent({
const archiveFilter = computed(() => {
if (!currentCollection.value?.meta) return null;
if (!currentCollection.value?.meta?.archive_app_filter) return null;
const field = currentCollection.value.meta.archive_field;
@@ -389,7 +387,7 @@ export default defineComponent({
if (archiveValue === 'true') archiveValue = true;
if (archiveValue === 'false') archiveValue = false;
if (props.showArchive) {
if (props.archive) {
return {
[field]: {
_eq: archiveValue,
@@ -432,7 +430,7 @@ export default defineComponent({
breadcrumb,
clearFilters,
confirmArchive,
archive,
archiveItems,
archiving,
batchEditAllowed,
batchArchiveAllowed,
@@ -489,7 +487,7 @@ export default defineComponent({
const error = ref<any>(null);
return { batchEditActive, confirmDelete, deleting, batchDelete, confirmArchive, archiving, archive, error };
return { batchEditActive, confirmDelete, deleting, batchDelete, confirmArchive, archiving, archiveItems, error };
async function batchDelete() {
deleting.value = true;
@@ -512,7 +510,7 @@ export default defineComponent({
}
}
async function archive() {
async function archiveItems() {
if (!currentCollection.value?.meta?.archive_field) return;
archiving.value = true;
@@ -675,6 +673,11 @@ export default defineComponent({
--v-button-color-disabled: var(--foreground-normal);
}
.header-icon.archive {
--v-button-color-disabled: var(--warning);
--v-button-background-color-disabled: var(--warning-10);
}
.layout {
--layout-offset-top: 64px;
}

View File

@@ -142,8 +142,7 @@
</template>
<template #navigation>
<collections-navigation-search />
<collections-navigation />
<collections-navigation :current-collection="collection" />
</template>
<v-form
@@ -196,7 +195,6 @@
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, toRefs, ref, ComponentPublicInstance } from 'vue';
import CollectionsNavigationSearch from '../components/navigation-search.vue';
import CollectionsNavigation from '../components/navigation.vue';
import CollectionsNotFound from './not-found.vue';
import { useCollection } from '@directus/shared/composables';
@@ -216,7 +214,6 @@ export default defineComponent({
name: 'CollectionsItem',
components: {
CollectionsNavigation,
CollectionsNavigationSearch,
CollectionsNotFound,
RevisionsDrawerDetail,
CommentsSidebarDetail,

View File

@@ -7,30 +7,18 @@
</template>
<template #navigation>
<collections-navigation-search />
<collections-navigation />
</template>
<v-table
v-if="navItems.length > 0"
v-model:headers="tableHeaders"
:items="navItems"
show-resize
fixed-header
@click:row="navigateToCollection"
>
<template #[`item.icon`]="{ item }">
<v-icon class="icon" :name="item.icon" :color="item.color" />
</template>
</v-table>
<v-info v-else icon="box" :title="t('no_collections')" center>
<v-info icon="box" :title="t('no_collections')" center>
<template v-if="isAdmin">
{{ t('no_collections_copy_admin') }}
</template>
<template v-else>
{{ t('no_collections_copy') }}
</template>
<template v-if="isAdmin" #append>
<v-button to="/settings/data-model/+">{{ t('create_collection') }}</v-button>
</template>
@@ -47,55 +35,23 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, ref } from 'vue';
import { HeaderRaw } from '@/components/v-table/types';
import CollectionsNavigation from '../components/navigation.vue';
import CollectionsNavigationSearch from '../components/navigation-search.vue';
import useNavigation, { NavItem } from '../composables/use-navigation';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores';
export default defineComponent({
name: 'CollectionsOverview',
components: {
CollectionsNavigation,
CollectionsNavigationSearch,
},
props: {},
setup() {
const { t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const tableHeaders = ref<HeaderRaw[]>([
{
text: '',
value: 'icon',
width: 42,
sortable: false,
},
{
text: t('name'),
value: 'name',
width: 240,
},
{
text: t('note'),
value: 'note',
width: 360,
},
]);
const { navItems } = useNavigation();
const isAdmin = computed(() => userStore.currentUser?.role.admin_access === true);
return { t, tableHeaders, navItems, navigateToCollection, isAdmin };
function navigateToCollection(navItem: NavItem) {
router.push(navItem.to);
}
return { t, isAdmin };
},
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<v-list v-model="selection" large :mandatory="false">
<v-list v-model="selection" nav :mandatory="false">
<navigation-item v-for="item in navSections" :key="item.name" :section="item" />
</v-list>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<v-list large>
<v-list nav>
<template v-if="loading && (nestedFolders === null || nestedFolders.length === 0)">
<v-list-item v-for="n in 4" :key="n">
<v-skeleton-loader type="list-item-icon" />

View File

@@ -1,5 +1,5 @@
<template>
<v-list large>
<v-list nav>
<v-button v-if="navItems.length === 0" full-width outlined dashed @click="$emit('create')">
{{ t('create_dashboard') }}
</v-button>

View File

@@ -1,5 +1,5 @@
<template>
<v-list large>
<v-list nav>
<v-list-item v-for="item in navItems" :key="item.to" :to="item.to">
<v-list-item-icon><v-icon :name="item.icon" /></v-list-item-icon>
<v-list-item-content>

View File

@@ -9,6 +9,14 @@
</template>
<template #actions>
<collection-dialog v-model="collectionDialogActive">
<template #activator="{ on }">
<v-button v-tooltip.bottom="t('create_folder')" rounded icon class="add-folder" @click="on">
<v-icon name="create_new_folder" />
</v-button>
</template>
</collection-dialog>
<v-button v-tooltip.bottom="t('create_collection')" rounded icon to="/settings/data-model/+">
<v-icon name="add" />
</v-button>
@@ -19,7 +27,7 @@
</template>
<div class="padding-box">
<v-info v-if="items.length === 0" type="warning" icon="box" :title="t('no_collections')" center>
<v-info v-if="collections.length === 0" type="warning" icon="box" :title="t('no_collections')" center>
{{ t('no_collections_copy_admin') }}
<template #append>
@@ -27,60 +35,59 @@
</template>
</v-info>
<v-table
v-else
v-model:headers="tableHeaders"
:items="items"
show-resize
fixed-header
item-key="collection"
@click:row="openCollection"
>
<template #[`item.icon`]="{ item }">
<v-icon
class="icon"
:class="{
hidden: (item.meta && item.meta.hidden) || false,
system: item.collection.startsWith('directus_'),
unmanaged: item.meta === null && item.collection.startsWith('directus_') === false,
}"
:name="item.icon"
:color="item.color"
/>
</template>
<v-list v-else class="draggable-list">
<draggable
:force-fallback="true"
:model-value="rootCollections"
:group="{ name: 'collections' }"
:swap-threshold="0.3"
class="root-drag-container"
item-key="collection"
handle=".drag-handle"
@update:model-value="onSort($event, true)"
>
<template #item="{ element }">
<collection-item
:collection="element"
:collections="collections"
@editCollection="editCollection = $event"
@setNestedSort="onSort"
/>
</template>
</draggable>
</v-list>
<template #[`item.name`]="{ item }">
<v-text-overflow
class="collection"
:class="{
hidden: (item.meta && item.meta.hidden) || false,
system: item.collection.startsWith('directus_'),
unmanaged: item.meta === null && item.collection.startsWith('directus_') === false,
}"
:text="item.collection"
/>
</template>
<v-list>
<v-list-item
v-for="collection of tableCollections"
:key="collection.collection"
v-tooltip="t('db_only_click_to_configure')"
class="collection-row hidden"
block
clickable
>
<v-list-item-icon>
<v-icon name="add" />
</v-list-item-icon>
<template #[`item.note`]="{ item }">
<span v-if="item.meta === null" class="note">
{{ t('db_only_click_to_configure') }}
</span>
<span v-else class="note">
{{ item.meta.note }}
</span>
</template>
<div class="collection-name" @click="openCollection(collection)">
<v-icon class="collection-icon" name="dns" />
<span class="collection-name">{{ collection.name }}</span>
</div>
<template #item-append="{ item }">
<v-icon
v-if="!item.meta && item.collection.startsWith('directus_') === false"
v-tooltip="t('db_only_click_to_configure')"
small
class="no-meta"
name="report_problem"
/>
<collection-options v-if="item.collection.startsWith('directus_') === false" :collection="item" />
</template>
</v-table>
<collection-options :collection="collection" />
</v-list-item>
</v-list>
<v-detail v-if="collections.length > 0" :label="t('system_collections')">
<collection-item
v-for="collection of systemCollections"
:key="collection.collection"
:collection="collection"
:collections="systemCollections"
disable-drag
/>
</v-detail>
</div>
<router-view name="add" />
@@ -89,169 +96,130 @@
<sidebar-detail icon="info_outline" :title="t('information')" close>
<div v-md="t('page_help_settings_datamodel_collections')" class="page-description" />
</sidebar-detail>
<collections-filter v-model="activeTypes" />
</template>
<collection-dialog
:model-value="!!editCollection"
:collection="editCollection"
@update:model-value="editCollection = null"
/>
</private-view>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, ref, computed } from 'vue';
import { defineComponent, computed, ref } from 'vue';
import SettingsNavigation from '../../../components/navigation.vue';
import { HeaderRaw } from '@/components/v-table/types';
import { useCollectionsStore } from '@/stores/';
import { Collection } from '@directus/shared/types';
import { useRouter } from 'vue-router';
import { sortBy } from 'lodash';
import { Collection } from '@/types';
import CollectionOptions from './components/collection-options.vue';
import CollectionsFilter from './components/collections-filter.vue';
import { sortBy, merge } from 'lodash';
import CollectionItem from './components/collection-item.vue';
import { translate } from '@/utils/translate-object-values';
const activeTypes = ref(['visible', 'hidden', 'unmanaged']);
import Draggable from 'vuedraggable';
import { unexpectedError } from '@/utils/unexpected-error';
import api from '@/api';
import CollectionDialog from './components/collection-dialog.vue';
export default defineComponent({
components: { SettingsNavigation, CollectionOptions, CollectionsFilter },
components: { SettingsNavigation, CollectionItem, CollectionOptions, Draggable, CollectionDialog },
setup() {
const { t } = useI18n();
const router = useRouter();
const collectionDialogActive = ref(false);
const editCollection = ref<Collection>();
const collectionsStore = useCollectionsStore();
const tableHeaders = ref<HeaderRaw[]>([
{
text: '',
value: 'icon',
width: 42,
sortable: false,
},
{
text: t('name'),
value: 'name',
width: 240,
},
{
text: t('note'),
value: 'note',
width: 360,
},
]);
function openCollection({ collection }: Collection) {
router.push(`/settings/data-model/${collection}`);
}
const { items } = useItems();
return { t, tableHeaders, items, openCollection, activeTypes };
function useItems() {
const visible = computed(() => {
return sortBy(
const collections = computed(() => {
return translate(
sortBy(
collectionsStore.collections.filter(
(collection) => collection.collection.startsWith('directus_') === false && collection.meta?.hidden === false
(collection) => collection.collection.startsWith('directus_') === false && collection.meta
),
'collection'
);
});
['meta.sort', 'collection']
)
);
});
const hidden = computed(() => {
return sortBy(
collectionsStore.collections
.filter(
(collection) =>
collection.collection.startsWith('directus_') === false && collection.meta?.hidden === true
)
.map((collection) => ({ ...collection, icon: 'visibility_off' })),
'collection'
);
});
const rootCollections = computed(() => {
return collections.value.filter((collection) => !collection.meta?.group);
});
const system = computed(() => {
return sortBy(
const tableCollections = computed(() => {
return translate(
sortBy(
collectionsStore.collections.filter(
(collection) =>
collection.collection.startsWith('directus_') === false &&
!!collection.meta === false &&
collection.schema
),
['meta.sort', 'collection']
)
);
});
const systemCollections = computed(() => {
return translate(
sortBy(
collectionsStore.collections
.filter((collection) => collection.collection.startsWith('directus_') === true)
.map((collection) => ({ ...collection, icon: 'settings' })),
'collection'
)
);
});
return {
collectionDialogActive,
t,
collections,
tableCollections,
systemCollections,
onSort,
rootCollections,
editCollection,
};
async function onSort(updates: Collection[], removeGroup = false) {
const updatesWithSortValue = updates.map((collection, index) =>
merge(collection, { meta: { sort: index + 1, group: removeGroup ? null : collection.meta?.group } })
);
collectionsStore.collections = collectionsStore.collections.map((collection) => {
const updatedValues = updatesWithSortValue.find(
(updatedCollection) => updatedCollection.collection === collection.collection
);
return updatedValues ? merge({}, collection, updatedValues) : collection;
});
const unmanaged = computed(() => {
return sortBy(
collectionsStore.collections
.filter((collection) => collection.collection.startsWith('directus_') === false)
.filter((collection) => collection.meta === null)
.map((collection) => ({ ...collection, icon: 'dns' })),
'collection'
try {
await Promise.all(
updatesWithSortValue.map((collection) =>
api.patch(`/collections/${collection.collection}`, {
meta: { sort: collection.meta.sort, group: collection.meta.group },
})
)
);
});
const items = computed(() => {
const items = [];
if (activeTypes.value.includes('visible')) {
items.push(visible.value);
}
if (activeTypes.value.includes('unmanaged')) {
items.push(unmanaged.value);
}
if (activeTypes.value.includes('hidden')) {
items.push(hidden.value);
}
if (activeTypes.value.includes('system')) {
items.push(translate(system.value));
}
return items.flat();
});
return { items };
} catch (err) {
unexpectedError(err);
}
}
},
});
</script>
<style scoped>
.icon :deep(i) {
vertical-align: baseline;
}
.icon.hidden :deep(i) {
color: var(--foreground-subdued);
}
.icon.system :deep(i) {
color: var(--primary);
}
.collection {
font-family: var(--family-monospace);
}
.hidden {
color: var(--foreground-subdued);
}
.system {
color: var(--primary);
}
.note {
color: var(--foreground-subdued);
}
<style scoped lang="scss">
.padding-box {
padding: var(--content-padding);
padding-top: 0;
}
.v-table {
--v-table-sticky-offset-top: 64px;
display: contents;
.root-drag-container {
padding: 8px 0;
overflow: hidden;
}
.header-icon {
@@ -259,9 +227,40 @@ export default defineComponent({
--v-button-background-color-disabled: var(--warning-10);
}
.no-meta {
--v-icon-color: var(--warning);
.collection-item.hidden {
--v-list-item-color: var(--foreground-subdued);
}
margin-right: 4px;
.collection-name {
flex-grow: 1;
font-family: var(--family-monospace);
}
.collection-icon {
margin-right: 8px;
}
.hidden .collection-name {
color: var(--foreground-subdued);
}
.draggable-list :deep(.sortable-ghost) {
.v-list-item {
--v-list-item-background-color: var(--primary-alt);
--v-list-item-border-color: var(--primary);
--v-list-item-background-color-hover: var(--primary-alt);
--v-list-item-border-color-hover: var(--primary);
> * {
opacity: 0;
}
}
}
.add-folder {
--v-button-background-color: var(--primary-10);
--v-button-color: var(--primary);
--v-button-background-color-hover: var(--primary-25);
--v-button-color-hover: var(--primary);
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<v-dialog :model-value="modelValue" persistent @update:modelValue="$emit('update:modelValue', $event)" @esc="cancel">
<template #activator="slotBinding">
<slot name="activator" v-bind="slotBinding" />
</template>
<v-card>
<v-card-title v-if="!collection">{{ t('create_folder') }}</v-card-title>
<v-card-title v-else>{{ t('edit_folder') }}</v-card-title>
<v-card-text>
<div class="fields">
<v-input
v-model="values.collection"
:disabled="!!collection"
class="full collection-key"
db-safe
autofocus
:placeholder="t('folder_key')"
/>
<interface-select-icon width="half" :value="values.icon" @input="values.icon = $event" />
<interface-select-color width="half" :value="values.color" @input="values.color = $event" />
<v-input v-model="values.note" class="full" :placeholder="t('note')" />
<interface-list
width="full"
class="full"
:value="values.translations"
:placeholder="t('no_translations')"
template="{{ translation }} ({{ language }})"
:fields="[
{
field: 'language',
name: '$t:language',
type: 'string',
schema: {
default_value: 'en-US',
},
meta: {
interface: 'system-language',
width: 'half',
},
},
{
field: 'translation',
name: '$t:field_options.directus_collections.collection_name',
type: 'string',
meta: {
interface: 'input',
width: 'half',
options: {
placeholder: '$t:field_options.directus_collections.translation_placeholder',
},
},
},
]"
@input="values.translations = $event"
/>
</div>
</v-card-text>
<v-card-actions>
<v-button secondary @click="cancel">
{{ t('cancel') }}
</v-button>
<v-button :disabled="!values.collection" :loading="saving" @click="save">
{{ t('save') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import api from '@/api';
import { unexpectedError } from '@/utils/unexpected-error';
import { defineComponent, ref, reactive, PropType, watch } from 'vue';
import { useCollectionsStore } from '@/stores';
import { useI18n } from 'vue-i18n';
import { isEqual } from 'lodash';
import { Collection } from '@/types';
export default defineComponent({
name: 'CollectionDialog',
props: {
modelValue: {
type: Boolean,
default: false,
},
collection: {
type: Object as PropType<Collection>,
default: null,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const { t } = useI18n();
const collectionsStore = useCollectionsStore();
const values = reactive({
collection: props.collection?.collection ?? null,
icon: props.collection?.icon ?? 'folder',
note: props.collection?.meta?.note ?? null,
color: props.collection?.color ?? null,
translations: props.collection?.meta?.translations ?? null,
});
watch(
() => props.modelValue,
(newValue, oldValue) => {
if (isEqual(newValue, oldValue) === false) {
values.collection = props.collection?.collection ?? null;
values.icon = props.collection?.icon ?? 'folder';
values.note = props.collection?.meta?.note ?? null;
values.color = props.collection?.color ?? null;
values.translations = props.collection?.meta?.translations ?? null;
}
}
);
const saving = ref(false);
return { values, cancel, saving, save, t };
function cancel() {
emit('update:modelValue', false);
}
async function save() {
saving.value = true;
try {
if (props.collection) {
await api.patch(`/collections/${props.collection.collection}`, { meta: values });
await collectionsStore.hydrate();
} else {
await api.post<any>('/collections', { collection: values.collection, meta: values });
await collectionsStore.hydrate();
}
emit('update:modelValue', false);
} catch (err) {
unexpectedError(err);
} finally {
saving.value = false;
}
}
},
});
</script>
<style scoped>
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.full {
grid-column: 1 / span 2;
}
.collection-key {
--v-input-font-family: var(--family-monospace);
}
</style>

View File

@@ -0,0 +1,203 @@
<template>
<div class="collection-item">
<v-list-item block clickable :class="{ hidden: collection.meta?.hidden }">
<v-list-item-icon>
<v-icon v-if="!disableDrag" class="drag-handle" name="drag_handle" />
</v-list-item-icon>
<div class="collection-name" @click="openCollection(collection)">
<v-icon
:color="collection.meta?.hidden ? 'var(--foreground-subdued)' : collection.color"
class="collection-icon"
:name="collection.meta?.hidden ? 'visibility_off' : collection.icon"
/>
<span>{{ collection.name }}</span>
</div>
<v-progress-circular v-if="nestedCollections.length && collapseLoading" small indeterminate />
<v-icon
v-else-if="nestedCollections.length"
v-tooltip="collapseTooltip"
:name="collapseIcon"
clickable
@click="toggleCollapse"
/>
<collection-options :collection="collection" />
</v-list-item>
<draggable
:force-fallback="true"
:model-value="nestedCollections"
:group="{ name: 'collections' }"
:swap-threshold="0.3"
class="drag-container"
item-key="collection"
handle=".drag-handle"
@update:model-value="onGroupSortChange"
>
<template #item="{ element }">
<collection-item
:collection="element"
:collections="collections"
@editCollection="$emit('editCollection', $event)"
@setNestedSort="$emit('setNestedSort', $event)"
/>
</template>
</draggable>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, ref } from 'vue';
import CollectionOptions from './collection-options.vue';
import { Collection } from '@/types';
import { useRouter } from 'vue-router';
import Draggable from 'vuedraggable';
import { useCollectionsStore } from '@/stores';
import { DeepPartial } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
import { unexpectedError } from '@/utils/unexpected-error';
export default defineComponent({
name: 'CollectionItem',
components: { CollectionOptions, Draggable },
props: {
collection: {
type: Object as PropType<Collection>,
required: true,
},
collections: {
type: Array as PropType<Collection[]>,
required: true,
},
disableDrag: {
type: Boolean,
default: false,
},
},
emits: ['setNestedSort', 'editCollection'],
setup(props, { emit }) {
const collectionsStore = useCollectionsStore();
const router = useRouter();
const { t } = useI18n();
const nestedCollections = computed(() =>
props.collections.filter((collection) => collection.meta?.group === props.collection.collection)
);
const collapseIcon = computed(() => {
switch (props.collection.meta?.collapse) {
case 'open':
return 'folder_open';
case 'closed':
return 'folder';
case 'locked':
return 'folder_lock';
}
return undefined;
});
const collapseTooltip = computed(() => {
switch (props.collection.meta?.collapse) {
case 'open':
return t('start_open');
case 'closed':
return t('start_collapsed');
case 'locked':
return t('always_open');
}
return undefined;
});
const collapseLoading = ref(false);
return {
collapseIcon,
openCollection,
onGroupSortChange,
nestedCollections,
update,
toggleCollapse,
collapseTooltip,
collapseLoading,
};
async function toggleCollapse() {
if (collapseLoading.value === true) return;
collapseLoading.value = true;
let newCollapse: 'open' | 'closed' | 'locked' = 'open';
if (props.collection.meta?.collapse === 'open') {
newCollapse = 'closed';
} else if (props.collection.meta?.collapse === 'closed') {
newCollapse = 'locked';
}
try {
await update({ meta: { collapse: newCollapse } });
} catch (err) {
unexpectedError(err);
} finally {
collapseLoading.value = false;
}
}
async function update(updates: DeepPartial<Collection>) {
await collectionsStore.updateCollection(props.collection.collection, updates);
}
function openCollection(collection: Collection) {
if (collection.schema) {
router.push(`/settings/data-model/${collection.collection}`);
} else {
emit('editCollection', collection);
}
}
function onGroupSortChange(collections: Collection[]) {
const updates = collections.map((collection) => ({
collection: collection.collection,
meta: {
group: props.collection.collection,
},
}));
emit('setNestedSort', updates);
}
},
});
</script>
<style scoped>
.drag-container {
margin-top: 8px;
margin-left: 20px;
}
.collection-item {
margin-bottom: 8px;
}
.collection-name {
display: flex;
flex-grow: 1;
align-items: center;
height: 100%;
font-family: var(--family-monospace);
}
.collection-icon {
margin-right: 8px;
}
.drag-handle {
cursor: grab;
}
.hidden {
--v-list-item-color: var(--foreground-subdued);
--v-icon-color: var(--foreground-subdued);
}
</style>

View File

@@ -13,8 +13,20 @@
{{ t('delete_collection') }}
</v-list-item-content>
</v-list-item>
<v-list-item clickable @click="update({ meta: { hidden: !collection.meta?.hidden } })">
<template v-if="collection.meta?.hidden === false">
<v-list-item-icon><v-icon name="visibility_off" /></v-list-item-icon>
<v-list-item-content>{{ t('make_collection_hidden') }}</v-list-item-content>
</template>
<template v-else>
<v-list-item-icon><v-icon name="visibility" /></v-list-item-icon>
<v-list-item-content>{{ t('make_collection_visible') }}</v-list-item-content>
</template>
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="deleteActive" @esc="deleteActive = null">
<v-card>
<v-card-title>{{ t('delete_collection_are_you_sure') }}</v-card-title>
@@ -34,7 +46,7 @@
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, PropType, ref } from 'vue';
import { Collection } from '@directus/shared/types';
import { Collection } from '@/types';
import { useCollectionsStore } from '@/stores/';
export default defineComponent({
@@ -50,7 +62,11 @@ export default defineComponent({
const collectionsStore = useCollectionsStore();
const { deleting, deleteActive, deleteCollection } = useDelete();
return { t, deleting, deleteActive, deleteCollection };
return { t, deleting, deleteActive, deleteCollection, update };
async function update(updates: Partial<Collection>) {
await collectionsStore.updateCollection(props.collection.collection, updates);
}
function useDelete() {
const deleting = ref(false);

View File

@@ -1,44 +0,0 @@
<template>
<sidebar-detail class="collections-filter" icon="filter_list" :title="t('collection', 2)">
<div class="type-label label">{{ t('collections_shown') }}</div>
<v-checkbox v-model="internalValue" value="visible" :label="t('visible_collections')" />
<v-checkbox v-model="internalValue" value="unmanaged" :label="t('unmanaged_collections')" />
<v-checkbox v-model="internalValue" value="hidden" :label="t('hidden_collections')" />
<v-checkbox v-model="internalValue" value="system" :label="t('system_collections')" />
</sidebar-detail>
</template>
<script lang="ts">
import { useI18n } from 'vue-i18n';
import { defineComponent, computed, PropType } from 'vue';
export default defineComponent({
props: {
modelValue: {
type: Array as PropType<string[]>,
required: true,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const { t } = useI18n();
const internalValue = computed({
get() {
return props.modelValue;
},
set(newVal) {
emit('update:modelValue', newVal);
},
});
return { t, internalValue };
},
});
</script>
<style lang="scss" scoped>
.label {
margin-bottom: 8px;
}
</style>

View File

@@ -219,6 +219,7 @@ export default defineComponent({
await api.post(`/collections`, {
collection: collectionName.value,
fields: [getPrimaryKeyField(), ...getSystemFields()],
schema: {},
meta: {
sort_field: sortField.value,
archive_field: archiveField.value,

View File

@@ -1,5 +1,5 @@
<template>
<v-list large>
<v-list nav>
<v-list-item to="/users" exact :active="currentRole === null">
<v-list-item-icon><v-icon name="folder_shared" outline /></v-list-item-icon>
<v-list-item-content>{{ t('all_users') }}</v-list-item-content>

View File

@@ -1,6 +1,8 @@
import api from '@/api';
import { i18n } from '@/lang';
import { Collection, CollectionRaw } from '@directus/shared/types';
import { Collection as CollectionRaw, DeepPartial } from '@directus/shared/types';
import { Collection } from '@/types';
import { getCollectionType } from '@directus/shared/utils';
import { notEmpty } from '@/utils/is-empty/';
import { notify } from '@/utils/notify';
import { unexpectedError } from '@/utils/unexpected-error';
@@ -38,7 +40,7 @@ export const useCollectionsStore = defineStore({
},
actions: {
async hydrate() {
const response = await api.get(`/collections`, { params: { limit: -1 } });
const response = await api.get<any>(`/collections`, { params: { limit: -1 } });
const collections: CollectionRaw[] = response.data.data;
@@ -46,6 +48,7 @@ export const useCollectionsStore = defineStore({
const icon = collection.meta?.icon || 'label';
const color = collection.meta?.color;
const name = formatTitle(collection.collection);
const type = getCollectionType(collection);
if (collection.meta && notEmpty(collection.meta.translations)) {
for (let i = 0; i < collection.meta.translations.length; i++) {
@@ -68,6 +71,7 @@ export const useCollectionsStore = defineStore({
return {
...collection,
name,
type,
icon,
color,
};
@@ -94,7 +98,7 @@ export const useCollectionsStore = defineStore({
async dehydrate() {
this.$reset();
},
async updateCollection(collection: string, updates: Partial<Collection>) {
async updateCollection(collection: string, updates: DeepPartial<Collection>) {
try {
await api.patch(`/collections/${collection}`, updates);
await this.hydrate();

View File

@@ -100,6 +100,11 @@ export const usePresetsStore = defineStore({
state: () => ({
collectionPresets: [] as Preset[],
}),
getters: {
bookmarks(): Preset[] {
return this.collectionPresets.filter((preset) => preset.bookmark !== null);
},
},
actions: {
async hydrate() {
// Hydrate is only called for logged in users, therefore, currentUser exists

View File

@@ -142,3 +142,10 @@ dd a {
.form-grid {
@include form-grid;
}
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
-webkit-appearance: none;
}

View File

@@ -0,0 +1,9 @@
import { Collection as CollectionRaw, CollectionType } from '@directus/shared/types';
import { TranslateResult } from 'vue-i18n';
export interface Collection extends CollectionRaw {
name: string | TranslateResult;
icon: string;
type: CollectionType;
color?: string | null;
}

View File

@@ -2,4 +2,3 @@ export * from './collections';
export * from './error';
export * from './insights';
export * from './notifications';
export * from './relations';

View File

@@ -1,4 +1,4 @@
import { TranslateResult } from 'vue-i18n';
import { Table } from 'knex-schema-inspector/dist/types/table';
type Translations = {
language: string;
@@ -7,29 +7,31 @@ type Translations = {
plural: string;
};
export interface CollectionRaw {
export type CollectionMeta = {
collection: string;
meta: {
note: string | null;
hidden: boolean;
singleton: boolean;
icon: string | null;
color: string | null;
translations: Translations[] | null;
display_template: string | null;
sort_field: string | null;
archive_field: string | null;
archive_value: string | null;
unarchive_value: string | null;
archive_app_filter: boolean;
item_duplication_fields: string[] | null;
accountability: 'all' | 'activity' | null;
} | null;
schema: Record<string, any>;
note: string | null;
hidden: boolean;
singleton: boolean;
icon: string | null;
color: string | null;
translations: Translations[] | null;
display_template: string | null;
sort_field: string | null;
archive_field: string | null;
archive_value: string | null;
unarchive_value: string | null;
archive_app_filter: boolean;
item_duplication_fields: string[] | null;
accountability: 'all' | 'activity' | null;
sort: number | null;
group: string | null;
collapse: 'open' | 'closed' | 'locked';
};
export interface Collection {
collection: string;
meta: CollectionMeta | null;
schema: Table | null;
}
export interface Collection extends CollectionRaw {
name: string | TranslateResult;
icon: string;
color?: string | null;
}
export type CollectionType = 'alias' | 'table' | 'unknown';

View File

@@ -0,0 +1,55 @@
import { getCollectionType } from '.';
import { Collection } from '../types';
const TableCollection: Collection = {
collection: 'table',
schema: {
name: 'table',
},
meta: null,
};
const AliasCollection: Collection = {
collection: 'table',
schema: null,
meta: {
collection: 'table',
note: '',
hidden: true,
singleton: true,
icon: 'box',
color: '#abcabc',
translations: null,
display_template: null,
sort_field: null,
archive_field: null,
archive_value: null,
unarchive_value: null,
archive_app_filter: true,
item_duplication_fields: null,
accountability: null,
sort: null,
group: null,
collapse: 'open',
},
};
const UnknownCollection: Collection = {
collection: 'unknown',
schema: null,
meta: null,
};
describe('getCollectionType', () => {
it('returns "table" when collection has schema information', () => {
expect(getCollectionType(TableCollection)).toStrictEqual('table');
});
it('returns "alias" when collection has schema information', () => {
expect(getCollectionType(AliasCollection)).toStrictEqual('alias');
});
it('returns "unknown" when collection has schema information', () => {
expect(getCollectionType(UnknownCollection)).toStrictEqual('unknown');
});
});

View File

@@ -0,0 +1,13 @@
import { Collection, CollectionType } from '../types';
/**
* Get the type of collection. One of alias | table. (And later: view)
*
* @param collection Collection object to get the type of
* @returns collection type
*/
export function getCollectionType(collection: Collection): CollectionType {
if (collection.schema) return 'table';
if (collection.meta) return 'alias';
return 'unknown';
}

View File

@@ -2,6 +2,7 @@ export * from './adjust-date';
export * from './deep-map';
export * from './define-extension';
export * from './generate-joi';
export * from './get-collection-type';
export * from './get-fields-from-template';
export * from './get-filter-operators-for-type';
export * from './get-relation-type';