diff --git a/packages/related-items-bundle/README.md b/packages/related-items-bundle/README.md new file mode 100644 index 00000000..ad8162a9 --- /dev/null +++ b/packages/related-items-bundle/README.md @@ -0,0 +1,80 @@ +# Related Items Bundle + +Find all collections, fields or items related to a collection even if it doesn't include the reverse fields from the supported relationships: Many to One (m2o), One to Many (o2m), Many to Many (m2m) or Many to Any (m2a). + +![Related Items Bundle](https://raw.githubusercontent.com/directus-labs/extensions/main/packages/related-items-bundle/docs/related-items-bundle.jpg) + +You can include system collections such as Users and Files and benefit from seeing all the relations to other collections. For example, when viewing a file in the File Library, you will see what collections and items reference the file and know what will be impacted if it is edited or deleted. + +## Usage + +Once installed and configured, visit an item for an included collection and scroll to the bottom of the page. By default, all related items are returned with pagination over 10 records. Use the collection filters at the top to easily see the related items in that collection. + +Click on an item to open the draw for more information or make changes to that item. However, some system tables are not supported. + +## Requirements + +- Directus 11.1.2+ +- Admin user to initialize module + +## Installation + +Refer to the Official Guide for details on installing the extension from the Marketplace or manually. + +## How to configure this module + +1. Using an Admin user, go to the project settings `/admin/settings/project` and scroll to the bottom +2. Click on the field labelled "Related Items Collections" +3. Tick the collections to include from the dropdown field +4. Save changes + +When opening the project settings for the first time, the module will automatically add the new system field at the bottom of the page. Any selection of the collections will create a new alias field within that collection's Data model. The new interface can be repositioned or customized but any changes to these fields will be lost if you choose to exclude the collection in future. Admin permissions are required during these steps. + +## Permissions + +This extension uses the current user's policy/role permissions and will only show the permitted data. Please refer to your Access Policies to ensure your users have required access. + +## API Reference + +This bundle contains an endpoint extension. The data can be queried using the following endpoint: + +``` +GET /related-items// +``` + +The response will be an array of related collections and for each one, a secondary array of any related items from that collection. For example: + +``` +{ + "collection": "directus_files", + "fields": [ + "directus_files_id.id", + "directus_files_id.title", + "directus_files_id.type" + ], + "relation": "m2m", + "translations": null, + "field": "article_id", + "junction_field": "directus_files_id", + "primary_key": "id", + "template": "{{ title }}", + "items": [ + { + "directus_files_id": { + "id": "x0x1234x-5xx6-7890-x123-xxx4xx56789x", + "title": "Annual Leave Policy", + "type": "image/jpeg" + } + }, + { + "directus_files_id": { + "id": "x9x8765x-5xx6-7890-x123-xxx4xx56789x", + "title": "Brand Guidelines", + "type": "image/png" + } + } + ] +} +``` + +_Note: The fields and primary key can be used as context when processing the items or rendering an output._ \ No newline at end of file diff --git a/packages/related-items-bundle/docs/related-items-bundle.jpg b/packages/related-items-bundle/docs/related-items-bundle.jpg new file mode 100644 index 00000000..c31d96b1 Binary files /dev/null and b/packages/related-items-bundle/docs/related-items-bundle.jpg differ diff --git a/packages/related-items-bundle/package.json b/packages/related-items-bundle/package.json new file mode 100644 index 00000000..39d84032 --- /dev/null +++ b/packages/related-items-bundle/package.json @@ -0,0 +1,81 @@ +{ + "name": "@directus-labs/related-items-bundle", + "type": "module", + "version": "1.0.0", + "description": "Show all related items across selected collections.", + "author": "Directus Labs", + "contributors": [ + { + "name": "Tim Butterfield", + "email": "tim.butterfield@me.com", + "url": "https://www.npmjs.com/~timio23" + } + ], + "license": "MIT", + "keywords": [ + "directus", + "directus-extension", + "directus-extension-bundle", + "relational", + "m2o", + "m2m", + "o2m", + "m2a" + ], + "icon": "extension", + "files": [ + "dist" + ], + "directus:extension": { + "type": "bundle", + "path": { + "app": "dist/app.js", + "api": "dist/api.js" + }, + "entries": [ + { + "type": "module", + "name": "related-items-module", + "source": "src/related-items-module/index.ts" + }, + { + "type": "endpoint", + "name": "related-items-endpoint", + "source": "src/related-items-endpoint/index.ts" + }, + { + "type": "interface", + "name": "related-items-interface", + "source": "src/related-items-interface/index.ts" + }, + { + "type": "hook", + "name": "related-items-hook", + "source": "src/related-items-hook/index.ts" + } + ], + "host": "^11.1.2" + }, + "scripts": { + "build": "directus-extension build", + "dev": "directus-extension build -w --no-minify", + "link": "directus-extension link", + "add": "directus-extension add", + "validate": "directus-extension validate" + }, + "dependencies": { + "@directus/eslint-config": "^0.1.0", + "@directus/format-title": "^12.0.1", + "@directus/system-data": "^3.1.0", + "vue-i18n": "^9.14.0", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@directus/constants": "^13.0.0", + "@directus/extensions-sdk": "^13.0.1", + "@directus/types": "^13.0.0", + "@directus/utils": "^13.0.0", + "typescript": "^5.6.3", + "vue": "^3.5.13" + } +} diff --git a/packages/related-items-bundle/src/related-items-endpoint/index.ts b/packages/related-items-bundle/src/related-items-endpoint/index.ts new file mode 100644 index 00000000..0d8991f7 --- /dev/null +++ b/packages/related-items-bundle/src/related-items-endpoint/index.ts @@ -0,0 +1,318 @@ +import type { Accountability, Collection, Field, Query, Relation } from '@directus/types'; +import type { RelatedItem } from '../types'; +import { defineEndpoint } from '@directus/extensions-sdk'; +import { getFieldsFromTemplate } from '@directus/utils'; +import { displayTemplate } from './utils/display-template'; + +interface CollectionDetail { + property: Collection; + display_template: string | null | undefined; + template_fields: string[]; + field_name: string; + many_field?: string | null; + item_id: number | string | number[] | string[]; + fields: Field[]; + primaryKey: string; +} + +export default defineEndpoint({ + id: 'related-items', + handler: (router, { services, getSchema }) => { + const { + FieldsService, + CollectionsService, + ItemsService, + RelationsService, + + } = services; + + router.get('/:collection/:id', async (req, res) => { + const collection: string = req.params.collection; + const primaryId: number | string = req.params.id; + const query = req.query; + + const accountability: Accountability | null = 'accountability' in req ? req.accountability as Accountability : null; + const schema = await getSchema(); + + const collectionService = new CollectionsService({ + accountability, + schema, + }); + + const relationService = new RelationsService({ + accountability, + schema, + }); + + const fieldService = new FieldsService({ + accountability, + schema, + }); + + async function fetchItem({ collection, id, query }: { collection: string | null; id?: number | string; query?: Query }) { + if (!collection || (!id && !query)) return; + + const itemService = new ItemsService(collection, { + accountability, + schema, + }); + + try { + return id ? await itemService.readOne(id) : await itemService.readByQuery(query); + } + catch (error) { + console.error(error); + return id ? null : []; + } + } + + const requested_item: Record = await fetchItem({ collection, id: primaryId, query }); + const relations: Relation[] = await relationService.readAll(); + const related_o2m_collections: Relation[] = relations.filter((r) => r.collection === collection); + const related_m2o_collections: Relation[] = relations.filter((r) => r.related_collection === collection || r.meta?.one_allowed_collections?.includes(collection)); + + async function fetchCollectionInfo({ collection, field_name, item_id, relation }: { collection: string | null; field_name: string; item_id: number | string | number[] | string[]; relation: Relation }): Promise { + if (!collection) return null; + const current_collection: Collection = await collectionService.readOne(collection); + const display_template = displayTemplate(collection, current_collection.meta?.display_template); + const template_fields = getFieldsFromTemplate(display_template).filter((t) => !t.includes('$')); + const fields: Field[] = await fieldService.readAll(collection); + const primaryKey: string = fields.find((f) => f.schema?.is_primary_key)?.field ?? 'id'; + return { property: current_collection, display_template, template_fields, field_name, many_field: relation.meta?.junction_field, item_id, fields, primaryKey }; + } + + async function fetchM2aCollectionInfo({ collections, m2m_relation, relation }: { collections: string[]; m2m_relation: Relation; relation: Relation }) { + if (collections.length === 0 || m2m_relation === null) return []; + const promises = collections.map(async (collection) => { + if (m2m_relation.meta?.many_field && m2m_relation.meta.junction_field && m2m_relation.meta?.one_collection_field) { + const many_field: string = m2m_relation.meta?.many_field; + const junction_field: string = m2m_relation.meta.junction_field; + const one_collection_field: string = m2m_relation.meta?.one_collection_field; + + try { + const m2a_junction_items: Record[] = await fetchItem({ collection: m2m_relation.collection, query: { + fields: [ + many_field, + ], + filter: { + [junction_field]: { + _eq: primaryId, + }, + [one_collection_field]: { + _eq: collection, + }, + }, + limit: -1, + } }); + const item_ids = m2a_junction_items.map((i) => i[many_field]) as number[] | string[]; + + return await fetchCollectionInfo({ collection, field_name: relation.meta?.one_field ?? relation.field, item_id: item_ids, relation }); + } + catch (error) { + console.error(error); + return null; + } + } + else { + return null; + } + }); + return Promise.all(promises); + } + + async function build_output({ o2m, is_m2a, is_m2a_junction, has_junction, relation_type, collection, related_collection, relation }: { o2m: boolean; is_m2a: boolean; is_m2a_junction: boolean; has_junction: boolean; relation_type: string; collection: CollectionDetail; related_collection?: string; relation: Relation }) { + const itemFields = [ + relation_type === 'm2m' ? `${collection.many_field}.${collection.primaryKey}` : collection.primaryKey, + ...collection.template_fields.map((f) => relation_type === 'm2m' ? `${collection.many_field}.${f}` : f), + ...( + collection.property.collection === 'directus_files' + ? [relation_type === 'm2m' ? `${collection.many_field}.type` : 'type'] + : [] + ), + ...( + collection.property.collection === 'directus_comments' + ? ['date_created'] + : [] + ), + ...( + collection.property.collection === 'directus_panels' + ? ['dashboard.name'] + : [] + ), + ...( + collection.property.collection === 'directus_notifications' + ? ['timestamp'] + : [] + ), + ]; + + const itemFilters = o2m || is_m2a || is_m2a_junction + ? (Array.isArray(collection.item_id) + ? { + [collection.primaryKey]: { + _in: collection.item_id, + }, + } + : { + [collection.field_name]: { + _eq: collection.item_id, + }, + }) + : { + [has_junction ? collection.field_name : collection.primaryKey]: { + _eq: collection.item_id, + }, + }; + + const collectionInfo = { + collection: collection.property.collection, + fields: itemFields, + relation: relation_type, + translations: collection.property.meta?.translations, + field: collection.field_name, + junction_field: has_junction ? relation.meta?.junction_field : null, + primary_key: collection.primaryKey, + template: collection.display_template ?? `{{ ${collection.primaryKey} }}`, + } as RelatedItem; + + try { + const relatedItems = await fetchItem({ collection: is_m2a || is_m2a_junction || related_collection === undefined ? collection.property.collection : related_collection, query: { + fields: itemFields, + // @ts-expect-error saying _and is missing but it's not required + filter: itemFilters, + limit: -1, + } }); + return { + ...collectionInfo, + items: relatedItems.filter((value: any, index: number, self: any) => + index === self.findIndex((t: any) => ( + t[collectionInfo.primary_key] === value[collectionInfo.primary_key] + )), + ), + }; + } + catch { + return { + ...collectionInfo, + items: [], + }; + } + } + + async function relatedCollections(relations: Relation[]) { + const promises = relations.map(async (r) => { + const o2m = r.related_collection === collection; + const has_junction = (o2m || r.field === 'item') && r.meta?.junction_field !== null; + const is_m2a = r.meta?.junction_field === 'item' && (has_junction || !r.related_collection); + const is_m2a_junction = r.field === 'item' && !r.related_collection && r.collection === collection; + + const RelationType = () => { + if (is_m2a || is_m2a_junction) return 'm2a'; + if (has_junction) return 'm2m'; + if (o2m) return 'o2m'; + return 'm2o'; + }; + + const calculateField = () => { + if (has_junction && o2m && !is_m2a) return r.field; + if (o2m) return r.meta?.one_field ?? r.field; + return r.meta?.many_field ?? r.field; + }; + + const field = calculateField(); + + const relatedCollection = () => { + if (o2m) return r.collection; + if (r.related_collection) return r.related_collection; + return r.collection; + }; + + const related_collection = await fetchCollectionInfo({ + collection: relatedCollection(), + field_name: field, + item_id: o2m ? primaryId : requested_item[field], + relation: r, + }); + + const m2m_relation = has_junction ? await relationService.readOne(related_collection?.property.collection, r.meta?.junction_field) : null; + + const m2m_related_collection = m2m_relation + ? await fetchCollectionInfo({ + collection: m2m_relation ? m2m_relation.related_collection : null, + field_name: field, + item_id: field === 'item' || has_junction ? primaryId : (r.meta?.many_field ? requested_item[r.meta.many_field] : requested_item[field]), + relation: r, + }) + : null; + + const m2a_allowed_collections = m2m_relation && m2m_relation.meta?.one_allowed_collections !== null + ? m2m_relation.meta?.one_allowed_collections + : (is_m2a_junction && r.meta?.one_allowed_collections + ? r.meta?.one_allowed_collections + : []); + + const m2a_related_collections = is_m2a || is_m2a_junction + ? await fetchM2aCollectionInfo({ + collections: m2a_allowed_collections, + m2m_relation: is_m2a_junction + ? { + collection: r.collection, + meta: { + many_field: 'item', + one_collection_field: 'collection', + junction_field: 'id', + }, + } + : m2m_relation, + relation: r, + }) + : []; + + const collections: (CollectionDetail | null)[] = is_m2a || is_m2a_junction + ? m2a_related_collections + : (has_junction + ? [m2m_related_collection] + : [related_collection]); + + async function processCollections(collections: (CollectionDetail | null)[], related_collection: string) { + const output_promise = collections.filter((c) => c && c.item_id).map(async (c) => await build_output({ + o2m, + is_m2a, + is_m2a_junction, + has_junction, + relation_type: RelationType(), + collection: c as CollectionDetail, + related_collection, + relation: r, + })); + return Promise.all(output_promise); + } + + return related_collection ? (await processCollections(collections, related_collection.property.collection)) as RelatedItem[] : []; + }); + return Promise.all(promises); + } + + try { + const related_content: RelatedItem[] = []; + + const o2m_collection_items = await relatedCollections(related_o2m_collections); + const m2o_collection_items = await relatedCollections(related_m2o_collections); + + o2m_collection_items.forEach((c) => { + related_content.push(...c); + }); + + m2o_collection_items.forEach((c) => { + related_content.push(...c); + }); + + res.json(related_content); + } + catch (error) { + console.error(error); + res.send(500).json({ error: 'Internal Server Error' }); + } + }); + }, +}); diff --git a/packages/related-items-bundle/src/related-items-endpoint/utils/display-template.ts b/packages/related-items-bundle/src/related-items-endpoint/utils/display-template.ts new file mode 100644 index 00000000..48da029a --- /dev/null +++ b/packages/related-items-bundle/src/related-items-endpoint/utils/display-template.ts @@ -0,0 +1,32 @@ +export function displayTemplate(collection: string | null, display_template?: string | null) { + switch (collection) { + case 'directus_comments': { + return '{{ comment }}'; + } + case 'directus_roles': + case 'directus_dashboards': + case 'directus_policies': + case 'directus_flows': + case 'directus_operations': { + return '{{ name }}'; + } + case 'directus_panels': { + return '{{ type }}'; + } + case 'directus_activity': { + return '{{ action }} ID:{{ item }} in {{ collection }}'; + } + case 'directus_notifications': { + return '{{ subject }}'; + } + case 'directus_files': { + return '{{ title }}'; + } + case 'directus_presets': { + return '{{ collection }} {{ bookmark }}'; + } + // No default + } + + return display_template; +} diff --git a/packages/related-items-bundle/src/related-items-hook/index.ts b/packages/related-items-bundle/src/related-items-hook/index.ts new file mode 100644 index 00000000..9e3b651d --- /dev/null +++ b/packages/related-items-bundle/src/related-items-hook/index.ts @@ -0,0 +1,40 @@ +import type { Field } from '@directus/types'; +import { defineHook } from '@directus/extensions-sdk'; +import { alias_field } from '../shared/alias-field'; + +export default defineHook(({ action }, { services }) => { + const { FieldsService } = services; + + action('settings.update', async ({ collection, payload }, { accountability, schema }) => { + if (collection === 'directus_settings' && 'related_items_collections' in payload) { + const collections: string[] = payload.related_items_collections; + + const fieldService = new FieldsService({ + accountability, + schema, + }); + + const fields: Field[] = await fieldService.readAll(); + const existingFields = fields.filter((f) => f.field === 'directus_related_items_alias'); + const existingFieldsToDelete = existingFields.filter((f) => !collections.includes(f.collection)); + + existingFieldsToDelete.forEach(async (f) => { + try { + await fieldService.deleteField(f.collection, f.field); + } + catch (error) { + console.error(error); + } + }); + + collections.filter((c) => !existingFields.some((f) => f.collection === c)).forEach(async (c) => { + try { + await fieldService.createField(c, alias_field); + } + catch (error) { + console.error(error); + } + }); + } + }); +}); diff --git a/packages/related-items-bundle/src/related-items-interface/index.ts b/packages/related-items-bundle/src/related-items-interface/index.ts new file mode 100644 index 00000000..c30e0e47 --- /dev/null +++ b/packages/related-items-bundle/src/related-items-interface/index.ts @@ -0,0 +1,15 @@ +import { defineInterface } from '@directus/extensions-sdk'; +import InterfaceComponent from './interface.vue'; + +export default defineInterface({ + id: 'related-items-interface', + name: 'Related Items', + icon: 'hub', + description: 'Show related items for the current record.', + component: InterfaceComponent, + hideLabel: true, + options: null, + types: ['alias'], + localTypes: ['presentation'], + group: 'relational', +}); diff --git a/packages/related-items-bundle/src/related-items-interface/interface.vue b/packages/related-items-bundle/src/related-items-interface/interface.vue new file mode 100644 index 00000000..c32bcbc0 --- /dev/null +++ b/packages/related-items-bundle/src/related-items-interface/interface.vue @@ -0,0 +1,27 @@ + + + diff --git a/packages/related-items-bundle/src/related-items-interface/related-items-list.vue b/packages/related-items-bundle/src/related-items-interface/related-items-list.vue new file mode 100644 index 00000000..804f3b6c --- /dev/null +++ b/packages/related-items-bundle/src/related-items-interface/related-items-list.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/packages/related-items-bundle/src/related-items-interface/utils/get-route.ts b/packages/related-items-bundle/src/related-items-interface/utils/get-route.ts new file mode 100644 index 00000000..251e08cf --- /dev/null +++ b/packages/related-items-bundle/src/related-items-interface/utils/get-route.ts @@ -0,0 +1,59 @@ +import { isSystemCollection } from '@directus/system-data'; + +const accessibleSystemCollections = { + directus_users: { route: '/users' }, + directus_files: { route: '/files' }, + directus_dashboards: { route: '/insights' }, + directus_activity: { route: '/activity' }, + directus_settings: { route: '/settings/project', singleton: true }, + directus_collections: { route: '/settings/data-model' }, + directus_roles: { route: '/settings/roles' }, + directus_presets: { route: '/settings/presets' }, + directus_translations: { route: '/settings/translations' }, + directus_webhooks: { route: '/settings/webhooks' }, + directus_flows: { route: '/settings/flows' }, +} as const; + +function isAccessibleSystemCollection(collection: string): collection is keyof typeof accessibleSystemCollections { + return collection in accessibleSystemCollections; +} + +/** + * Get the route of an accessible system collection in the admin app for a given collection name + * + * @param collection - Collection name + * @returns - URL route for the system collection, empty string if not an accessible system collection + */ +export function getSystemCollectionRoute(collection: string) { + if (isAccessibleSystemCollection(collection)) return accessibleSystemCollections[collection].route; + + return ''; +} + +/** + * Get the route of a collection in the admin app for a given collection name + * + * @param collection - Collection name + * @returns - URL route for the collection + */ +export function getCollectionRoute(collection: string | null) { + if (collection === null) return ''; + + if (isSystemCollection(collection)) return getSystemCollectionRoute(collection); + + return `/content/${collection}`; +} +export function getItemRoute(collection: string | null, primaryKey: string | number) { + if (collection === null) return ''; + + const collectionRoute = getCollectionRoute(collection); + + if (collectionRoute === '') return ''; + + if (isAccessibleSystemCollection(collection) && 'singleton' in accessibleSystemCollections[collection]) + return collectionRoute; + + const itemRoute = primaryKey === '+' ? primaryKey : encodeURIComponent(primaryKey); + + return `${collectionRoute}/${itemRoute}`; +} diff --git a/packages/related-items-bundle/src/related-items-module/index.ts b/packages/related-items-bundle/src/related-items-module/index.ts new file mode 100644 index 00000000..4b3cfd25 --- /dev/null +++ b/packages/related-items-bundle/src/related-items-module/index.ts @@ -0,0 +1,16 @@ +import { defineModule } from '@directus/extensions-sdk'; +import { injectRelatedItemsField } from './utils/inject-related-items-field'; + +export default defineModule({ + id: 'related-items', + hidden: true, + name: 'Related Items', + icon: 'hub', + routes: [], + preRegisterCheck(user) { + const admin = user.admin_access; + if (!admin) return false; + injectRelatedItemsField(); + return true; + }, +}); diff --git a/packages/related-items-bundle/src/related-items-module/settings-field.ts b/packages/related-items-bundle/src/related-items-module/settings-field.ts new file mode 100644 index 00000000..4226c0cc --- /dev/null +++ b/packages/related-items-bundle/src/related-items-module/settings-field.ts @@ -0,0 +1,22 @@ +import type { DeepPartial, Field } from '@directus/types'; + +export const system_field: DeepPartial = { + field: 'related_items_collections', + type: 'string', + name: 'Related Items Collections', + meta: { + required: true, + interface: 'system-collection', + special: [ + 'cast-json', + ], + options: { + includeSystem: true, + includeSingleton: true, + multiple: true, + allowNone: true, + }, + note: 'Select the collections to include the related items module.', + width: 'full', + }, +}; diff --git a/packages/related-items-bundle/src/related-items-module/shim.d.ts b/packages/related-items-bundle/src/related-items-module/shim.d.ts new file mode 100644 index 00000000..afaa5e81 --- /dev/null +++ b/packages/related-items-bundle/src/related-items-module/shim.d.ts @@ -0,0 +1,6 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + + const component: DefineComponent<{}, {}, any>; + export default component; +} diff --git a/packages/related-items-bundle/src/related-items-module/utils/get-directus-app.ts b/packages/related-items-bundle/src/related-items-module/utils/get-directus-app.ts new file mode 100644 index 00000000..f44cdd6f --- /dev/null +++ b/packages/related-items-bundle/src/related-items-module/utils/get-directus-app.ts @@ -0,0 +1,4 @@ +export function getDirectusApp() { + // @ts-expect-error __vue_app__ does not exist error + return document.querySelector('#app')?.__vue_app__; +} diff --git a/packages/related-items-bundle/src/related-items-module/utils/get-directus-router.ts b/packages/related-items-bundle/src/related-items-module/utils/get-directus-router.ts new file mode 100644 index 00000000..e4057d8f --- /dev/null +++ b/packages/related-items-bundle/src/related-items-module/utils/get-directus-router.ts @@ -0,0 +1,7 @@ +import type { Router } from 'vue-router'; +import { getDirectusApp } from './get-directus-app'; + +export function getDirectusRouter(): Router { + const app = getDirectusApp(); + return app?.config?.globalProperties?.$router; +} diff --git a/packages/related-items-bundle/src/related-items-module/utils/inject-related-items-field.ts b/packages/related-items-bundle/src/related-items-module/utils/inject-related-items-field.ts new file mode 100644 index 00000000..c1c23cd8 --- /dev/null +++ b/packages/related-items-bundle/src/related-items-module/utils/inject-related-items-field.ts @@ -0,0 +1,77 @@ +import { STORES_INJECT } from '@directus/constants'; +import { nextTick } from 'vue'; +import { system_field } from '../settings-field'; +import { getDirectusApp } from './get-directus-app'; +import { getDirectusRouter } from './get-directus-router'; +import { unexpectedError } from './unexpected-error'; + +const config = { attributes: true, childList: true, subtree: true }; +const observer = new MutationObserver((mutations) => { + if (mutations.some((e) => (e.type === 'attributes' && e.attributeName === 'disabled' && (e.target as HTMLButtonElement).disabled))) { + hydrateFields(); + } +}); + +export function injectRelatedItemsField() { + const router = getDirectusRouter(); + + if (router) { + router.afterEach(async (to: Record) => { + if (observer) + observer.disconnect(); + + if (to.name === 'settings-project') { + initializeApp(); + } + }); + } +} + +async function initializeApp(retry: number = 0) { + const titleContainer = document.querySelector('.title-container'); + + if (!titleContainer) { + if (retry < 3) { + setTimeout(() => initializeApp(retry + 1), 100); + } + + return; + } + + const directusApp = getDirectusApp(); + const stores = directusApp._container._vnode.component.provides[STORES_INJECT]; + + const { useFieldsStore, useSettingsStore } = stores; + const fieldStore = useFieldsStore(); + const settingsStore = useSettingsStore(); + + try { + if (!('related_items_collections' in settingsStore.settings)) { + await fieldStore.upsertField('directus_settings', 'related_items_collections', system_field); + await settingsStore.hydrate(); + } + } + catch (error: any) { + unexpectedError(error, stores); + } + + await nextTick(); + const saveButtons = document.querySelector('.action-buttons'); + observer.observe(saveButtons as HTMLElement, config); +} + +async function hydrateFields() { + const router = getDirectusRouter(); + + if (router.currentRoute.value.name !== 'settings-project') return; + + const directusApp = getDirectusApp(); + const stores = directusApp._container._vnode.component.provides[STORES_INJECT]; + const { useFieldsStore } = stores; + const fieldStore = useFieldsStore(); + + setTimeout( + await fieldStore.hydrate(), + 2000, + ); +} diff --git a/packages/related-items-bundle/src/related-items-module/utils/unexpected-error.ts b/packages/related-items-bundle/src/related-items-module/utils/unexpected-error.ts new file mode 100644 index 00000000..442fdf13 --- /dev/null +++ b/packages/related-items-bundle/src/related-items-module/utils/unexpected-error.ts @@ -0,0 +1,19 @@ +export function unexpectedError(error: any, stores: any): void { + const { useNotificationsStore } = stores; + const store = useNotificationsStore(); + + const code = + error?.response?.data?.errors?.[0]?.extensions?.code + || error?.extensions?.code + || 'UNKNOWN'; + + console.warn(error); + + store.add({ + title: code, + type: 'error', + code, + dialog: true, + error, + }); +} diff --git a/packages/related-items-bundle/src/shared/alias-field.ts b/packages/related-items-bundle/src/shared/alias-field.ts new file mode 100644 index 00000000..4b9d82e9 --- /dev/null +++ b/packages/related-items-bundle/src/shared/alias-field.ts @@ -0,0 +1,20 @@ +import type { DeepPartial, Field } from '@directus/types'; + +export const alias_field: DeepPartial = { + field: 'directus_related_items_alias', + type: 'alias', + meta: { + interface: 'related-items-interface', + special: [ + 'alias', + 'no-data', + ], + translations: [ + { + language: 'en-US', + translation: 'Related Items', + }, + ], + width: 'full', + }, +}; diff --git a/packages/related-items-bundle/src/types.ts b/packages/related-items-bundle/src/types.ts new file mode 100644 index 00000000..e277f2e6 --- /dev/null +++ b/packages/related-items-bundle/src/types.ts @@ -0,0 +1,35 @@ +export interface RelatedItem { + collection: string; + relation: 'm2a' | 'm2m' | 'm2o' | 'o2m'; + field?: string | null; + translations: Translations[] | null; + fields: string[]; + junction_field?: string | null; + primary_key: string; + template?: string | null; + items: Record; +} + +export interface RelatedItemObject { + collection: string; + disabled: boolean; + field?: string | null; + relation: 'm2a' | 'm2m' | 'm2o' | 'o2m'; + fields: string[]; + template?: string | null; + item_id: string; + data: Record; +} + +export interface CollectionFilters { + collection: string; + name: string; + item_count: number; +} + +interface Translations { + language: string; + translation: string; + singular: string; + plural: string; +} diff --git a/packages/related-items-bundle/tsconfig.json b/packages/related-items-bundle/tsconfig.json new file mode 100644 index 00000000..6f7c4d5d --- /dev/null +++ b/packages/related-items-bundle/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM"], + "rootDir": "./src", + "moduleResolution": "node", + "resolveJsonModule": false, + "strict": true, + "strictBindCallApply": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "strictPropertyInitialization": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["./src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 08f89b1c..1dd2000d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -934,6 +934,43 @@ importers: specifier: ^3.4.30 version: 3.5.13(typescript@5.8.3) + packages/related-items-bundle: + dependencies: + '@directus/eslint-config': + specifier: ^0.1.0 + version: 0.1.0(@typescript-eslint/utils@8.32.1(eslint@9.26.0)(typescript@5.8.3))(@vue/compiler-sfc@3.5.14)(eslint@9.26.0)(typescript@5.8.3) + '@directus/format-title': + specifier: ^12.0.1 + version: 12.0.1 + '@directus/system-data': + specifier: ^3.1.0 + version: 3.1.0 + vue-i18n: + specifier: ^9.14.0 + version: 9.14.4(vue@3.5.13(typescript@5.8.3)) + vue-router: + specifier: ^4.4.5 + version: 4.5.1(vue@3.5.13(typescript@5.8.3)) + devDependencies: + '@directus/constants': + specifier: ^13.0.0 + version: 13.0.1 + '@directus/extensions-sdk': + specifier: ^13.0.1 + version: 13.0.4(@types/node@22.15.19)(@unhead/vue@2.0.9(vue@3.5.13(typescript@5.8.3)))(knex@3.1.0)(pinia@3.0.2(typescript@5.8.3)(vue@3.5.13(typescript@5.8.3)))(sass@1.89.0)(terser@5.39.2)(typescript@5.8.3)(vue-router@4.5.1(vue@3.5.13(typescript@5.8.3))) + '@directus/types': + specifier: ^13.0.0 + version: 13.1.1(knex@3.1.0)(vue@3.5.13(typescript@5.8.3)) + '@directus/utils': + specifier: ^13.0.0 + version: 13.0.5(vue@3.5.13(typescript@5.8.3)) + typescript: + specifier: ^5.6.3 + version: 5.8.3 + vue: + specifier: ^3.5.13 + version: 3.5.13(typescript@5.8.3) + packages/resend-operation: devDependencies: '@directus/extensions-sdk':