Merge pull request #279 from directus/webhooks

Webhooks
This commit is contained in:
Rijk van Zanten
2020-09-10 15:41:11 -04:00
committed by GitHub
28 changed files with 610 additions and 72 deletions

5
api/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
})
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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(

View File

@@ -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;

View File

@@ -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

5
api/src/emitter.ts Normal file
View File

@@ -0,0 +1,5 @@
import { EventEmitter2 } from 'eventemitter2';
const emitter = new EventEmitter2({ wildcard: true, verboseMemoryLeak: true });
export default emitter;

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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<Webhook[]>('*')
.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<Item>[]): Promise<PrimaryKey[]>;
async create(data: Partial<Item>): Promise<PrimaryKey>;
async create(data: Partial<Item> | Partial<Item>[]): Promise<PrimaryKey | PrimaryKey[]> {
const result = await super.create(data);
await this.register();
return result;
}
update(data: Partial<Item>, keys: PrimaryKey[]): Promise<PrimaryKey[]>;
update(data: Partial<Item>, key: PrimaryKey): Promise<PrimaryKey>;
update(data: Partial<Item>[]): Promise<PrimaryKey[]>;
async update(
data: Partial<Item> | Partial<Item>[],
key?: PrimaryKey | PrimaryKey[]
): Promise<PrimaryKey | PrimaryKey[]> {
const result = await super.update(data, key as any);
await this.register();
return result;
}
delete(key: PrimaryKey): Promise<PrimaryKey>;
delete(keys: PrimaryKey[]): Promise<PrimaryKey[]>;
async delete(key: PrimaryKey | PrimaryKey[]): Promise<PrimaryKey | PrimaryKey[]> {
const result = await super.delete(key as any);
await this.register();
return result;
}
}

View File

@@ -12,3 +12,4 @@ export * from './query';
export * from './relation';
export * from './services';
export * from './sessions';
export * from './webhooks';

10
api/src/types/webhooks.ts Normal file
View File

@@ -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;
};

View File

@@ -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'],
}));

View File

@@ -0,0 +1,48 @@
<template>
<v-notice v-if="items.length === 0">
{{ $t('no_collections') }}
</v-notice>
<interface-checkboxes v-else :choices="items" @input="$listeners.input" :value="value" :disabled="disabled" />
</template>
<script lang="ts">
import { defineComponent, computed } from '@vue/composition-api';
import { useCollectionsStore } from '@/stores/';
export default defineComponent({
props: {
value: {
type: Array,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
includeSystem: {
type: Boolean,
default: false,
},
},
setup(props) {
const collectionsStore = useCollectionsStore();
const collections = computed(() => {
if (props.includeSystem) return collectionsStore.state.collections;
return collectionsStore.state.collections.filter(
(collection) => collection.collection.startsWith('directus_') === false
);
});
const items = computed(() => {
return collections.value.map((collection) => ({
text: collection.name,
value: collection.collection,
}));
});
return { items };
},
});
</script>

View File

@@ -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'],
}));

View File

@@ -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",

View File

@@ -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",

View File

@@ -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();
};

View File

@@ -12,9 +12,78 @@
<settings-navigation />
</template>
<div class="content">
<v-notice>Pre-Release: Feature not yet available</v-notice>
</div>
<template #actions>
<search-input v-model="searchQuery" />
<v-dialog v-model="confirmDelete" v-if="selection.length > 0">
<template #activator="{ on }">
<v-button rounded icon class="action-delete" @click="on">
<v-icon name="delete" outline />
</v-button>
</template>
<v-card>
<v-card-title>{{ $tc('batch_delete_confirm', selection.length) }}</v-card-title>
<v-card-actions>
<v-button @click="confirmDelete = false" secondary>
{{ $t('cancel') }}
</v-button>
<v-button @click="batchDelete" class="action-delete" :loading="deleting">
{{ $t('delete') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-button
rounded
icon
class="action-batch"
v-if="selection.length > 1"
:to="batchLink"
v-tooltip.bottom="$t('edit')"
>
<v-icon name="edit" outline />
</v-button>
<v-button rounded icon :to="addNewLink" v-tooltip.bottom="$t('create_webhook')">
<v-icon name="add" />
</v-button>
</template>
<component
class="layout"
ref="layoutRef"
:is="`layout-${layout}`"
collection="directus_webhooks"
:selection.sync="selection"
:layout-options.sync="layoutOptions"
:layout-query.sync="layoutQuery"
:filters="filters"
:search-query="searchQuery"
@update:filters="filters = $event"
>
<template #no-results>
<v-info :title="$t('no_results')" icon="search" center>
{{ $t('no_results_copy') }}
<template #append>
<v-button @click="clearFilters">{{ $t('clear_filters') }}</v-button>
</template>
</v-info>
</template>
<template #no-items>
<v-info :title="$tc('webhooks_count', 0)" icon="anchor" center type="warning">
{{ $t('no_webhooks_copy') }}
<template #append>
<v-button :to="{ path: '/settings/webhooks/+' }">{{ $t('create_webhook') }}</v-button>
</template>
</v-info>
</template>
</component>
<template #drawer>
<drawer-detail icon="info_outline" :title="$t('information')" close>
@@ -27,10 +96,15 @@
</template>
<script lang="ts">
import { defineComponent } from '@vue/composition-api';
import { defineComponent, computed, ref } from '@vue/composition-api';
import SettingsNavigation from '../../components/navigation.vue';
import LayoutDrawerDetail from '@/views/private/components/layout-drawer-detail';
import marked from 'marked';
import { LayoutComponent } from '@/layouts/types';
import { usePreset } from '@/composables/use-preset';
import { i18n } from '@/lang';
import api from '@/api';
import SearchInput from '@/views/private/components/search-input';
type Item = {
[field: string]: any;
@@ -38,7 +112,74 @@ type Item = {
export default defineComponent({
name: 'webhooks-browse',
components: { SettingsNavigation, LayoutDrawerDetail },
components: { SettingsNavigation, LayoutDrawerDetail, SearchInput },
setup(props) {
const layoutRef = ref<LayoutComponent | null>(null);
const selection = ref<Item[]>([]);
const { layout, layoutOptions, layoutQuery, filters, searchQuery } = usePreset(ref('directus_webhooks'));
const { addNewLink, batchLink } = useLinks();
const { confirmDelete, deleting, batchDelete } = useBatchDelete();
return {
addNewLink,
batchDelete,
batchLink,
confirmDelete,
deleting,
filters,
layoutRef,
selection,
layoutOptions,
layoutQuery,
layout,
searchQuery,
marked,
clearFilters,
};
function useBatchDelete() {
const confirmDelete = ref(false);
const deleting = ref(false);
return { confirmDelete, deleting, batchDelete };
async function batchDelete() {
deleting.value = true;
confirmDelete.value = false;
const batchPrimaryKeys = selection.value;
await api.delete(`/webhooks/${batchPrimaryKeys}`);
await layoutRef.value?.refresh();
selection.value = [];
deleting.value = false;
confirmDelete.value = false;
}
}
function useLinks() {
const addNewLink = computed<string>(() => {
return `/settings/webhooks/+`;
});
const batchLink = computed<string>(() => {
const batchPrimaryKeys = selection.value;
return `/settings/webhooks/${batchPrimaryKeys}`;
});
return { addNewLink, batchLink };
}
function clearFilters() {
filters.value = [];
searchQuery.value = null;
}
}
});
</script>
@@ -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;
}
</style>

View File

@@ -23,6 +23,7 @@ export const types = [
'timestamp',
'binary',
'uuid',
'csv',
'unknown',
] as const;

View File

@@ -17,6 +17,7 @@ const defaultInterfaceMap: Record<typeof types[number], string> = {
timestamp: 'datetime',
uuid: 'text-input',
unknown: 'text-input',
csv: 'tags'
};
/**