diff --git a/api/package-lock.json b/api/package-lock.json index 6df0e51aac..4bba55a5a5 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -27875,6 +27875,11 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==" }, + "eventemitter2": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.3.tgz", + "integrity": "sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ==" + }, "events": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", diff --git a/api/package.json b/api/package.json index c77302432e..fe430d85a4 100644 --- a/api/package.json +++ b/api/package.json @@ -80,6 +80,7 @@ "cookie-parser": "^1.4.5", "cors": "^2.8.5", "dotenv": "^8.2.0", + "eventemitter2": "^6.4.3", "execa": "^4.0.3", "exif-reader": "^1.0.3", "express": "^4.17.1", diff --git a/api/src/app.ts b/api/src/app.ts index cef1c0a8eb..d8ea0c0745 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -36,10 +36,12 @@ import webhooksRouter from './controllers/webhooks'; import notFoundHandler from './controllers/not-found'; import sanitizeQuery from './middleware/sanitize-query'; +import WebhooksService from './services/webhooks'; validateEnv(['KEY', 'SECRET']); const app = express(); + app.disable('x-powered-by'); app.set('trust proxy', true); @@ -99,4 +101,8 @@ app.use(respond); app.use(notFoundHandler); app.use(errorHandler); +// Register all webhooks +const webhooksService = new WebhooksService(); +webhooksService.register(); + export default app; diff --git a/api/src/controllers/folders.ts b/api/src/controllers/folders.ts index c66ef2a8ea..fe74a67e0c 100644 --- a/api/src/controllers/folders.ts +++ b/api/src/controllers/folders.ts @@ -14,7 +14,7 @@ router.post( res.locals.payload = { data: record || null }; return next(); - }), + }) ); router.get( @@ -28,39 +28,42 @@ router.get( res.locals.payload = { data: records || null, meta }; return next(); - }), + }) ); router.get( '/:pk', asyncHandler(async (req, res, next) => { const service = new FoldersService({ accountability: req.accountability }); - const record = await service.readByKey(req.params.pk, req.sanitizedQuery); + const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const record = await service.readByKey(primaryKey as any, req.sanitizedQuery); res.locals.payload = { data: record || null }; return next(); - }), + }) ); router.patch( '/:pk', asyncHandler(async (req, res, next) => { const service = new FoldersService({ accountability: req.accountability }); - const primaryKey = await service.update(req.body, req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const primaryKey = await service.update(req.body, pk as any); const record = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: record || null }; return next(); - }), + }) ); router.delete( '/:pk', asyncHandler(async (req, res, next) => { const service = new FoldersService({ accountability: req.accountability }); - await service.delete(req.params.pk); + const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + await service.delete(primaryKey as any); return next(); - }), + }) ); export default router; diff --git a/api/src/controllers/permissions.ts b/api/src/controllers/permissions.ts index 0727f5ef35..8c8e7a2e3c 100644 --- a/api/src/controllers/permissions.ts +++ b/api/src/controllers/permissions.ts @@ -16,7 +16,7 @@ router.post( res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.get( @@ -30,7 +30,7 @@ router.get( res.locals.payload = { data: item || null, meta }; return next(); - }), + }) ); router.get( @@ -54,7 +54,7 @@ router.get( res.locals.payload = { data: items || null }; return next(); - }), + }) ); router.get( @@ -62,33 +62,35 @@ router.get( asyncHandler(async (req, res, next) => { if (req.path.endsWith('me')) return next(); const service = new PermissionsService({ accountability: req.accountability }); - const record = await service.readByKey(Number(req.params.pk), req.sanitizedQuery); + const primaryKey = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const record = await service.readByKey(primaryKey as any, req.sanitizedQuery); res.locals.payload = { data: record || null }; return next(); - }), + }) ); router.patch( '/:pk', asyncHandler(async (req, res, next) => { const service = new PermissionsService({ accountability: req.accountability }); - const primaryKey = await service.update(req.body, Number(req.params.pk)); - + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const primaryKey = await service.update(req.body, pk as any); const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.delete( '/:pk', asyncHandler(async (req, res, next) => { const service = new PermissionsService({ accountability: req.accountability }); - await service.delete(Number(req.params.pk)); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + await service.delete(pk as any); return next(); - }), + }) ); export default router; diff --git a/api/src/controllers/presets.ts b/api/src/controllers/presets.ts index ec56310c9f..abd9300925 100644 --- a/api/src/controllers/presets.ts +++ b/api/src/controllers/presets.ts @@ -35,7 +35,8 @@ router.get( '/:pk', asyncHandler(async (req, res, next) => { const service = new PresetsService({ accountability: req.accountability }); - const record = await service.readByKey(req.params.pk, req.sanitizedQuery); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const record = await service.readByKey(pk as any, req.sanitizedQuery); res.locals.payload = { data: record || null }; return next(); @@ -46,7 +47,8 @@ router.patch( '/:pk', asyncHandler(async (req, res, next) => { const service = new PresetsService({ accountability: req.accountability }); - const primaryKey = await service.update(req.body, req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const primaryKey = await service.update(req.body, pk as any); const record = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: record || null }; @@ -58,7 +60,8 @@ router.delete( '/:pk', asyncHandler(async (req, res, next) => { const service = new PresetsService({ accountability: req.accountability }); - await service.delete(req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + await service.delete(pk as any); return next(); }) ); diff --git a/api/src/controllers/relations.ts b/api/src/controllers/relations.ts index 03f8f2dc12..246ca3f0cd 100644 --- a/api/src/controllers/relations.ts +++ b/api/src/controllers/relations.ts @@ -13,7 +13,7 @@ router.post( const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.get( @@ -27,37 +27,40 @@ router.get( res.locals.payload = { data: records || null, meta }; return next(); - }), + }) ); router.get( '/:pk', asyncHandler(async (req, res, next) => { const service = new RelationsService({ accountability: req.accountability }); - const record = await service.readByKey(req.params.pk, req.sanitizedQuery); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const record = await service.readByKey(pk as any, req.sanitizedQuery); res.locals.payload = { data: record || null }; return next(); - }), + }) ); router.patch( '/:pk', asyncHandler(async (req, res, next) => { const service = new RelationsService({ accountability: req.accountability }); - const primaryKey = await service.update(req.body, req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const primaryKey = await service.update(req.body, pk as any); const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.delete( '/:pk', asyncHandler(async (req, res, next) => { const service = new RelationsService({ accountability: req.accountability }); - await service.delete(Number(req.params.pk)); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + await service.delete(pk as any); return next(); - }), + }) ); export default router; diff --git a/api/src/controllers/revisions.ts b/api/src/controllers/revisions.ts index 23d7a08490..eccb50a3ea 100644 --- a/api/src/controllers/revisions.ts +++ b/api/src/controllers/revisions.ts @@ -16,17 +16,18 @@ router.get( res.locals.payload = { data: records || null, meta }; return next(); - }), + }) ); router.get( '/:pk', asyncHandler(async (req, res, next) => { const service = new RevisionsService({ accountability: req.accountability }); - const record = await service.readByKey(req.params.pk, req.sanitizedQuery); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const record = await service.readByKey(pk as any, req.sanitizedQuery); res.locals.payload = { data: record || null }; return next(); - }), + }) ); export default router; diff --git a/api/src/controllers/roles.ts b/api/src/controllers/roles.ts index c7fcc59da2..80a7ac8e37 100644 --- a/api/src/controllers/roles.ts +++ b/api/src/controllers/roles.ts @@ -13,7 +13,7 @@ router.post( const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.get( @@ -27,37 +27,40 @@ router.get( res.locals.payload = { data: records || null, meta }; return next(); - }), + }) ); router.get( '/:pk', asyncHandler(async (req, res, next) => { const service = new RolesService({ accountability: req.accountability }); - const record = await service.readByKey(req.params.pk, req.sanitizedQuery); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const record = await service.readByKey(pk as any, req.sanitizedQuery); res.locals.payload = { data: record || null }; return next(); - }), + }) ); router.patch( '/:pk', asyncHandler(async (req, res, next) => { const service = new RolesService({ accountability: req.accountability }); - const primaryKey = await service.update(req.body, req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const primaryKey = await service.update(req.body, pk as any); const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.delete( '/:pk', asyncHandler(async (req, res, next) => { const service = new RolesService({ accountability: req.accountability }); - await service.delete(req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + await service.delete(pk as any); return next(); - }), + }) ); export default router; diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index 1904901c96..291860e63f 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -16,7 +16,7 @@ router.post( const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.get( @@ -30,7 +30,7 @@ router.get( res.locals.payload = { data: item || null, meta }; return next(); - }), + }) ); router.get( @@ -45,7 +45,7 @@ router.get( res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.get( @@ -53,10 +53,11 @@ router.get( asyncHandler(async (req, res, next) => { if (req.path.endsWith('me')) return next(); const service = new UsersService({ accountability: req.accountability }); - const items = await service.readByKey(req.params.pk, req.sanitizedQuery); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const items = await service.readByKey(pk as any, req.sanitizedQuery); res.locals.payload = { data: items || null }; return next(); - }), + }) ); router.patch( @@ -72,7 +73,7 @@ router.patch( res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.patch( @@ -97,20 +98,22 @@ router.patch( '/:pk', asyncHandler(async (req, res, next) => { const service = new UsersService({ accountability: req.accountability }); - const primaryKey = await service.update(req.body, req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const primaryKey = await service.update(req.body, pk as any); const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; return next(); - }), + }) ); router.delete( '/:pk', asyncHandler(async (req, res, next) => { const service = new UsersService({ accountability: req.accountability }); - await service.delete(req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + await service.delete(pk as any); return next(); }) @@ -161,7 +164,7 @@ router.post( res.locals.payload = { data: { secret, otpauth_url: url } }; return next(); - }), + }) ); router.post( diff --git a/api/src/controllers/webhooks.ts b/api/src/controllers/webhooks.ts index f13c09136f..b70135f3f4 100644 --- a/api/src/controllers/webhooks.ts +++ b/api/src/controllers/webhooks.ts @@ -13,7 +13,8 @@ router.post( const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; - }), + return next(); + }) ); router.get( @@ -26,38 +27,44 @@ router.get( const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery); res.locals.payload = { data: records || null, meta }; - }), + return next(); + }) ); router.get( '/:pk', asyncHandler(async (req, res, next) => { const service = new WebhooksService({ accountability: req.accountability }); - const record = await service.readByKey(req.params.pk, req.sanitizedQuery); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const record = await service.readByKey(pk as any, req.sanitizedQuery); res.locals.payload = { data: record || null }; - }), + return next(); + }) ); router.patch( '/:pk', asyncHandler(async (req, res, next) => { const service = new WebhooksService({ accountability: req.accountability }); - const primaryKey = await service.update(req.body, req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + const primaryKey = await service.update(req.body, pk as any); const item = await service.readByKey(primaryKey, req.sanitizedQuery); res.locals.payload = { data: item || null }; - }), + return next(); + }) ); router.delete( '/:pk', asyncHandler(async (req, res, next) => { const service = new WebhooksService({ accountability: req.accountability }); - await service.delete(req.params.pk); + const pk = req.params.pk.includes(',') ? req.params.pk.split(',') : req.params.pk; + await service.delete(pk as any); return next(); - }), + }) ); export default router; diff --git a/api/src/database/seeds/system.yaml b/api/src/database/seeds/system.yaml index 968cddfbc9..01112982ed 100644 --- a/api/src/database/seeds/system.yaml +++ b/api/src/database/seeds/system.yaml @@ -520,7 +520,29 @@ tables: directus_webhooks: id: increments: true - # TBD + name: + type: string + length: 255 + method: + type: string + length: 10 + default: POST + url: + type: string + length: 255 + status: + type: string + length: 10 + default: inactive + data: + type: boolean + default: false + actions: + type: string + length: 100 + collections: + type: string + length: 255 rows: directus_collections: @@ -543,6 +565,7 @@ rows: - collection: directus_relations - collection: directus_revisions - collection: directus_roles + - collection: directus_sessions - collection: directus_settings - collection: directus_users archive_field: status @@ -881,7 +904,7 @@ rows: field: collection type: string system: - interface: collections + interface: collection width: full special: json sort: 10 @@ -1816,7 +1839,85 @@ rows: hidden: true locked: true - # directus_webhooks TBD + - collection: directus_webhooks + field: id + hidden: true + locked: true + - collection: directus_webhooks + field: name + interface: text-input + locked: true + sort: 1 + width: full + - collection: directus_webhooks + field: method + interface: dropdown + locked: true + options: + choices: + - GET + - POST + sort: 2 + width: half + - collection: directus_webhooks + field: url + interface: text-input + locked: true + options: + iconRight: link + sort: 3 + width: half + - collection: directus_webhooks + field: status + interface: dropdown + locked: true + options: + choices: + - text: Active + value: active + - text: Inactive + value: inactive + sort: 4 + width: half + - collection: directus_webhooks + field: data + interface: toggle + locked: true + options: + choices: + label: Include item data in request + sort: 5 + width: half + - collection: directus_webhooks + field: triggers_divider + interface: divider + options: + icon: api + title: Triggers + color: '#2F80ED' + special: alias + sort: 6 + width: full + - collection: directus_webhooks + field: actions + interface: checkboxes + options: + choices: + - text: Create + value: create + - text: Update + value: update + - text: Delete + value: delete + special: csv + sort: 7 + width: full + - collection: directus_webhooks + field: collections + interface: collections + special: csv + sort: 8 + width: full - collection: directus_activity field: action diff --git a/api/src/emitter.ts b/api/src/emitter.ts new file mode 100644 index 0000000000..b6a984e212 --- /dev/null +++ b/api/src/emitter.ts @@ -0,0 +1,5 @@ +import { EventEmitter2 } from 'eventemitter2'; + +const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true }); + +export default emitter; diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index f655654186..3cafb703f6 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -234,6 +234,8 @@ export default class FieldsService { const type = field.type as 'float' | 'decimal'; /** @todo add precision and scale support */ column = table[type](field.field /* precision, scale */); + } else if (field.type === 'csv') { + column = table.string(field.field); } else { column = table[field.type](field.field); } diff --git a/api/src/services/items.ts b/api/src/services/items.ts index c716eca737..b46b7fe9bc 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -14,6 +14,7 @@ import { } from '../types'; import Knex from 'knex'; import cache from '../cache'; +import emitter from '../emitter'; import PayloadService from './payload'; import AuthorizationService from './authorization'; @@ -150,6 +151,13 @@ export default class ItemsService implements AbstractService { await cache.clear(); } + emitter.emitAsync(`item.create.${this.collection}`, { + collection: this.collection, + item: primaryKeys, + action: 'create', + payload: payloads, + }); + return primaryKeys; }); @@ -167,6 +175,7 @@ export default class ItemsService implements AbstractService { } const records = await runAST(ast); + return records; } @@ -311,6 +320,13 @@ export default class ItemsService implements AbstractService { await cache.clear(); } + emitter.emitAsync(`item.update.${this.collection}`, { + collection: this.collection, + item: key, + action: 'update', + payload, + }); + return key; } @@ -373,6 +389,12 @@ export default class ItemsService implements AbstractService { await cache.clear(); } + emitter.emitAsync(`item.delete.${this.collection}`, { + collection: this.collection, + item: key, + action: 'delete', + }); + return key; } diff --git a/api/src/services/webhooks.ts b/api/src/services/webhooks.ts index f4b962f759..bd0c29388d 100644 --- a/api/src/services/webhooks.ts +++ b/api/src/services/webhooks.ts @@ -1,8 +1,115 @@ import ItemsService from './items'; -import { AbstractServiceOptions } from '../types'; +import { Item, PrimaryKey, AbstractServiceOptions } from '../types'; +import emitter from '../emitter'; +import { ListenerFn } from 'eventemitter2'; +import { Webhook } from '../types'; +import axios from 'axios'; +import logger from '../logger'; + +let registered: { event: string; handler: ListenerFn }[] = []; export default class WebhooksService extends ItemsService { constructor(options?: AbstractServiceOptions) { super('directus_webhooks', options); } + + async register() { + this.unregister(); + + const webhooks = await this.knex + .select('*') + .from('directus_webhooks') + .where({ status: 'active' }); + + for (const webhook of webhooks) { + if (webhook.actions === '*') { + if (webhook.collections === '*') { + const event = 'item.*.*'; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } else { + for (const collection of webhook.collections.split(',')) { + const event = `item.*.${collection}`; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } + } + } else { + for (const action of webhook.actions.split(',')) { + if (webhook.collections === '*') { + const event = `item.${action}.*`; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } else { + for (const collection of webhook.collections.split(',')) { + const event = `item.${action}.${collection}`; + const handler = this.createHandler(webhook); + emitter.on(event, handler); + registered.push({ event, handler }); + } + } + } + } + } + } + + unregister() { + for (const { event, handler } of registered) { + emitter.off(event, handler); + } + + registered = []; + } + + createHandler(webhook: Webhook): ListenerFn { + return async (data) => { + try { + await axios({ + url: webhook.url, + method: webhook.method, + data: webhook.data ? data : null, + }); + } catch (error) { + logger.warn(`Webhook "${webhook.name}" (id: ${webhook.id}) failed`); + logger.warn(error); + } + }; + } + + async create(data: Partial[]): Promise; + async create(data: Partial): Promise; + async create(data: Partial | Partial[]): Promise { + const result = await super.create(data); + + await this.register(); + + return result; + } + + update(data: Partial, keys: PrimaryKey[]): Promise; + update(data: Partial, key: PrimaryKey): Promise; + update(data: Partial[]): Promise; + async update( + data: Partial | Partial[], + key?: PrimaryKey | PrimaryKey[] + ): Promise { + const result = await super.update(data, key as any); + + await this.register(); + + return result; + } + + delete(key: PrimaryKey): Promise; + delete(keys: PrimaryKey[]): Promise; + async delete(key: PrimaryKey | PrimaryKey[]): Promise { + const result = await super.delete(key as any); + + await this.register(); + + return result; + } } diff --git a/api/src/types/index.ts b/api/src/types/index.ts index 1543d0e6e8..52ca062a11 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -12,3 +12,4 @@ export * from './query'; export * from './relation'; export * from './services'; export * from './sessions'; +export * from './webhooks'; diff --git a/api/src/types/webhooks.ts b/api/src/types/webhooks.ts new file mode 100644 index 0000000000..21922a0903 --- /dev/null +++ b/api/src/types/webhooks.ts @@ -0,0 +1,10 @@ +export type Webhook = { + id: number; + name: string; + method: 'GET' | 'POST'; + url: string; + status: 'active' | 'inactive'; + data: boolean; + actions: string; + collections: string; +}; diff --git a/app/src/interfaces/collections/collections.vue b/app/src/interfaces/_system/collection/collection.vue similarity index 100% rename from app/src/interfaces/collections/collections.vue rename to app/src/interfaces/_system/collection/collection.vue diff --git a/app/src/interfaces/_system/collection/index.ts b/app/src/interfaces/_system/collection/index.ts new file mode 100644 index 0000000000..99bb3c673c --- /dev/null +++ b/app/src/interfaces/_system/collection/index.ts @@ -0,0 +1,30 @@ +import { defineInterface } from '@/interfaces/define'; +import InterfaceCollection from './collection.vue'; + +export default defineInterface(({ i18n }) => ({ + id: 'collection', + name: i18n.t('interfaces.collection.collection'), + description: i18n.t('interfaces.collection.description'), + icon: 'featured_play_list', + component: InterfaceCollection, + types: ['string'], + options: [ + { + field: 'includeSystem', + name: i18n.t('system'), + type: 'boolean', + meta: { + width: 'half', + interface: 'toggle', + options: { + label: i18n.t('interfaces.collection.include_system_collections'), + }, + }, + schema: { + default_value: false, + }, + }, + ], + system: true, + recommendedDisplays: ['collection'], +})); diff --git a/app/src/interfaces/_system/collections/collections.vue b/app/src/interfaces/_system/collections/collections.vue new file mode 100644 index 0000000000..2c57a74067 --- /dev/null +++ b/app/src/interfaces/_system/collections/collections.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/src/interfaces/collections/index.ts b/app/src/interfaces/_system/collections/index.ts similarity index 89% rename from app/src/interfaces/collections/index.ts rename to app/src/interfaces/_system/collections/index.ts index f3da84141a..8d21692cbb 100644 --- a/app/src/interfaces/collections/index.ts +++ b/app/src/interfaces/_system/collections/index.ts @@ -7,7 +7,7 @@ export default defineInterface(({ i18n }) => ({ description: i18n.t('interfaces.collections.description'), icon: 'featured_play_list', component: InterfaceCollections, - types: ['string'], + types: ['json', 'csv'], options: [ { field: 'includeSystem', @@ -25,5 +25,6 @@ export default defineInterface(({ i18n }) => ({ }, }, ], - recommendedDisplays: ['collection'], + system: true, + recommendedDisplays: ['labels'], })); diff --git a/app/src/lang/en-US/index.json b/app/src/lang/en-US/index.json index 971f18ccb0..42cd3b31d3 100644 --- a/app/src/lang/en-US/index.json +++ b/app/src/lang/en-US/index.json @@ -17,6 +17,7 @@ "create_preset": "Create Preset", "create_role": "Create Role", "create_user": "Create User", + "create_webhook": "Create Webhook", "rename_folder": "Rename Folder", "delete_folder": "Delete Folder", @@ -459,6 +460,9 @@ "user_count": "No Users | One User | {count} Users", "no_users_copy": "There are no users in this role yet.", + "webhooks_count": "No Webhooks | One Webhook | {count} Webhooks", + "no_webhooks_copy": "There are no webhooks yet.", + "all_items": "All Items", "no_collections": "No Collections", diff --git a/app/src/lang/en-US/interfaces.json b/app/src/lang/en-US/interfaces.json index 180e5f9331..e45115a92f 100644 --- a/app/src/lang/en-US/interfaces.json +++ b/app/src/lang/en-US/interfaces.json @@ -14,6 +14,11 @@ "line_number": "Line Number", "placeholder": "Enter code here..." }, + "collection": { + "collection": "Collection", + "description": "Select between existing collections", + "include_system_collections": "Include System Collections" + }, "collections": { "collections": "Collections", "description": "Select between existing collections", diff --git a/app/src/modules/collections/index.ts b/app/src/modules/collections/index.ts index b32b6d7c33..785dec032c 100644 --- a/app/src/modules/collections/index.ts +++ b/app/src/modules/collections/index.ts @@ -32,6 +32,14 @@ const checkForSystem: NavigationGuard = (to, from, next) => { } } + if (to.params.collection === 'directus_webhooks') { + if (to.params.primaryKey) { + return next(`/settings/webhooks/${to.params.primaryKey}`); + } else { + return next('/settings/webhooks'); + } + } + return next(); }; diff --git a/app/src/modules/settings/routes/webhooks/browse.vue b/app/src/modules/settings/routes/webhooks/browse.vue index 2e9b58f4ee..b95d46053b 100644 --- a/app/src/modules/settings/routes/webhooks/browse.vue +++ b/app/src/modules/settings/routes/webhooks/browse.vue @@ -12,9 +12,78 @@ -
- Pre-Release: Feature not yet available -
+ + + + + + + @@ -48,7 +189,21 @@ export default defineComponent({ --v-button-background-color-disabled: var(--warning-25); } -.content { - padding: var(--content-padding); +.action-delete { + --v-button-background-color: var(--danger-25); + --v-button-color: var(--danger); + --v-button-background-color-hover: var(--danger-50); + --v-button-color-hover: var(--danger); +} + +.action-batch { + --v-button-background-color: var(--warning-25); + --v-button-color: var(--warning); + --v-button-background-color-hover: var(--warning-50); + --v-button-color-hover: var(--warning); +} + +.layout { + --layout-offset-top: 64px; } diff --git a/app/src/types/fields.ts b/app/src/types/fields.ts index 46bf69b359..acbf689417 100644 --- a/app/src/types/fields.ts +++ b/app/src/types/fields.ts @@ -23,6 +23,7 @@ export const types = [ 'timestamp', 'binary', 'uuid', + 'csv', 'unknown', ] as const; diff --git a/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts b/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts index 7753595c10..448e045369 100644 --- a/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts +++ b/app/src/utils/get-default-interface-for-type/get-default-interface-for-type.ts @@ -17,6 +17,7 @@ const defaultInterfaceMap: Record = { timestamp: 'datetime', uuid: 'text-input', unknown: 'text-input', + csv: 'tags' }; /**