diff --git a/api/src/database/run-ast.ts b/api/src/database/run-ast.ts index 10c4eda398..2a9911afa8 100644 --- a/api/src/database/run-ast.ts +++ b/api/src/database/run-ast.ts @@ -155,8 +155,6 @@ function getDBQuery( delete queryCopy.limit; } - query.sort = query.sort || [{ column: primaryKeyField, order: 'asc' }]; - applyQuery(table, dbQuery, queryCopy, schema); return dbQuery; @@ -187,6 +185,10 @@ function applyParentFilters(nestedCollectionNodes: NestedCollectionNode[], paren nestedNode.children.push({ type: 'field', name: nestedNode.relation.many_field }); } + if (nestedNode.relation.sort_field) { + nestedNode.children.push({ type: 'field', name: nestedNode.relation.sort_field }); + } + nestedNode.query = { ...nestedNode.query, filter: { @@ -245,16 +247,30 @@ function mergeWithParentItems( } } else if (nestedNode.type === 'o2m') { for (const parentItem of parentItems) { - let itemChildren = nestedItems.filter((nestedItem) => { - if (nestedItem === null) return false; - if (Array.isArray(nestedItem[nestedNode.relation.many_field])) return true; + let itemChildren = nestedItems + .filter((nestedItem) => { + if (nestedItem === null) return false; + if (Array.isArray(nestedItem[nestedNode.relation.many_field])) return true; - return ( - nestedItem[nestedNode.relation.many_field] == parentItem[nestedNode.relation.one_primary!] || - nestedItem[nestedNode.relation.many_field]?.[nestedNode.relation.one_primary!] == - parentItem[nestedNode.relation.one_primary!] - ); - }); + return ( + nestedItem[nestedNode.relation.many_field] == parentItem[nestedNode.relation.one_primary!] || + nestedItem[nestedNode.relation.many_field]?.[nestedNode.relation.one_primary!] == + parentItem[nestedNode.relation.one_primary!] + ); + }) + .sort((a, b) => { + // This is pre-filled in get-ast-from-query + const { column, order } = nestedNode.query.sort![0]!; + + if (a[column] === b[column]) return 0; + if (a[column] === null) return 1; + if (b[column] === null) return -1; + if (order === 'asc') { + return a[column] < b[column] ? -1 : 1; + } else { + return a[column] < b[column] ? 1 : -1; + } + }); // We re-apply the requested limit here. This forces the _n_ nested items per parent concept if (nested) { diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts index aa6c0a12cb..999c11016c 100644 --- a/api/src/services/payload.ts +++ b/api/src/services/payload.ts @@ -445,7 +445,9 @@ export class PayloadService { const relatedRecords: Partial[] = []; if (Array.isArray(payload[relation.one_field!])) { - for (const relatedRecord of payload[relation.one_field!] || []) { + for (let i = 0; i < (payload[relation.one_field!] || []).length; i++) { + const relatedRecord = (payload[relation.one_field!] || [])[i]; + let record = cloneDeep(relatedRecord); if (typeof relatedRecord === 'string' || typeof relatedRecord === 'number') { @@ -467,6 +469,15 @@ export class PayloadService { }; } + if (relation.sort_field) { + record = { + ...record, + [relation.sort_field]: i + 1, + }; + } + + console.log(record); + relatedRecords.push({ ...record, [relation.many_field]: parent || payload[relation.one_primary!], diff --git a/api/src/types/schema.ts b/api/src/types/schema.ts index 695957987a..33e8afb1e8 100644 --- a/api/src/types/schema.ts +++ b/api/src/types/schema.ts @@ -5,6 +5,10 @@ import { Permission } from './permissions'; export type SchemaOverview = { tables: SO; relations: Relation[]; + collections: { + collection: string; + sort_field: string | null; + }[]; fields: { id: number; collection: string; diff --git a/api/src/utils/get-ast-from-query.ts b/api/src/utils/get-ast-from-query.ts index 62ca2fe993..45f8fd3a59 100644 --- a/api/src/utils/get-ast-from-query.ts +++ b/api/src/utils/get-ast-from-query.ts @@ -61,6 +61,11 @@ export default async function getASTFromQuery( delete query.fields; delete query.deep; + if (!query.sort) { + const sortField = schema.collections.find((collectionInfo) => collectionInfo.collection === collection)?.sort_field; + query.sort = [{ column: sortField || schema.tables[collection].primary, order: 'asc' }]; + } + ast.children = await parseFields(collection, fields, deep); return ast; @@ -180,6 +185,10 @@ export default async function getASTFromQuery( query: getDeepQuery(deep?.[relationalField] || {}), children: await parseFields(relatedCollection, nestedFields as string[], deep?.[relationalField] || {}), }; + + if (relationType === 'o2m' && !child!.query.sort) { + child!.query.sort = [{ column: relation.sort_field || relation.many_primary, order: 'asc' }]; + } } if (child) { diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index 431b308b09..e8628c84a5 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -24,6 +24,10 @@ export async function getSchema(options?: { const relations = await database.select('*').from('directus_relations'); + const collections = await database + .select<{ collection: string; sort_field: string | null }[]>('collection', 'sort_field') + .from('directus_collections'); + const fields = await database .select<{ id: number; collection: string; field: string; special: string }[]>( 'id', @@ -72,6 +76,7 @@ export async function getSchema(options?: { return { tables: schemaOverview, relations: relations, + collections, fields: fields.map((transform) => ({ ...transform, special: transform.special?.split(','), diff --git a/app/src/interfaces/tree-view/index.ts b/app/src/interfaces/tree-view/index.ts new file mode 100644 index 0000000000..407d59c187 --- /dev/null +++ b/app/src/interfaces/tree-view/index.ts @@ -0,0 +1,14 @@ +import { defineInterface } from '../define'; +import InterfaceTreeView from './tree-view.vue'; + +export default defineInterface(({ i18n }) => ({ + id: 'tree-view', + name: i18n.t('tree_view'), + description: i18n.t('interfaces.tree-view.description'), + icon: 'account_tree', + types: ['alias'], + groups: ['o2m'], + relational: true, + component: InterfaceTreeView, + options: [], +})); diff --git a/app/src/interfaces/tree-view/item-preview.vue b/app/src/interfaces/tree-view/item-preview.vue new file mode 100644 index 0000000000..6e0536147c --- /dev/null +++ b/app/src/interfaces/tree-view/item-preview.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/app/src/interfaces/tree-view/nested-draggable.vue b/app/src/interfaces/tree-view/nested-draggable.vue new file mode 100644 index 0000000000..56c288e875 --- /dev/null +++ b/app/src/interfaces/tree-view/nested-draggable.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/app/src/interfaces/tree-view/tree-view.vue b/app/src/interfaces/tree-view/tree-view.vue new file mode 100644 index 0000000000..2ca9f262ee --- /dev/null +++ b/app/src/interfaces/tree-view/tree-view.vue @@ -0,0 +1,336 @@ + + + + + diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 95636c9821..487de12428 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -583,6 +583,7 @@ settings_webhooks: Webhooks settings_presets: Presets & Bookmarks scope: Scope layout: Layout +tree_view: Tree View changes_are_permanent: Changes are permanent preset_name_placeholder: Name of bookmark... preset_search_placeholder: Search query... @@ -984,6 +985,9 @@ interfaces: translations: display_template: Display Template no_collection: No Collection + tree-view: + description: Tree view for nested recursive one-to-many items + recursive_only: The tree view interface only works for recursive relationships. user: user: User description: Select an existing directus user diff --git a/app/src/modules/settings/routes/data-model/field-detail/components/relationship-o2m.vue b/app/src/modules/settings/routes/data-model/field-detail/components/relationship-o2m.vue index 2c80709dab..bb503ebb94 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/components/relationship-o2m.vue +++ b/app/src/modules/settings/routes/data-model/field-detail/components/relationship-o2m.vue @@ -253,9 +253,7 @@ export default defineComponent({ const availableCollections = computed(() => { return orderBy( collectionsStore.state.collections.filter((collection) => { - return ( - collection.collection.startsWith('directus_') === false && collection.collection !== props.collection - ); + return collection.collection.startsWith('directus_') === false; }), ['collection'], ['asc'] @@ -265,7 +263,7 @@ export default defineComponent({ const systemCollections = computed(() => { return orderBy( collectionsStore.state.collections.filter((collection) => { - return collection.collection.startsWith('directus_') === true && collection.collection !== props.collection; + return collection.collection.startsWith('directus_') === true; }), ['collection'], ['asc'] diff --git a/app/src/modules/settings/routes/data-model/field-detail/field-detail.vue b/app/src/modules/settings/routes/data-model/field-detail/field-detail.vue index e99310ee01..6fe26621b9 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/field-detail.vue +++ b/app/src/modules/settings/routes/data-model/field-detail/field-detail.vue @@ -101,7 +101,7 @@