mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,5 +17,6 @@ dist
|
||||
*.tsbuildinfo
|
||||
.e2e-containers.json
|
||||
coverage
|
||||
TODO
|
||||
schema.yaml
|
||||
schema.json
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,5 +16,5 @@ export type Collection = {
|
||||
collection: string;
|
||||
fields?: Field[];
|
||||
meta: CollectionMeta | null;
|
||||
schema: Table;
|
||||
schema: Table | null;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
27
app/src/components/v-icon/custom-icons/folder_lock.vue
Normal file
27
app/src/components/v-icon/custom-icons/folder_lock.vue
Normal 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>
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -54,7 +54,7 @@ export default defineComponent({
|
||||
}
|
||||
}
|
||||
|
||||
&.large {
|
||||
&.nav {
|
||||
&.three-line,
|
||||
&.two-line {
|
||||
#{$this} {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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/';
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
175
app/src/modules/collections/components/navigation-item.vue
Normal file
175
app/src/modules/collections/components/navigation-item.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
9
app/src/types/collections.ts
Normal file
9
app/src/types/collections.ts
Normal 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;
|
||||
}
|
||||
@@ -2,4 +2,3 @@ export * from './collections';
|
||||
export * from './error';
|
||||
export * from './insights';
|
||||
export * from './notifications';
|
||||
export * from './relations';
|
||||
|
||||
@@ -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';
|
||||
|
||||
55
packages/shared/src/utils/get-collection-type.test.ts
Normal file
55
packages/shared/src/utils/get-collection-type.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
13
packages/shared/src/utils/get-collection-type.ts
Normal file
13
packages/shared/src/utils/get-collection-type.ts
Normal 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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user