🌊 Add Data Flows to Directus 🌊 (#12522)

* Replace attachment with correct icon

* Use standardized options formatting

* Improve preview styling, fix names

* Format IDs of DB read operation as csv

* Remove flow active state from header

* Don't return null for unknown flows

* Fix webhook trigger not showing id

* Fix alignment of attachment

* Make heading secondary if it's the reject handler

* Use flow name in subtitle of operation drawer

* Rename "Create new Operation" -> "Create Operation"

* Make name/key required

* Give name autofocus

* Add "uncaught exception" log message

* Various improvements on operations

* default status to "active"

* Add status dropdown at the bottom of trigger

* put status dot to the right of flow name header

* add toggle status option in context menu

* fix trigger options staging

* fix flow deletion

* show configured operation key on name hover

* prevent block pushing status toggle down

* ensure key is unique between operations in a flow

* allow add new panel when previous one is deleted

* fix staged panels temporarily disappear

The deletion of newTree.id causes it to "disconnect" when saving, thus causing it seemingly disappear. Using a cloneDeep prevents it from mutating the current stagedPanels

* hide key input when in query mode

* add write operation

* undo previous route props change

* include staged panel keys in key validation

* fix key validation logic

* add color to flow & insights

* ensure trigger does not have reject button

* prevent operation key error showing up when saving

* change context menu to Delete Operation

* fix add operation when removed operation is staged

* Hide ID in read preview when in query mode

* fix reject button showing without edit mode

* fix status toggle in flow overview

* simplify request operation methods & allow other

* fix preview function type

* simplify slot syntax

* add manual trigger

* simplify manual trigger handler

* prevent drawer closing on esc

* allow filter config without selecting collection

* improve affordance of add operation button

* fix loner reject button color

* Added emitEvents option to write operation (#13121)

* added emitEvent option toggle to write operation

* Re-gen package-lock

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>

* Clean up active/inactive toggle

* Align arrow to grid

* Visually align border radius of glow

* Tweak padding of footer in panels

* Remove init event

* Combine event triggers into single "Hook" trigger

* Fix mail operation

* Cleanup imports, return undefined for webhook executino flows

* Add border to panel footer

* Upgrade preview of hook trigger

* Don't warn on uncaught flow executions

* Clean up types

* Fix typing of reload

* Default to correct icon

* Add migrations to remove webhooks table

* Remove webhooks

* Update icons for triggers

* Reorganize triggers

* Merge flows and webhooks migrations

* Add permissions option to database read operatoin

* Add permissions configuration to database write

* Remove flow logs in favor of using directus_activity

* Upgrade webhook configuration, fix create operatoin

* Rename validation->condition

* Subdue everything when inactive

* Tweak tests

* Fix the test for real

* Remove circular FK trigger, please MSSQL

* Make things worse to please MSFT

* Add input

* Drop input scope from condition operation

* naming and description changes

* Default flow overview icon color to primary

* add danger styling to delete flow button

* fix hint buttons subdued style

* Hide trigger unlinked resolve btn when not editing

Don't show "check mark icon" or "operation arrow" on empty trigger when NOT in edit mode

* show email "to" value as CSV

* remove unused webhook.preview translation

* Default sort order of overview table to `name`

* Track activity / revisions in flows

* Extract w/ the intent to reuse revisions fetching

* Move Action type to shared to facilitate app use

* [WIP] Start rendering logs drawer from sidebar

* Fix type error (sorry Eron)

* add update operation

* add delete operation

* use parseJSON util in operations

* Add missing fields to flows system data

* Await promise in sleep operation

* fix e2e test missing flows & operations tables

* Add fallback title to flow logs drawer

* Add default value to flow prop for flow-dialog

* Hydrate flows store before moving to details page when creating flow

* Rename CRUD operations to item-*

* Change trigger options subtitle to Trigger Options

* Remove trigger name option

* Fix typescript complaining

* Remove two lines

* Fix notification operation

* Log error when executing a schedule flow

* Fix schedule flow activity tracking

* Fix notification operation when there is no user

* Make permissions for notifications configurable

* Do not drop non null constraint from column that is nullable

* Remove invalid option from activity seed

* Show resolve/reject dot when operation has successor

* Improve flow arrow placement

* Prevent arrow color from flickering

* Fix arrow being stuck when hovered while saving

* Fix arrows not being subdued on lone leaf operations

* Add tooltips to operation handles

* Remove option to trigger flow on init

* Move operation handle tooltip to icon

* Disconnect duplicated operation

* Fix deleting connected operations

* Make delete action name generic in v-workspace-panel

* Use flow-specific wording in flow edit tooltip

* Simplify hint handle check

* Fix deleting first operation

* Use useEditsGuard composable in flow component

* Add asynchronous option to webhook trigger

* Add option to make preview elements copyable

* Add hover transition to panels

* Register operation preview components as operation-preview-*

* Remove selectability of panel header and operation body

* Add return option to filter and operation triggers

* Add missing key

* Remove unnecessary ampersand from URLs in filter tests (#13523)

* Remove unused prop

* New translations en-US.yaml (Polish) (#13524)

* My favorite

* v9.11.1

* v9.11.1

* New translations en-US.yaml (Polish) (#13528)

* New translations en-US.yaml (Czech) (#13541)

* New translations en-US.yaml (Czech) (#13545)

* fix metadata for directus_folders (#13527)

* add `meta` to list responses for OAS (#13531)

* remove existing pasting check on slug values (#13532)

* Add copy button to user id on user info page (#13540)

* New translations en-US.yaml (Czech) (#13547)

* New translations en-US.yaml (Czech) (#13548)

* Added missing "DB_SSL_*_FILE" to the "_FILE" allow list. (#13539)

* Remove workaround in release flow (#13455)

This forces the release workflow to use `node@16.15` which includes `npm@8.5`.

* Remove npmrc files which prevent lockfile creation in workspaces (#13444)

* Remove npmrc files which prevent lockfile creation in workspaces

Since `v8.5.0` npm will detect that it is running inside a workspace and issue commands at the root package.

* Require a minimum npm version of 8.5.0

* Package-lock 🖤

* Don't consider SIGN_OUT an SSO error (#13389)

* Don't consider SIGN_OUT an SSO error

* Add SESSION_EXPIRED as valid reason

* Improve translation for require_value_to_be_set (#13363)

English + Dutch

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>

* Fix field conditions optionDefaults computed property (#13563)

* fix: remove .value from options

* additional ref fix & type/null errors

Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>

* `last_page` type was `date` instead of `str` (#13577)

* `last_page` type was `date` instead of `str`

* Update docs/reference/system/users.md

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>

* New Crowdin updates (#13557)

* New translations en-US.yaml (Romanian)

* New translations en-US.yaml (Indonesian)

* New translations en-US.yaml (Spanish, Chile)

* New translations en-US.yaml (Thai)

* New translations en-US.yaml (Spanish, Latin America)

* New translations en-US.yaml (Russian)

* New translations en-US.yaml (Polish)

* New translations en-US.yaml (Swedish)

* New translations en-US.yaml (Turkish)

* New translations en-US.yaml (Portuguese, Brazilian)

* New translations en-US.yaml (French)

* New translations en-US.yaml (Spanish)

* New translations en-US.yaml (Bulgarian)

* New translations en-US.yaml (Catalan)

* New translations en-US.yaml (Danish)

* New translations en-US.yaml (German)

* New translations en-US.yaml (Finnish)

* New translations en-US.yaml (Hungarian)

* New translations en-US.yaml (Chinese Simplified)

* New translations en-US.yaml (Italian)

* New translations en-US.yaml (Slovenian)

* New translations en-US.yaml (Ukrainian)

* New translations en-US.yaml (English, United Kingdom)

* New translations en-US.yaml (English, Canada)

* New translations en-US.yaml (French, Canada)

* New translations en-US.yaml (Croatian)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Czech)

* Fix validate query number comparison

Ref https://github.com/directus/directus/pull/13492#issuecomment-1138770254

* New translations en-US.yaml (Polish) (#13580)

* add to project (#13581)

* Allow authentication using MSSQL azure-active-directory-service-principal-secret (#11141)

* Extract ignored settings requires by azure authentication

* Change the way to extract initial database settings

* Fix invalid names after extracting from env util

* Replace missing var after solving conflicts

* Add default value to poolconfig

* This should unbreak it

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>

* Create pull_request_template.md

* Update pull_request_template.md

* Add System token interface (#13549)

* Add system token interface

* use system token interface in users token field

* Update placeholder

* move notice below input

* fix clear value interaction

* update placeholder and notice text

* remove unused translation key

* rename class to match current naming

* fix bugs in disabled state and it's UX

Co-authored-by: Rijk van Zanten <rijkvanzanten@me.com>
Co-authored-by: jaycammarano <jay.cammarano@gmail.com>

* New Crowdin updates (#13586)

* New translations en-US.yaml (Romanian)

* New translations en-US.yaml (Indonesian)

* New translations en-US.yaml (Spanish, Chile)

* New translations en-US.yaml (Thai)

* New translations en-US.yaml (Serbian (Latin))

* New translations en-US.yaml (Spanish, Latin America)

* New translations en-US.yaml (Russian)

* New translations en-US.yaml (Polish)

* New translations en-US.yaml (Portuguese)

* New translations en-US.yaml (Swedish)

* New translations en-US.yaml (Turkish)

* New translations en-US.yaml (Estonian)

* New translations en-US.yaml (Portuguese, Brazilian)

* New translations en-US.yaml (French)

* New translations en-US.yaml (Spanish)

* New translations en-US.yaml (Bulgarian)

* New translations en-US.yaml (Catalan)

* New translations en-US.yaml (Czech)

* New translations en-US.yaml (Danish)

* New translations en-US.yaml (German)

* New translations en-US.yaml (Finnish)

* New translations en-US.yaml (Hungarian)

* New translations en-US.yaml (Chinese Simplified)

* New translations en-US.yaml (Italian)

* New translations en-US.yaml (Japanese)

* New translations en-US.yaml (Dutch)

* New translations en-US.yaml (Slovenian)

* New translations en-US.yaml (Ukrainian)

* New translations en-US.yaml (English, United Kingdom)

* New translations en-US.yaml (English, Canada)

* New translations en-US.yaml (French, Canada)

* New translations en-US.yaml (Croatian)

* Add project_url to defaultTemplateData (#12033)

Might be useful in template footers.

* Update items.md

* Rename panel to tile

* Rename preview->overview

* Style flow log detail

* Log all parsed options

* Show used options in revision

* Finish log detail drawer

* new create flow flow

* fix firstOpen for new create flow flow

* update field layout for create flow form

* Fix TS typing

* Fix missing import

* Append random hash to key when duplicating operations

* Revert "Remove webhooks"

This reverts commit 044d3d8b66.

* Don't delete webhooks

* Make option preview selectable

* Prevent invalid linking when duplicating operations after creating operations

* Prevent sending of malformed query filter when deleting flow

* implement new manual trigger

* simplify payload for manual trigger

* use buttons instead of dropdown + run button

* add async option & loading state

* add collection check to manual trigger

* emit refresh after running flow in sidebar

* Add cross-instance messenger for reloading

* Use flow drawer for both create and edit

* Add manual trigger flow permissions to app recommended

Ensures that non-admin users can actually see the flows sidebar detail

* Add basic logs redaction

* Remove endpoint to trigger an operation

* Allow configuring location for manual trigger

* Rename "hook" trigger to "event"

* Tweak icon size

* Fix create flow button in info notice

* Make activity tracking full width

* Tweak descriptions

* Too long for comfort

* Remove mode option from item-* operations

* fix manual trigger empty collections option

* Add no-logs-yet message in sidebar detail

* Reset trigger options on change of trigger

* Rename `data`->`payload`

* Remove mode from preview of item-* operations

* Return operation options with "{{key}}" as raw value

* Show flow name in delete confirmation

* Add default generated name/key to new operations

* shorten arrows WIP

still needs icons moved

* rename note to description

* fix hint button icons

* update event hook type labels

* Animate resolve/reject arrow hints

* reorder event types

* Use x+4 instead of x+6 for new operation panels

* compress options to fit 6 lines in operation

* update hook labels

* animate trigger box shadow

sorry, rijk!

* update (global) disabled button color 1 shade

* Format times nicer

* Add placeholder for query

* add a note

* Fix formatting for curly brackets in translations

* Add item Create/update payload placeholder

* Add placeholder to user uuid

* Accept either null or undefined for nullable operation options

* Allow any string as request body

* Add more placeholders

* Consolidate filterScope and actionScope, filterCollections and actionCollections

* Rename flow note to description in types

Co-authored-by: rijkvanzanten <rijkvanzanten@me.com>
Co-authored-by: Azri Kahar <42867097+azrikahar@users.noreply.github.com>
Co-authored-by: Brainslug <br41nslug@users.noreply.github.com>
Co-authored-by: Ben Haynes <ben@rngr.org>
Co-authored-by: Nicola Krumschmidt <nicola.krumschmidt@freenet.de>
Co-authored-by: Aiden Foxx <aiden.foxx.mail@gmail.com>
Co-authored-by: Jan-Willem <jan-willem@qdentity.nl>
Co-authored-by: Yasser Lahbibi <yasser.lahbibi@apenhet.com>
Co-authored-by: Louis <32719150+louisgavalda@users.noreply.github.com>
Co-authored-by: Jay Cammarano <67079013+jaycammarano@users.noreply.github.com>
Co-authored-by: Erick Torres <ericktorresh@gmail.com>
Co-authored-by: jaycammarano <jay.cammarano@gmail.com>
Co-authored-by: Yuriy Belenko <yura-bely@mail.ru>
Co-authored-by: ian <licitdev@gmail.com>
This commit is contained in:
Nitwel
2022-06-03 00:18:49 +02:00
committed by GitHub
parent d175290657
commit 857eae54de
133 changed files with 9498 additions and 2448 deletions

View File

@@ -111,8 +111,10 @@
"execa": "^5.1.1",
"exifr": "^7.1.2",
"express": "^4.17.1",
"fast-redact": "^3.1.1",
"flat": "^5.0.2",
"fs-extra": "^10.0.0",
"globby": "^11.0.4",
"graphql": "^15.5.0",
"graphql-compose": "^9.0.1",
"helmet": "^4.6.0",
@@ -130,6 +132,7 @@
"lodash": "^4.17.21",
"macos-release": "^2.4.1",
"marked": "^4.0.3",
"micromustache": "^8.0.3",
"mime-types": "^2.1.31",
"ms": "^2.1.3",
"nanoid": "^3.1.23",
@@ -182,6 +185,7 @@
"@types/express": "4.17.13",
"@types/express-pino-logger": "4.0.3",
"@types/express-session": "1.17.4",
"@types/fast-redact": "^3.0.1",
"@types/flat": "5.0.2",
"@types/fs-extra": "9.0.13",
"@types/inquirer": "8.1.3",

View File

@@ -13,12 +13,14 @@ import dashboardsRouter from './controllers/dashboards';
import extensionsRouter from './controllers/extensions';
import fieldsRouter from './controllers/fields';
import filesRouter from './controllers/files';
import flowsRouter from './controllers/flows';
import foldersRouter from './controllers/folders';
import graphqlRouter from './controllers/graphql';
import itemsRouter from './controllers/items';
import notFoundHandler from './controllers/not-found';
import panelsRouter from './controllers/panels';
import notificationsRouter from './controllers/notifications';
import operationsRouter from './controllers/operations';
import permissionsRouter from './controllers/permissions';
import presetsRouter from './controllers/presets';
import relationsRouter from './controllers/relations';
@@ -35,6 +37,7 @@ import emitter from './emitter';
import env from './env';
import { InvalidPayloadException } from './exceptions';
import { getExtensionManager } from './extensions';
import { getFlowManager } from './flows';
import logger, { expressLogger } from './logger';
import authenticate from './middleware/authenticate';
import getPermissions from './middleware/get-permissions';
@@ -83,8 +86,10 @@ export default async function createApp(): Promise<express.Application> {
await registerAuthProviders();
const extensionManager = getExtensionManager();
const flowManager = getFlowManager();
await extensionManager.initialize();
await flowManager.initialize();
const app = express();
@@ -215,9 +220,11 @@ export default async function createApp(): Promise<express.Application> {
app.use('/extensions', extensionsRouter);
app.use('/fields', fieldsRouter);
app.use('/files', filesRouter);
app.use('/flows', flowsRouter);
app.use('/folders', foldersRouter);
app.use('/items', itemsRouter);
app.use('/notifications', notificationsRouter);
app.use('/operations', operationsRouter);
app.use('/panels', panelsRouter);
app.use('/permissions', permissionsRouter);
app.use('/presets', presetsRouter);

View File

@@ -1,3 +1,4 @@
import { Action } from '@directus/shared/types';
import express from 'express';
import Joi from 'joi';
import { ForbiddenException, InvalidPayloadException } from '../exceptions';
@@ -5,7 +6,6 @@ import { respond } from '../middleware/respond';
import useCollection from '../middleware/use-collection';
import { validateBatch } from '../middleware/validate-batch';
import { ActivityService, MetaService } from '../services';
import { Action } from '../types';
import asyncHandler from '../utils/async-handler';
import { getIPFromReq } from '../utils/get-ip-from-req';

View File

@@ -0,0 +1,211 @@
import express from 'express';
import { UUID_REGEX } from '../constants';
import { ForbiddenException } from '../exceptions';
import { getFlowManager } from '../flows';
import { respond } from '../middleware/respond';
import useCollection from '../middleware/use-collection';
import { validateBatch } from '../middleware/validate-batch';
import { MetaService, FlowsService } from '../services';
import { PrimaryKey } from '../types';
import asyncHandler from '../utils/async-handler';
const router = express.Router();
router.use(useCollection('directus_flows'));
const webhookFlowHandler = asyncHandler(async (req, res, next) => {
const flowManager = getFlowManager();
const result = await flowManager.runWebhookFlow(
`${req.method}-${req.params.pk}`,
{
path: req.path,
query: req.query,
body: req.body,
method: req.method,
headers: req.headers,
},
{
accountability: req.accountability,
schema: req.schema,
}
);
res.locals.payload = result;
return next();
});
router.get(`/trigger/:pk(${UUID_REGEX})`, webhookFlowHandler, respond);
router.post(`/trigger/:pk(${UUID_REGEX})`, webhookFlowHandler, respond);
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new FlowsService({
accountability: req.accountability,
schema: req.schema,
});
const savedKeys: PrimaryKey[] = [];
if (Array.isArray(req.body)) {
const keys = await service.createMany(req.body);
savedKeys.push(...keys);
} else {
const key = await service.createOne(req.body);
savedKeys.push(key);
}
try {
if (Array.isArray(req.body)) {
const items = await service.readMany(savedKeys, req.sanitizedQuery);
res.locals.payload = { data: items };
} else {
const item = await service.readOne(savedKeys[0], req.sanitizedQuery);
res.locals.payload = { data: item };
}
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
const readHandler = asyncHandler(async (req, res, next) => {
const service = new FlowsService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
res.locals.payload = { data: records || null, meta };
return next();
});
router.get('/', validateBatch('read'), readHandler, respond);
router.search('/', validateBatch('read'), readHandler, respond);
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new FlowsService({
accountability: req.accountability,
schema: req.schema,
});
const record = await service.readOne(req.params.pk, req.sanitizedQuery);
res.locals.payload = { data: record || null };
return next();
}),
respond
);
router.patch(
'/',
validateBatch('update'),
asyncHandler(async (req, res, next) => {
const service = new FlowsService({
accountability: req.accountability,
schema: req.schema,
});
let keys: PrimaryKey[] = [];
if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);
}
try {
const result = await service.readMany(keys, req.sanitizedQuery);
res.locals.payload = { data: result };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new FlowsService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.updateOne(req.params.pk, req.body);
try {
const item = await service.readOne(primaryKey, req.sanitizedQuery);
res.locals.payload = { data: item || null };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
const service = new FlowsService({
accountability: req.accountability,
schema: req.schema,
});
if (Array.isArray(req.body)) {
await service.deleteMany(req.body);
} else if (req.body.keys) {
await service.deleteMany(req.body.keys);
} else {
await service.deleteByQuery(req.body.query);
}
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new FlowsService({
accountability: req.accountability,
schema: req.schema,
});
await service.deleteOne(req.params.pk);
return next();
}),
respond
);
export default router;

View File

@@ -0,0 +1,184 @@
import express from 'express';
import { ForbiddenException } from '../exceptions';
import { respond } from '../middleware/respond';
import useCollection from '../middleware/use-collection';
import { validateBatch } from '../middleware/validate-batch';
import { MetaService, OperationsService } from '../services';
import { PrimaryKey } from '../types';
import asyncHandler from '../utils/async-handler';
const router = express.Router();
router.use(useCollection('directus_operations'));
router.post(
'/',
asyncHandler(async (req, res, next) => {
const service = new OperationsService({
accountability: req.accountability,
schema: req.schema,
});
const savedKeys: PrimaryKey[] = [];
if (Array.isArray(req.body)) {
const keys = await service.createMany(req.body);
savedKeys.push(...keys);
} else {
const key = await service.createOne(req.body);
savedKeys.push(key);
}
try {
if (Array.isArray(req.body)) {
const items = await service.readMany(savedKeys, req.sanitizedQuery);
res.locals.payload = { data: items };
} else {
const item = await service.readOne(savedKeys[0], req.sanitizedQuery);
res.locals.payload = { data: item };
}
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
const readHandler = asyncHandler(async (req, res, next) => {
const service = new OperationsService({
accountability: req.accountability,
schema: req.schema,
});
const metaService = new MetaService({
accountability: req.accountability,
schema: req.schema,
});
const records = await service.readByQuery(req.sanitizedQuery);
const meta = await metaService.getMetaForQuery(req.collection, req.sanitizedQuery);
res.locals.payload = { data: records || null, meta };
return next();
});
router.get('/', validateBatch('read'), readHandler, respond);
router.search('/', validateBatch('read'), readHandler, respond);
router.get(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new OperationsService({
accountability: req.accountability,
schema: req.schema,
});
const record = await service.readOne(req.params.pk, req.sanitizedQuery);
res.locals.payload = { data: record || null };
return next();
}),
respond
);
router.patch(
'/',
validateBatch('update'),
asyncHandler(async (req, res, next) => {
const service = new OperationsService({
accountability: req.accountability,
schema: req.schema,
});
let keys: PrimaryKey[] = [];
if (req.body.keys) {
keys = await service.updateMany(req.body.keys, req.body.data);
} else {
keys = await service.updateByQuery(req.body.query, req.body.data);
}
try {
const result = await service.readMany(keys, req.sanitizedQuery);
res.locals.payload = { data: result };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.patch(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new OperationsService({
accountability: req.accountability,
schema: req.schema,
});
const primaryKey = await service.updateOne(req.params.pk, req.body);
try {
const item = await service.readOne(primaryKey, req.sanitizedQuery);
res.locals.payload = { data: item || null };
} catch (error) {
if (error instanceof ForbiddenException) {
return next();
}
throw error;
}
return next();
}),
respond
);
router.delete(
'/',
asyncHandler(async (req, res, next) => {
const service = new OperationsService({
accountability: req.accountability,
schema: req.schema,
});
if (Array.isArray(req.body)) {
await service.deleteMany(req.body);
} else if (req.body.keys) {
await service.deleteMany(req.body.keys);
} else {
await service.deleteByQuery(req.body.query);
}
return next();
}),
respond
);
router.delete(
'/:pk',
asyncHandler(async (req, res, next) => {
const service = new OperationsService({
accountability: req.accountability,
schema: req.schema,
});
await service.deleteOne(req.params.pk);
return next();
}),
respond
);
export default router;

View File

@@ -0,0 +1,89 @@
import { Knex } from 'knex';
import { toArray } from '@directus/shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { parseJSON } from '../../utils/parse-json';
export async function up(knex: Knex): Promise<void> {
await knex.schema.createTable('directus_flows', (table) => {
table.uuid('id').primary().notNullable();
table.string('name').notNullable();
table.string('icon', 30);
table.string('color').nullable();
table.text('description');
table.string('status').notNullable().defaultTo('active');
table.string('trigger');
table.string('accountability').defaultTo('all');
table.json('options');
table.uuid('operation').unique();
table.timestamp('date_created').defaultTo(knex.fn.now());
table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL');
});
await knex.schema.createTable('directus_operations', (table) => {
table.uuid('id').primary().notNullable();
table.string('name');
table.string('key').notNullable();
table.string('type').notNullable();
table.integer('position_x').notNullable();
table.integer('position_y').notNullable();
table.json('options');
table.uuid('resolve').unique().references('id').inTable('directus_operations');
table.uuid('reject').unique().references('id').inTable('directus_operations');
table.uuid('flow').notNullable().references('id').inTable('directus_flows').onDelete('CASCADE');
table.timestamp('date_created').defaultTo(knex.fn.now());
table.uuid('user_created').references('id').inTable('directus_users').onDelete('SET NULL');
});
const webhooks = await knex.select('*').from('directus_webhooks');
const flows = [];
const operations = [];
for (const webhook of webhooks) {
const flowID = uuidv4();
flows.push({
id: flowID,
name: webhook.name,
status: 'inactive',
trigger: 'hook',
options: JSON.stringify({
name: webhook.name,
type: 'action',
scope: toArray(webhook.actions).map((scope) => `items.${scope}`),
collections: toArray(webhook.collections),
}),
});
operations.push({
id: uuidv4(),
name: 'Request',
key: 'request',
type: 'request',
position_x: 21,
position_y: 1,
options: JSON.stringify({
url: webhook.url,
headers: typeof webhook.headers === 'string' ? parseJSON(webhook.headers) : webhook.headers,
data: webhook.data ? '{{$trigger}}' : null,
method: webhook.method,
}),
date_created: new Date(),
flow: flowID,
});
}
if (flows.length && operations.length) {
await knex.insert(flows).into('directus_flows');
await knex.insert(operations).into('directus_operations');
for (const operation of operations) {
await knex('directus_flows').update({ operation: operation.id }).where({ id: operation.flow });
}
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTable('directus_operations');
await knex.schema.dropTable('directus_flows');
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_dashboards', (table) => {
table.string('color').nullable();
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_dashboards', (table) => {
table.dropColumn('color');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_activity', (table) => {
table.setNullable('ip');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_activity', (table) => {
table.dropNullable('ip');
});
}

View File

@@ -0,0 +1,13 @@
import { Knex } from 'knex';
export async function up(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_notifications', (table) => {
table.setNullable('sender');
});
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.alterTable('directus_notifications', (table) => {
table.dropNullable('sender');
});
}

View File

@@ -20,7 +20,6 @@ columns:
user_agent:
type: string
length: 255
nullabel: false
collection:
type: string
length: 64

View File

@@ -68,3 +68,7 @@ data:
- collection: directus_shares
icon: share
note: $t:directus_collection.directus_shares
- collection: directus_flows
note: $t:directus_collection.directus_flows
- collection: directus_operations
note: $t:directus_collection.directus_operations

View File

@@ -0,0 +1,21 @@
table: directus_flows
fields:
- field: id
special: uuid
- field: name
- field: icon
- field: color
- field: note
- field: status
- field: trigger
- field: accountability
- field: options
special: cast-json
- field: operation
- field: operations
special: o2m
- field: date_created
special: date-created
- field: user_created
special: user-created

View File

@@ -0,0 +1,19 @@
table: directus_operations
fields:
- field: id
special: uuid
- field: name
- field: key
- field: type
- field: position_x
- field: position_y
- field: options
special: cast-json
- field: resolve
- field: reject
- field: flow
- field: date_created
special: date-created
- field: user_created
special: user-created

View File

@@ -61,6 +61,20 @@ data:
many_field: dashboard
one_collection: directus_dashboards
one_field: panels
- many_collection: directus_flows
many_field: operation
one_collection: directus_operations
- many_collection: directus_operations
many_field: flow
one_collection: directus_flows
one_field: operations
one_deselect_action: delete
- many_collection: directus_operations
many_field: resolve
one_collection: directus_operations
- many_collection: directus_operations
many_field: reject
one_collection: directus_operations
- many_collection: directus_files
many_field: modified_by
one_collection: directus_users
@@ -88,6 +102,12 @@ data:
- many_collection: directus_panels
many_field: user_created
one_collection: directus_users
- many_collection: directus_flows
many_field: user_created
one_collection: directus_users
- many_collection: directus_operations
many_field: user_created
one_collection: directus_users
- many_collection: directus_notifications
many_field: recipient
one_collection: directus_users

View File

@@ -29,7 +29,7 @@ export class Emitter {
context: EventContext
): Promise<T> {
const events = Array.isArray(event) ? event : [event];
const listeners: FilterHandler[] = events.flatMap((event) => this.filterEmitter.listeners(event));
const listeners = events.flatMap((event) => this.filterEmitter.listeners(event) as FilterHandler<T>[]);
let updatedPayload = payload;
for (const listener of listeners) {

View File

@@ -2,13 +2,16 @@ import express, { Router } from 'express';
import path from 'path';
import {
ActionHandler,
ApiExtension,
AppExtensionType,
EndpointConfig,
Extension,
ExtensionType,
FilterHandler,
HookConfig,
HybridExtension,
InitHandler,
OperationApiConfig,
ScheduleHandler,
} from '@directus/shared/types';
import {
@@ -25,6 +28,8 @@ import {
APP_SHARED_DEPS,
EXTENSION_PACKAGE_TYPES,
EXTENSION_TYPES,
HYBRID_EXTENSION_TYPES,
PACK_EXTENSION_TYPE,
} from '@directus/shared/constants';
import getDatabase from './database';
import emitter, { Emitter } from './emitter';
@@ -36,7 +41,7 @@ import fse from 'fs-extra';
import { getSchema } from './utils/get-schema';
import * as services from './services';
import { schedule, ScheduledTask, validate } from 'node-cron';
import { schedule, validate } from 'node-cron';
import { rollup } from 'rollup';
import virtual from '@rollup/plugin-virtual';
import alias from '@rollup/plugin-alias';
@@ -44,7 +49,10 @@ import { Url } from './utils/url';
import getModuleDefault from './utils/get-module-default';
import { clone, escapeRegExp } from 'lodash';
import chokidar, { FSWatcher } from 'chokidar';
import { pluralize } from '@directus/shared/utils';
import { isExtensionObject, isHybridExtension, pluralize } from '@directus/shared/utils';
import { getFlowManager } from './flows';
import globby from 'globby';
import { EventHandler } from './types';
let extensionManager: ExtensionManager | undefined;
@@ -58,16 +66,11 @@ export function getExtensionManager(): ExtensionManager {
return extensionManager;
}
type EventHandler =
| { type: 'filter'; name: string; handler: FilterHandler }
| { type: 'action'; name: string; handler: ActionHandler }
| { type: 'init'; name: string; handler: InitHandler }
| { type: 'schedule'; task: ScheduledTask };
type AppExtensions = Partial<Record<AppExtensionType, string>>;
type ApiExtensions = {
hooks: { path: string; events: EventHandler[] }[];
endpoints: { path: string }[];
operations: { path: string }[];
};
type Options = {
@@ -87,7 +90,7 @@ class ExtensionManager {
private extensions: Extension[] = [];
private appExtensions: AppExtensions = {};
private apiExtensions: ApiExtensions = { hooks: [], endpoints: [] };
private apiExtensions: ApiExtensions = { hooks: [], endpoints: [], operations: [] };
private apiEmitter: Emitter;
private endpointRouter: Router;
@@ -180,6 +183,7 @@ class ExtensionManager {
this.registerHooks();
this.registerEndpoints();
await this.registerOperations();
if (env.SERVE_APP) {
this.appExtensions = await this.generateExtensionBundles();
@@ -191,6 +195,7 @@ class ExtensionManager {
private async unload(): Promise<void> {
this.unregisterHooks();
this.unregisterEndpoints();
this.unregisterOperations();
this.apiEmitter.offAll();
@@ -205,16 +210,18 @@ class ExtensionManager {
if (this.options.watch && !this.watcher) {
logger.info('Watching extensions for changes...');
const localExtensionPaths = (env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES).map((type) =>
path.posix.join(
const localExtensionPaths = (env.SERVE_APP ? EXTENSION_TYPES : API_EXTENSION_TYPES).flatMap((type) => {
const typeDir = path.posix.join(
path.relative('.', env.EXTENSIONS_PATH).split(path.sep).join(path.posix.sep),
pluralize(type),
'*',
'index.js'
)
);
pluralize(type)
);
this.watcher = chokidar.watch([path.resolve('.', 'package.json'), ...localExtensionPaths], {
return isHybridExtension(type)
? [path.posix.join(typeDir, '*', 'app.js'), path.posix.join(typeDir, '*', 'api.js')]
: path.posix.join(typeDir, '*', 'index.js');
});
this.watcher = chokidar.watch([path.resolve('package.json'), ...localExtensionPaths], {
ignoreInitial: true,
});
@@ -230,10 +237,15 @@ class ExtensionManager {
const toPackageExtensionPaths = (extensions: Extension[]) =>
extensions
.filter((extension) => !extension.local)
.map((extension) =>
extension.type !== 'pack'
? path.resolve(extension.path, extension.entrypoint || '')
: path.resolve(extension.path, 'package.json')
.flatMap((extension) =>
extension.type === PACK_EXTENSION_TYPE
? path.resolve(extension.path, 'package.json')
: isExtensionObject(extension, HYBRID_EXTENSION_TYPES)
? [
path.resolve(extension.path, extension.entrypoint.app),
path.resolve(extension.path, extension.entrypoint.api),
]
: path.resolve(extension.path, extension.entrypoint)
);
const addedPackageExtensionPaths = toPackageExtensionPaths(added);
@@ -311,11 +323,16 @@ class ExtensionManager {
}
private registerHooks(): void {
const hooks = this.extensions.filter((extension) => extension.type === 'hook');
const hooks = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'hook');
for (const hook of hooks) {
try {
this.registerHook(hook);
const hookPath = path.resolve(hook.path, hook.entrypoint);
const hookInstance: HookConfig | { default: HookConfig } = require(hookPath);
const config = getModuleDefault(hookInstance);
this.registerHook(config, hookPath);
} catch (error: any) {
logger.warn(`Couldn't register hook "${hook.name}"`);
logger.warn(error);
@@ -324,11 +341,16 @@ class ExtensionManager {
}
private registerEndpoints(): void {
const endpoints = this.extensions.filter((extension) => extension.type === 'endpoint');
const endpoints = this.extensions.filter((extension): extension is ApiExtension => extension.type === 'endpoint');
for (const endpoint of endpoints) {
try {
this.registerEndpoint(endpoint, this.endpointRouter);
const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint);
const endpointInstance: EndpointConfig | { default: EndpointConfig } = require(endpointPath);
const config = getModuleDefault(endpointInstance);
this.registerEndpoint(config, endpointPath, this.endpointRouter);
} catch (error: any) {
logger.warn(`Couldn't register endpoint "${endpoint.name}"`);
logger.warn(error);
@@ -336,14 +358,43 @@ class ExtensionManager {
}
}
private registerHook(hook: Extension) {
const hookPath = path.resolve(hook.path, hook.entrypoint || '');
const hookInstance: HookConfig | { default: HookConfig } = require(hookPath);
private async registerOperations(): Promise<void> {
const internalPaths = await globby(
path.posix.join(path.relative('.', __dirname).split(path.sep).join(path.posix.sep), 'operations/*/index.(js|ts)')
);
const register = getModuleDefault(hookInstance);
const internalOperations = internalPaths.map((internalPath) => {
const dirs = internalPath.split(path.sep);
return {
name: dirs[dirs.length - 2],
path: dirs.slice(0, -1).join(path.sep),
entrypoint: { api: dirs[dirs.length - 1] },
};
});
const operations = this.extensions.filter(
(extension): extension is HybridExtension => extension.type === 'operation'
);
for (const operation of [...internalOperations, ...operations]) {
try {
const operationPath = path.resolve(operation.path, operation.entrypoint.api);
const operationInstance: OperationApiConfig | { default: OperationApiConfig } = require(operationPath);
const config = getModuleDefault(operationInstance);
this.registerOperation(config, operationPath);
} catch (error: any) {
logger.warn(`Couldn't register operation "${operation.name}"`);
logger.warn(error);
}
}
}
private registerHook(register: HookConfig, path: string) {
const hookHandler: { path: string; events: EventHandler[] } = {
path: hookPath,
path,
events: [],
};
@@ -410,14 +461,9 @@ class ExtensionManager {
this.apiExtensions.hooks.push(hookHandler);
}
private registerEndpoint(endpoint: Extension, router: Router) {
const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint || '');
const endpointInstance: EndpointConfig | { default: EndpointConfig } = require(endpointPath);
const mod = getModuleDefault(endpointInstance);
const register = typeof mod === 'function' ? mod : mod.handler;
const routeName = typeof mod === 'function' ? endpoint.name : mod.id;
private registerEndpoint(config: EndpointConfig, path: string, router: Router) {
const register = typeof config === 'function' ? config : config.handler;
const routeName = typeof config === 'function' ? config.name : config.id;
const scopedRouter = express.Router();
router.use(`/${routeName}`, scopedRouter);
@@ -433,7 +479,17 @@ class ExtensionManager {
});
this.apiExtensions.endpoints.push({
path: endpointPath,
path,
});
}
private registerOperation(config: OperationApiConfig, path: string) {
const flowManager = getFlowManager();
flowManager.addOperation(config.id, config.handler);
this.apiExtensions.operations.push({
path,
});
}
@@ -471,4 +527,16 @@ class ExtensionManager {
this.apiExtensions.endpoints = [];
}
private unregisterOperations(): void {
for (const operation of this.apiExtensions.operations) {
delete require.cache[require.resolve(operation.path)];
}
const flowManager = getFlowManager();
flowManager.clearOperations();
this.apiExtensions.operations = [];
}
}

367
api/src/flows.ts Normal file
View File

@@ -0,0 +1,367 @@
import * as sharedExceptions from '@directus/shared/exceptions';
import {
ActionHandler,
FilterHandler,
Flow,
Operation,
OperationHandler,
SchemaOverview,
Accountability,
Action,
} from '@directus/shared/types';
import { get } from 'micromustache';
import { schedule, validate } from 'node-cron';
import getDatabase from './database';
import emitter from './emitter';
import env from './env';
import * as exceptions from './exceptions';
import logger from './logger';
import * as services from './services';
import { FlowsService } from './services';
import { EventHandler } from './types';
import { constructFlowTree } from './utils/construct-flow-tree';
import { getSchema } from './utils/get-schema';
import { ActivityService } from './services/activity';
import { RevisionsService } from './services/revisions';
import { Knex } from 'knex';
import { omit } from 'lodash';
import { getMessenger } from './messenger';
import fastRedact from 'fast-redact';
import { applyOperationOptions } from './utils/operation-options';
let flowManager: FlowManager | undefined;
const redactLogs = fastRedact({
censor: '--redacted--',
paths: ['*.headers.authorization', '*.access_token', '*.headers.cookie'],
serialize: false,
});
export function getFlowManager(): FlowManager {
if (flowManager) {
return flowManager;
}
flowManager = new FlowManager();
return flowManager;
}
type TriggerHandler = {
id: string;
events: EventHandler[];
};
const TRIGGER_KEY = '$trigger';
const ACCOUNTABILITY_KEY = '$accountability';
const LAST_KEY = '$last';
class FlowManager {
private operations: Record<string, OperationHandler> = {};
private triggerHandlers: TriggerHandler[] = [];
private operationFlowHandlers: Record<string, any> = {};
private webhookFlowHandlers: Record<string, any> = {};
public async initialize(): Promise<void> {
const flowsService = new FlowsService({ knex: getDatabase(), schema: await getSchema() });
const flows = await flowsService.readByQuery({
filter: { status: { _eq: 'active' } },
fields: ['*', 'operations.*'],
});
const flowTrees = flows.map((flow) => constructFlowTree(flow));
for (const flow of flowTrees) {
if (flow.trigger === 'event') {
const events: string[] =
flow.options?.scope
?.map((scope: string) => {
if (['items.create', 'items.update', 'items.delete'].includes(scope)) {
return (
flow.options?.collections?.map((collection: string) => {
if (collection.startsWith('directus_')) {
const action = scope.split('.')[1];
return collection.substring(9) + '.' + action;
}
return `${collection}.${scope}`;
}) ?? []
);
}
return scope;
})
?.flat() ?? [];
if (flow.options.type === 'filter') {
const handler: FilterHandler = (payload, meta, context) =>
this.executeFlow(
flow,
{ payload, ...meta },
{
accountability: context.accountability,
database: context.database,
getSchema: context.schema ? () => context.schema : getSchema,
}
);
events.forEach((event) => emitter.onFilter(event, handler));
this.triggerHandlers.push({
id: flow.id,
events: events.map((event) => ({ type: 'filter', name: event, handler })),
});
} else if (flow.options.type === 'action') {
const handler: ActionHandler = (meta, context) =>
this.executeFlow(flow, meta, {
accountability: context.accountability,
database: context.database,
getSchema: context.schema ? () => context.schema : getSchema,
});
events.forEach((event) => emitter.onAction(event, handler));
this.triggerHandlers.push({
id: flow.id,
events: events.map((event) => ({ type: 'action', name: event, handler })),
});
}
} else if (flow.trigger === 'schedule') {
if (validate(flow.options.cron)) {
const task = schedule(flow.options.cron, async () => {
try {
await this.executeFlow(flow);
} catch (error: any) {
logger.error(error);
}
});
this.triggerHandlers.push({ id: flow.id, events: [{ type: flow.trigger, task }] });
} else {
logger.warn(`Couldn't register cron trigger. Provided cron is invalid: ${flow.options.cron}`);
}
} else if (flow.trigger === 'operation') {
const handler = (data: unknown, context: Record<string, unknown>) => this.executeFlow(flow, data, context);
this.operationFlowHandlers[flow.id] = handler;
} else if (flow.trigger === 'webhook') {
const handler = (data: unknown, context: Record<string, unknown>) => {
if (flow.options.async) {
this.executeFlow(flow, data, context);
} else {
return this.executeFlow(flow, data, context);
}
};
const method = flow.options?.method ?? 'GET';
// Default return to $last for webhooks
flow.options.return = flow.options.return ?? '$last';
this.webhookFlowHandlers[`${method}-${flow.id}`] = handler;
} else if (flow.trigger === 'manual') {
const handler = (data: unknown, context: Record<string, unknown>) => {
const enabledCollections = flow.options?.collections ?? [];
const targetCollection = (data as Record<string, any>)?.body.collection;
if (!targetCollection) {
logger.warn(`Manual trigger requires "collection" to be specified in the payload`);
throw new exceptions.ForbiddenException();
}
if (enabledCollections.length === 0) {
logger.warn(`There is no collections configured for this manual trigger`);
throw new exceptions.ForbiddenException();
}
if (!enabledCollections.includes(targetCollection)) {
logger.warn(`Specified collection must be one of: ${enabledCollections.join(', ')}.`);
throw new exceptions.ForbiddenException();
}
if (flow.options.async) {
this.executeFlow(flow, data, context);
} else {
return this.executeFlow(flow, data, context);
}
};
// Default return to $last for manual
flow.options.return = '$last';
this.webhookFlowHandlers[`POST-${flow.id}`] = handler;
}
}
getMessenger().subscribe('flows', (event) => {
if (event.type === 'reload') {
this.reload();
}
});
}
public async reload(): Promise<void> {
for (const trigger of this.triggerHandlers) {
trigger.events.forEach((event) => {
switch (event.type) {
case 'filter':
emitter.offFilter(event.name, event.handler);
break;
case 'action':
emitter.offAction(event.name, event.handler);
break;
case 'schedule':
event.task.stop();
break;
}
});
}
this.triggerHandlers = [];
this.operationFlowHandlers = {};
this.webhookFlowHandlers = {};
await this.initialize();
}
public addOperation(id: string, operation: OperationHandler): void {
this.operations[id] = operation;
}
public clearOperations(): void {
this.operations = {};
}
public async runOperationFlow(id: string, data: unknown, context: Record<string, unknown>): Promise<unknown> {
if (!(id in this.operationFlowHandlers)) {
logger.warn(`Couldn't find operation triggered flow with id "${id}"`);
return null;
}
const handler = this.operationFlowHandlers[id];
return handler(data, context);
}
public async runWebhookFlow(id: string, data: unknown, context: Record<string, unknown>): Promise<unknown> {
if (!(id in this.webhookFlowHandlers)) {
logger.warn(`Couldn't find webhook or manual triggered flow with id "${id}"`);
throw new exceptions.ForbiddenException();
}
const handler = this.webhookFlowHandlers[id];
return handler(data, context);
}
private async executeFlow(flow: Flow, data: unknown = null, context: Record<string, unknown> = {}): Promise<unknown> {
const database = (context.database as Knex) ?? getDatabase();
const schema = (context.schema as SchemaOverview) ?? (await getSchema({ database }));
const keyedData: Record<string, unknown> = {
[TRIGGER_KEY]: data,
[LAST_KEY]: data,
[ACCOUNTABILITY_KEY]: context?.accountability ?? null,
};
let nextOperation = flow.operation;
const steps: {
operation: string;
key: string;
status: 'resolve' | 'reject' | 'unknown';
options: Record<string, any> | null;
}[] = [];
while (nextOperation !== null) {
const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, context);
keyedData[nextOperation.key] = data;
keyedData[LAST_KEY] = data;
steps.push({ operation: nextOperation!.id, key: nextOperation.key, status, options });
nextOperation = successor;
}
if (flow.accountability !== null) {
const activityService = new ActivityService({
knex: database,
schema: schema,
});
const accountability = context?.accountability as Accountability | undefined;
const activity = await activityService.createOne({
action: Action.RUN,
user: accountability?.user ?? null,
collection: 'directus_flows',
ip: accountability?.ip ?? null,
user_agent: accountability?.userAgent ?? null,
item: flow.id,
});
if (flow.accountability === 'all') {
const revisionsService = new RevisionsService({
knex: database,
schema: schema,
});
await revisionsService.createOne({
activity: activity,
collection: 'directus_flows',
item: flow.id,
data: {
steps: steps,
data: redactLogs(omit(keyedData, '$accountability.permissions')), // Permissions is a ton of data, and is just a copy of what's in the directus_permissions table
},
});
}
}
if (flow.options.return === '$all') {
return keyedData;
} else if (flow.options.return) {
return get(keyedData, flow.options.return);
}
return undefined;
}
private async executeOperation(
operation: Operation,
keyedData: Record<string, unknown>,
context: Record<string, unknown> = {}
): Promise<{
successor: Operation | null;
status: 'resolve' | 'reject' | 'unknown';
data: unknown;
options: Record<string, any> | null;
}> {
if (!(operation.type in this.operations)) {
logger.warn(`Couldn't find operation ${operation.type}`);
return { successor: null, status: 'unknown', data: null, options: null };
}
const handler = this.operations[operation.type];
const options = applyOperationOptions(operation.options, keyedData);
try {
const result = await handler(options, {
services,
exceptions: { ...exceptions, ...sharedExceptions },
env,
database: getDatabase(),
logger,
getSchema,
data: keyedData,
accountability: null,
...context,
});
return { successor: operation.resolve, status: 'resolve', data: result ?? null, options };
} catch (error: unknown) {
return { successor: operation.reject, status: 'reject', data: error ?? null, options };
}
}
}

View File

@@ -0,0 +1,20 @@
import { Filter } from '@directus/shared/types';
import { defineOperationApi, validatePayload } from '@directus/shared/utils';
type Options = {
filter: Filter;
};
export default defineOperationApi<Options>({
id: 'condition',
handler: ({ filter }, { data }) => {
const errors = validatePayload(filter, data);
if (errors.length > 0) {
throw errors;
} else {
return null;
}
},
});

View File

@@ -0,0 +1,51 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { Item } from '../../types';
import { optionToObject } from '../../utils/operation-options';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
type Options = {
collection: string;
payload?: Record<string, any> | string | null;
emitEvents: boolean;
permissions: string; // $public, $trigger, $full, or UUID of a role
};
export default defineOperationApi<Options>({
id: 'item-create',
handler: async ({ collection, payload, emitEvents, permissions }, { accountability, database, getSchema }) => {
const schema = await getSchema({ database });
let customAccountability: Accountability | null;
if (!permissions || permissions === '$trigger') {
customAccountability = accountability;
} else if (permissions === '$full') {
customAccountability = null;
} else if (permissions === '$public') {
customAccountability = await getAccountabilityForRole(null, { database, schema, accountability });
} else {
customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability });
}
const itemsService = new ItemsService(collection, {
schema: await getSchema({ database }),
accountability: customAccountability,
knex: database,
});
const payloadObject: Partial<Item> | Partial<Item>[] | null = optionToObject(payload) ?? null;
let result: PrimaryKey[] | null;
if (!payloadObject) {
result = null;
} else {
result = await itemsService.createMany(toArray(payloadObject), { emitEvents });
}
return result;
},
});

View File

@@ -0,0 +1,56 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { optionToObject } from '../../utils/operation-options';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
type Options = {
collection: string;
key?: PrimaryKey | PrimaryKey[] | null;
query?: Record<string, any> | string | null;
permissions: string; // $public, $trigger, $full, or UUID of a role
};
export default defineOperationApi<Options>({
id: 'item-delete',
handler: async ({ collection, key, query, permissions }, { accountability, database, getSchema }) => {
const schema = await getSchema({ database });
let customAccountability: Accountability | null;
if (!permissions || permissions === '$trigger') {
customAccountability = accountability;
} else if (permissions === '$full') {
customAccountability = null;
} else if (permissions === '$public') {
customAccountability = await getAccountabilityForRole(null, { database, schema, accountability });
} else {
customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability });
}
const itemsService: ItemsService = new ItemsService(collection, {
schema: await getSchema({ database }),
accountability: customAccountability,
knex: database,
});
const queryObject = query ? optionToObject(query) : {};
let result: PrimaryKey | PrimaryKey[] | null;
if (!key) {
result = await itemsService.deleteByQuery(queryObject);
} else {
const keys = toArray(key);
if (keys.length === 1) {
result = await itemsService.deleteOne(keys[0]);
} else {
result = await itemsService.deleteMany(keys);
}
}
return result;
},
});

View File

@@ -0,0 +1,57 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { Item } from '../../types';
import { optionToObject } from '../../utils/operation-options';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
type Options = {
collection: string;
key?: PrimaryKey | PrimaryKey[] | null;
query?: Record<string, any> | string | null;
permissions: string; // $public, $trigger, $full, or UUID of a role
};
export default defineOperationApi<Options>({
id: 'item-read',
handler: async ({ collection, key, query, permissions }, { accountability, database, getSchema }) => {
const schema = await getSchema({ database });
let customAccountability: Accountability | null;
if (!permissions || permissions === '$trigger') {
customAccountability = accountability;
} else if (permissions === '$full') {
customAccountability = null;
} else if (permissions === '$public') {
customAccountability = await getAccountabilityForRole(null, { database, schema, accountability });
} else {
customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability });
}
const itemsService = new ItemsService(collection, {
schema,
accountability: customAccountability,
knex: database,
});
const queryObject = query ? optionToObject(query) : {};
let result: Item | Item[] | null;
if (!key) {
result = await itemsService.readByQuery(queryObject);
} else {
const keys = toArray(key);
if (keys.length === 1) {
result = await itemsService.readOne(keys[0], queryObject);
} else {
result = await itemsService.readMany(keys, queryObject);
}
}
return result;
},
});

View File

@@ -0,0 +1,64 @@
import { Accountability, PrimaryKey } from '@directus/shared/types';
import { defineOperationApi, toArray } from '@directus/shared/utils';
import { ItemsService } from '../../services';
import { Item } from '../../types';
import { optionToObject } from '../../utils/operation-options';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
type Options = {
collection: string;
key?: PrimaryKey | PrimaryKey[] | null;
payload?: Record<string, any> | string | null;
query?: Record<string, any> | string | null;
permissions: string; // $public, $trigger, $full, or UUID of a role
};
export default defineOperationApi<Options>({
id: 'item-update',
handler: async ({ collection, key, payload, query, permissions }, { accountability, database, getSchema }) => {
const schema = await getSchema({ database });
let customAccountability: Accountability | null;
if (!permissions || permissions === '$trigger') {
customAccountability = accountability;
} else if (permissions === '$full') {
customAccountability = null;
} else if (permissions === '$public') {
customAccountability = await getAccountabilityForRole(null, { database, schema, accountability });
} else {
customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability });
}
const itemsService: ItemsService = new ItemsService(collection, {
schema: await getSchema({ database }),
accountability: customAccountability,
knex: database,
});
const payloadObject: Partial<Item> | Partial<Item>[] | null = optionToObject(payload) ?? null;
const queryObject = query ? optionToObject(query) : {};
if (!payloadObject) {
return null;
}
let result: PrimaryKey | PrimaryKey[] | null;
if (!key) {
result = await itemsService.updateByQuery(queryObject, payloadObject);
} else {
const keys = toArray(key);
if (keys.length === 1) {
result = await itemsService.updateOne(keys[0], payloadObject);
} else {
result = await itemsService.updateMany(keys, payloadObject);
}
}
return result;
},
});

View File

@@ -0,0 +1,15 @@
import { defineOperationApi } from '@directus/shared/utils';
import logger from '../../logger';
import { optionToString } from '../../utils/operation-options';
type Options = {
message: unknown;
};
export default defineOperationApi<Options>({
id: 'log',
handler: ({ message }) => {
logger.info(optionToString(message));
},
});

View File

@@ -0,0 +1,23 @@
import { defineOperationApi } from '@directus/shared/utils';
import { MailService } from '../../services';
import { md } from '../../utils/md';
type Options = {
body: string;
to: string;
subject: string;
};
export default defineOperationApi<Options>({
id: 'mail',
handler: async ({ body, to, subject }, { accountability, database, getSchema }) => {
const mailService = new MailService({ schema: await getSchema({ database }), accountability, knex: database });
await mailService.send({
html: md(body),
to,
subject,
});
},
});

View File

@@ -0,0 +1,49 @@
import { Accountability } from '@directus/shared/types';
import { defineOperationApi } from '@directus/shared/utils';
import { NotificationsService } from '../../services';
import { optionToString } from '../../utils/operation-options';
import { getAccountabilityForRole } from '../../utils/get-accountability-for-role';
type Options = {
recipient: string;
subject: string;
message?: unknown | null;
permissions: string; // $public, $trigger, $full, or UUID of a role
};
export default defineOperationApi<Options>({
id: 'notification',
handler: async ({ recipient, subject, message, permissions }, { accountability, database, getSchema }) => {
const schema = await getSchema({ database });
let customAccountability: Accountability | null;
if (!permissions || permissions === '$trigger') {
customAccountability = accountability;
} else if (permissions === '$full') {
customAccountability = null;
} else if (permissions === '$public') {
customAccountability = await getAccountabilityForRole(null, { database, schema, accountability });
} else {
customAccountability = await getAccountabilityForRole(permissions, { database, schema, accountability });
}
const notificationsService = new NotificationsService({
schema: await getSchema({ database }),
accountability: customAccountability,
knex: database,
});
const messageString = message ? optionToString(message) : null;
const result = await notificationsService.createOne({
recipient,
sender: customAccountability?.user ?? null,
subject,
message: messageString,
});
return result;
},
});

View File

@@ -0,0 +1,19 @@
import { defineOperationApi } from '@directus/shared/utils';
import axios, { Method } from 'axios';
type Options = {
url: string;
method: Method;
body: Record<string, any> | string | null;
headers: Record<string, string>;
};
export default defineOperationApi<Options>({
id: 'request',
handler: async ({ url, method, body, headers }) => {
const result = await axios({ url, method, data: body, headers });
return { status: result.status, statusText: result.statusText, headers: result.headers, data: result.data };
},
});

View File

@@ -0,0 +1,13 @@
import { defineOperationApi } from '@directus/shared/utils';
type Options = {
milliseconds: string | number;
};
export default defineOperationApi<Options>({
id: 'sleep',
handler: async ({ milliseconds }) => {
await new Promise((resolve) => setTimeout(resolve, Number(milliseconds)));
},
});

View File

@@ -0,0 +1,14 @@
import { defineOperationApi } from '@directus/shared/utils';
import { parseJSON } from '../../utils/parse-json';
type Options = {
json: string;
};
export default defineOperationApi<Options>({
id: 'transform',
handler: ({ json }) => {
return parseJSON(json);
},
});

View File

@@ -0,0 +1,28 @@
import { defineOperationApi } from '@directus/shared/utils';
import { getFlowManager } from '../../flows';
import { optionToObject } from '../../utils/operation-options';
type Options = {
flow: string;
payload?: Record<string, any> | Record<string, any>[] | string | null;
};
export default defineOperationApi<Options>({
id: 'trigger',
handler: async ({ flow, payload }, context) => {
const flowManager = getFlowManager();
const payloadObject = optionToObject(payload) ?? null;
let result: unknown | unknown[];
if (Array.isArray(payloadObject)) {
result = await Promise.all(payloadObject.map((payload) => flowManager.runOperationFlow(flow, payload, context)));
} else {
result = await flowManager.runOperationFlow(flow, payloadObject, context);
}
return result;
},
});

View File

@@ -1,18 +1,17 @@
import { AbstractServiceOptions, PrimaryKey, Item, Action } from '../types';
import { ItemsService } from './items';
import { MutationOptions } from '../types';
import { NotificationsService } from './notifications';
import { UsersService } from './users';
import { AuthorizationService } from './authorization';
import { Accountability } from '@directus/shared/types';
import { getPermissions } from '../utils/get-permissions';
import { Accountability, Action } from '@directus/shared/types';
import { uniq } from 'lodash';
import validateUUID from 'uuid-validate';
import env from '../env';
import { ForbiddenException } from '../exceptions/forbidden';
import logger from '../logger';
import { userName } from '../utils/user-name';
import { uniq } from 'lodash';
import env from '../env';
import validateUUID from 'uuid-validate';
import { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types';
import { getPermissions } from '../utils/get-permissions';
import { Url } from '../utils/url';
import { userName } from '../utils/user-name';
import { AuthorizationService } from './authorization';
import { ItemsService } from './items';
import { NotificationsService } from './notifications';
import { UsersService } from './users';
export class ActivityService extends ItemsService {
notificationsService: NotificationsService;

View File

@@ -1,27 +1,27 @@
import { Accountability, Action, SchemaOverview } from '@directus/shared/types';
import jwt from 'jsonwebtoken';
import { Knex } from 'knex';
import { clone, cloneDeep } from 'lodash';
import ms from 'ms';
import { nanoid } from 'nanoid';
import { performance } from 'perf_hooks';
import { getAuthProvider } from '../auth';
import { DEFAULT_AUTH_PROVIDER } from '../constants';
import getDatabase from '../database';
import emitter from '../emitter';
import env from '../env';
import { getAuthProvider } from '../auth';
import { DEFAULT_AUTH_PROVIDER } from '../constants';
import {
InvalidCredentialsException,
InvalidOTPException,
UserSuspendedException,
InvalidProviderException,
UserSuspendedException,
} from '../exceptions';
import { createRateLimiter } from '../rate-limiter';
import { ActivityService } from './activity';
import { TFAService } from './tfa';
import { AbstractServiceOptions, Action, Session, User, DirectusTokenPayload, LoginResult } from '../types';
import { Accountability, SchemaOverview } from '@directus/shared/types';
import { SettingsService } from './settings';
import { clone, cloneDeep } from 'lodash';
import { performance } from 'perf_hooks';
import { AbstractServiceOptions, DirectusTokenPayload, LoginResult, Session, User } from '../types';
import { stall } from '../utils/stall';
import { ActivityService } from './activity';
import { SettingsService } from './settings';
import { TFAService } from './tfa';
const loginAttemptsLimiter = createRateLimiter({ duration: 0 });

49
api/src/services/flows.ts Normal file
View File

@@ -0,0 +1,49 @@
import { FlowRaw } from '@directus/shared/types';
import { getMessenger, Messenger } from '../messenger';
import { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types';
import { ItemsService } from './items';
export class FlowsService extends ItemsService<FlowRaw> {
messenger: Messenger;
constructor(options: AbstractServiceOptions) {
super('directus_flows', options);
this.messenger = getMessenger();
}
async createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.createOne(data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.createMany(data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.updateOne(key, data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.updateMany(keys, data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.deleteOne(key, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.deleteMany(keys, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
}

View File

@@ -1,5 +1,5 @@
import { BaseException } from '@directus/shared/exceptions';
import { Accountability, Aggregate, Filter, Query, SchemaOverview } from '@directus/shared/types';
import { Accountability, Action, Aggregate, Filter, Query, SchemaOverview } from '@directus/shared/types';
import argon2 from 'argon2';
import {
ArgumentNode,
@@ -52,7 +52,7 @@ import getDatabase from '../database';
import env from '../env';
import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions';
import { getExtensionManager } from '../extensions';
import { AbstractServiceOptions, Action, GraphQLParams, Item } from '../types';
import { AbstractServiceOptions, GraphQLParams, Item } from '../types';
import { generateHash } from '../utils/generate-hash';
import { getGraphQLType } from '../utils/get-graphql-type';
import { reduceSchema } from '../utils/reduce-schema';
@@ -63,9 +63,11 @@ import { AuthenticationService } from './authentication';
import { CollectionsService } from './collections';
import { FieldsService } from './fields';
import { FilesService } from './files';
import { FlowsService } from './flows';
import { FoldersService } from './folders';
import { ItemsService } from './items';
import { NotificationsService } from './notifications';
import { OperationsService } from './operations';
import { PermissionsService } from './permissions';
import { PresetsService } from './presets';
import { RelationsService } from './relations';
@@ -1635,6 +1637,10 @@ export class GraphQLService {
return new WebhooksService(opts);
case 'directus_shares':
return new SharesService(opts);
case 'directus_flows':
return new FlowsService(opts);
case 'directus_operations':
return new OperationsService(opts);
default:
return new ItemsService(collection, opts);
}

View File

@@ -8,12 +8,14 @@ export * from './collections';
export * from './dashboards';
export * from './fields';
export * from './files';
export * from './flows';
export * from './folders';
export * from './graphql';
export * from './import-export';
export * from './mail';
export * from './meta';
export * from './notifications';
export * from './operations';
export * from './panels';
export * from './payload';
export * from './permissions';

View File

@@ -1,4 +1,4 @@
import { Accountability, PermissionsAction, Query, SchemaOverview } from '@directus/shared/types';
import { Accountability, Action, PermissionsAction, Query, SchemaOverview } from '@directus/shared/types';
import Keyv from 'keyv';
import { Knex } from 'knex';
import { assign, clone, cloneDeep, pick, without } from 'lodash';
@@ -9,14 +9,7 @@ import emitter from '../emitter';
import env from '../env';
import { ForbiddenException } from '../exceptions';
import { translateDatabaseError } from '../exceptions/database/translate';
import {
AbstractService,
AbstractServiceOptions,
Action,
Item as AnyItem,
MutationOptions,
PrimaryKey,
} from '../types';
import { AbstractService, AbstractServiceOptions, Item as AnyItem, MutationOptions, PrimaryKey } from '../types';
import getASTFromQuery from '../utils/get-ast-from-query';
import { AuthorizationService } from './authorization';
import { ActivityService, RevisionsService } from './index';

View File

@@ -1,8 +1,9 @@
import { UsersService, MailService } from '.';
import { AbstractServiceOptions, PrimaryKey, MutationOptions } from '../types';
import { ItemsService } from './items';
import { Notification } from '@directus/shared/types';
import { md } from '../utils/md';
import { UsersService } from './users';
import { MailService } from './mail';
export class NotificationsService extends ItemsService {
usersService: UsersService;

View File

@@ -0,0 +1,49 @@
import { OperationRaw } from '@directus/shared/types';
import { getMessenger, Messenger } from '../messenger';
import { AbstractServiceOptions, Item, MutationOptions, PrimaryKey } from '../types';
import { ItemsService } from './items';
export class OperationsService extends ItemsService<OperationRaw> {
messenger: Messenger;
constructor(options: AbstractServiceOptions) {
super('directus_operations', options);
this.messenger = getMessenger();
}
async createOne(data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.createOne(data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async createMany(data: Partial<Item>[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.createMany(data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async updateOne(key: PrimaryKey, data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.updateOne(key, data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async updateMany(keys: PrimaryKey[], data: Partial<Item>, opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.updateMany(keys, data, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async deleteOne(key: PrimaryKey, opts?: MutationOptions): Promise<PrimaryKey> {
const result = await super.deleteOne(key, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
async deleteMany(keys: PrimaryKey[], opts?: MutationOptions): Promise<PrimaryKey[]> {
const result = await super.deleteMany(keys, opts);
this.messenger.publish('flows', { type: 'reload' });
return result;
}
}

8
api/src/types/events.ts Normal file
View File

@@ -0,0 +1,8 @@
import { ActionHandler, FilterHandler, InitHandler } from '@directus/shared/types';
import { ScheduledTask } from 'node-cron';
export type EventHandler =
| { type: 'filter'; name: string; handler: FilterHandler }
| { type: 'action'; name: string; handler: ActionHandler }
| { type: 'init'; name: string; handler: InitHandler }
| { type: 'schedule'; task: ScheduledTask };

View File

@@ -2,8 +2,7 @@
* Custom properties on the req object in express
*/
import { Accountability } from '@directus/shared/types';
import { Query } from '@directus/shared/types';
import { Accountability, Query } from '@directus/shared/types';
import { SchemaOverview } from './schema';
export {};

View File

@@ -1,8 +1,8 @@
export * from './activity';
export * from './assets';
export * from './ast';
export * from './auth';
export * from './collection';
export * from './events';
export * from './files';
export * from './graphql';
export * from './items';

View File

@@ -0,0 +1,36 @@
import { Flow, FlowRaw, Operation, OperationRaw } from '@directus/shared/types';
import { omit } from 'lodash';
export function constructFlowTree(flow: FlowRaw): Flow {
const rootOperation = flow.operations.find((operation) => operation.id === flow.operation) ?? null;
const operationTree = constructOperationTree(rootOperation, flow.operations);
const flowTree: Flow = {
...omit(flow, 'operations'),
operation: operationTree,
};
return flowTree;
}
function constructOperationTree(root: OperationRaw | null, operations: OperationRaw[]): Operation | null {
if (root === null) {
return null;
}
const resolveOperation = root.resolve !== null ? operations.find((operation) => operation.id === root.resolve) : null;
const rejectOperation = root.reject !== null ? operations.find((operation) => operation.id === root.reject) : null;
if (resolveOperation === undefined || rejectOperation === undefined) {
throw new Error('Undefined reference in operations');
}
const operationTree: Operation = {
...omit(root, 'flow'),
resolve: constructOperationTree(resolveOperation, operations),
reject: constructOperationTree(rejectOperation, operations),
};
return operationTree;
}

View File

@@ -0,0 +1,47 @@
import { Accountability, SchemaOverview } from '@directus/shared/types';
import { getPermissions } from './get-permissions';
import { InvalidConfigException } from '../exceptions';
import { Knex } from 'knex';
export async function getAccountabilityForRole(
role: null | string,
context: {
accountability: null | Accountability;
schema: SchemaOverview;
database: Knex;
}
): Promise<Accountability> {
let generatedAccountability: Accountability | null = context.accountability;
if (role === null) {
generatedAccountability = {
role: null,
user: null,
admin: false,
app: false,
};
generatedAccountability.permissions = await getPermissions(generatedAccountability, context.schema);
} else {
const roleInfo = await context.database
.select(['app_access', 'admin_access'])
.from('directus_roles')
.where({ id: role })
.first();
if (!roleInfo) {
throw new InvalidConfigException(`Configured role "${role}" isn't a valid role ID or doesn't exist.`);
}
generatedAccountability = {
role,
user: null,
admin: roleInfo.admin_access === 1 || roleInfo.admin_access === '1' || roleInfo.admin_access === true,
app: roleInfo.app_access === 1 || roleInfo.app_access === '1' || roleInfo.app_access === true,
};
generatedAccountability.permissions = await getPermissions(generatedAccountability, context.schema);
}
return generatedAccountability;
}

View File

@@ -0,0 +1,51 @@
import { renderFn, get, Scope } from 'micromustache';
import { parseJSON } from './parse-json';
type Mustacheable = string | number | boolean | null | Mustacheable[] | { [key: string]: Mustacheable };
type GenericString<T> = T extends string ? string : T;
function resolveFn(path: string, scope?: Scope): unknown {
if (!scope) return undefined;
const value = get(scope, path);
return typeof value === 'object' ? JSON.stringify(value) : value;
}
function renderMustache<T extends Mustacheable>(item: T, scope: Scope): GenericString<T> {
if (typeof item === 'string') {
return renderFn(item, resolveFn, scope, { explicit: true }) as GenericString<T>;
} else if (Array.isArray(item)) {
return item.map((element) => renderMustache(element, scope)) as GenericString<T>;
} else if (typeof item === 'object' && item !== null) {
return Object.fromEntries(
Object.entries(item).map(([key, value]) => [key, renderMustache(value, scope)])
) as GenericString<T>;
} else {
return item as GenericString<T>;
}
}
export function applyOperationOptions(options: Record<string, any>, data: Record<string, any>): Record<string, any> {
return Object.fromEntries(
Object.entries(options).map(([key, value]) => {
if (typeof value === 'string') {
const single = value.match(/^\{\{\s*([^}\s]+)\s*\}\}$/);
if (single !== null) {
return [key, get(data, single[1])];
}
}
return [key, renderMustache(value, data)];
})
);
}
export function optionToObject<T>(option: T): Exclude<T, string> {
return typeof option === 'string' ? parseJSON(option) : option;
}
export function optionToString(option: unknown): string {
return typeof option === 'object' ? JSON.stringify(option) : String(option);
}

View File

@@ -54,6 +54,8 @@ import VTextarea from './v-textarea';
import VUpload from './v-upload';
import VDatePicker from './v-date-picker';
import VEmojiPicker from './v-emoji-picker.vue';
import VWorkspace from './v-workspace.vue';
import VWorkspaceTile from './v-workspace-tile.vue';
export function registerComponents(app: App): void {
app.component('VAvatar', VAvatar);
@@ -114,6 +116,8 @@ export function registerComponents(app: App): void {
app.component('VUpload', VUpload);
app.component('VDatePicker', VDatePicker);
app.component('VEmojiPicker', VEmojiPicker);
app.component('VWorkspace', VWorkspace);
app.component('VWorkspaceTile', VWorkspaceTile);
app.component('TransitionBounce', TransitionBounce);
app.component('TransitionDialog', TransitionDialog);

View File

@@ -199,7 +199,7 @@ export default defineComponent({
--v-button-background-color: var(--primary);
--v-button-background-color-hover: var(--primary-125);
--v-button-background-color-active: var(--primary);
--v-button-background-color-disabled: var(--background-subdued);
--v-button-background-color-disabled: var(--background-normal);
--v-button-font-size: 16px;
--v-button-font-weight: 600;
--v-button-line-height: 22px;

View File

@@ -215,11 +215,6 @@ body {
}
}
&:focus:not(:disabled) {
border-color: var(--primary);
box-shadow: 0 0 16px -8px var(--primary);
}
&:not(:disabled):not(.indeterminate) {
.label {
color: var(--foreground-normal);

View File

@@ -7,7 +7,7 @@
</v-divider>
</slot>
<transition-expand>
<div v-if="internalActive">
<div v-if="internalActive" class="content">
<slot />
</div>
</transition-expand>
@@ -73,7 +73,6 @@ export default defineComponent({
<style lang="scss" scoped>
.v-divider {
margin-bottom: 12px;
cursor: pointer;
}
@@ -90,4 +89,8 @@ export default defineComponent({
.v-icon {
margin-right: 4px;
}
.content {
margin-top: 12px;
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<v-dialog v-model="internalActive" :persistent="persistent" placement="right" @esc="$emit('cancel')">
<v-dialog v-model="internalActive" :persistent="persistent" placement="right" @esc="cancelable && $emit('cancel')">
<template #activator="{ on }">
<slot name="activator" v-bind="{ on }" />
</template>

View File

@@ -1,6 +1,6 @@
export type FancySelectItem = {
icon: string;
value: string | number;
value?: string | number;
text: string;
description?: string;
divider?: boolean;

View File

@@ -6,7 +6,7 @@
<div
v-else
class="v-fancy-select-option"
:class="{ active: item.value === modelValue, disabled }"
:class="{ active: item[itemValue] === modelValue, disabled }"
:style="{
'--index': index,
}"
@@ -17,11 +17,15 @@
</div>
<div class="content">
<div class="text">{{ item.text }}</div>
<div class="description">{{ item.description }}</div>
<div class="text">{{ item[itemText] }}</div>
<div class="description">{{ item[itemDescription] }}</div>
</div>
<v-icon v-if="modelValue === item.value && disabled === false" name="cancel" @click.stop="toggle(item)" />
<v-icon
v-if="modelValue === item[itemValue] && disabled === false"
name="cancel"
@click.stop="toggle(item)"
/>
<v-icon v-else-if="item.iconRight" class="icon-right" :name="item.iconRight" />
</div>
</template>
@@ -29,44 +33,41 @@
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
import { FancySelectItem } from './types';
<script lang="ts" setup>
import { computed } from 'vue';
export default defineComponent({
props: {
items: {
type: Array as PropType<FancySelectItem[]>,
required: true,
},
modelValue: {
type: [String, Number],
default: null,
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const visibleItems = computed(() => {
if (props.modelValue === null) return props.items;
interface Props {
items: Record<string, any>[];
modelValue?: string | number | null;
disabled?: boolean;
itemText?: string;
itemValue?: string;
itemDescription?: string;
}
return props.items.filter((item) => {
return item.value === props.modelValue;
});
});
return { toggle, visibleItems };
function toggle(item: FancySelectItem) {
if (props.disabled === true) return;
if (props.modelValue === item.value) emit('update:modelValue', null);
else emit('update:modelValue', item.value);
}
},
const props = withDefaults(defineProps<Props>(), {
modelValue: () => null,
disabled: false,
itemText: 'text',
itemValue: 'value',
itemDescription: 'description',
});
const emit = defineEmits(['update:modelValue']);
const visibleItems = computed(() => {
if (props.modelValue === null) return props.items;
return props.items.filter((item) => {
return item[props.itemValue] === props.modelValue;
});
});
function toggle(item: Record<string, any>) {
if (props.disabled === true) return;
if (props.modelValue === item[props.itemValue]) emit('update:modelValue', null);
else emit('update:modelValue', item[props.itemValue]);
}
</script>
<style lang="scss" scoped>

View File

@@ -0,0 +1,530 @@
<template>
<div
class="v-workspace-tile"
:style="positionStyling"
:class="{
editing: editMode,
draggable,
dragging,
'br-tl': dragging || borderRadius[0],
'br-tr': dragging || borderRadius[1],
'br-br': dragging || borderRadius[2],
'br-bl': dragging || borderRadius[3],
}"
data-move
@pointerdown="onPointerDown('move', $event)"
>
<div v-if="showHeader" class="header">
<v-icon class="icon" :style="iconColor" :name="icon" small />
<v-text-overflow class="name" :text="name || ''" />
<div class="spacer" />
<v-icon v-if="note" v-tooltip="note" class="note" name="info" />
</div>
<div v-if="editMode" class="edit-actions" @pointerdown.stop>
<v-icon v-tooltip="t('edit')" class="edit-icon" name="edit" clickable @click.stop="$emit('edit')" />
<v-menu v-if="showOptions" placement="bottom-end" show-arrow>
<template #activator="{ toggle }">
<v-icon class="more-icon" name="more_vert" clickable @click="toggle" />
</template>
<v-list>
<v-list-item clickable :disabled="id.startsWith('_')" @click="$emit('move')">
<v-list-item-icon>
<v-icon class="move-icon" name="input" />
</v-list-item-icon>
<v-list-item-content>
{{ t('copy_to') }}
</v-list-item-content>
</v-list-item>
<v-list-item clickable @click="$emit('duplicate')">
<v-list-item-icon>
<v-icon name="control_point_duplicate" />
</v-list-item-icon>
<v-list-item-content>{{ t('duplicate') }}</v-list-item-content>
</v-list-item>
<v-list-item class="delete-action" clickable @click="$emit('delete')">
<v-list-item-icon>
<v-icon name="delete" />
</v-list-item-icon>
<v-list-item-content>{{ t('delete') }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="resize-details">
({{ positioning.x - 1 }}:{{ positioning.y - 1 }})
<template v-if="resizable">{{ positioning.width }}×{{ positioning.height }}</template>
</div>
<div v-if="editMode && resizable" class="resize-handlers">
<div class="top" @pointerdown.stop="onPointerDown('resize-top', $event)" />
<div class="right" @pointerdown.stop="onPointerDown('resize-right', $event)" />
<div class="bottom" @pointerdown.stop="onPointerDown('resize-bottom', $event)" />
<div class="left" @pointerdown.stop="onPointerDown('resize-left', $event)" />
<div class="top-left" @pointerdown.stop="onPointerDown('resize-top-left', $event)" />
<div class="top-right" @pointerdown.stop="onPointerDown('resize-top-right', $event)" />
<div class="bottom-right" @pointerdown.stop="onPointerDown('resize-bottom-right', $event)" />
<div class="bottom-left" @pointerdown.stop="onPointerDown('resize-bottom-left', $event)" />
</div>
<div class="tile-content" :class="{ 'has-header': showHeader }">
<slot></slot>
<div v-if="$slots.footer" class="footer">
<slot name="footer"></slot>
</div>
</div>
<slot name="body"></slot>
</div>
</template>
<script setup lang="ts">
import { Panel } from '@directus/shared/types';
import { computed, ref, reactive, StyleValue } from 'vue';
import { throttle } from 'lodash';
import { useI18n } from 'vue-i18n';
export type AppTile = {
id: string;
x: number;
y: number;
width: number;
height: number;
name?: string;
icon?: string;
color?: string;
note?: string;
showHeader?: boolean;
minWidth?: number;
minHeight?: number;
draggable?: boolean;
borderRadius?: [boolean, boolean, boolean, boolean];
};
// Right now, it is not possible to do type Props = AppTile & {resizable?: boolean; editMode?: boolean}
type Props = {
id: string;
x: number;
y: number;
width: number;
height: number;
name?: string;
icon?: string;
color?: string;
note?: string;
showHeader?: boolean;
minWidth?: number;
minHeight?: number;
draggable?: boolean;
borderRadius?: [boolean, boolean, boolean, boolean];
resizable?: boolean;
editMode?: boolean;
showOptions?: boolean;
alwaysUpdatePosition?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
name: undefined,
icon: 'space_dashboard',
color: 'var(--primary)',
note: undefined,
showHeader: true,
minWidth: 8,
minHeight: 6,
resizable: true,
editMode: false,
draggable: true,
borderRadius: () => [true, true, true, true],
showOptions: true,
alwaysUpdatePosition: false,
});
const emit = defineEmits(['update', 'move', 'duplicate', 'delete', 'edit', 'preview']);
const { t } = useI18n();
/**
* When drag-n-dropping for positioning/resizing, we're
*/
const editedPosition = reactive<Partial<Panel>>({
position_x: undefined,
position_y: undefined,
width: undefined,
height: undefined,
});
const { onPointerDown, dragging } = useDragDrop();
const positioning = computed(() => {
if (dragging.value) {
return {
x: editedPosition.position_x ?? props.x,
y: editedPosition.position_y ?? props.y,
width: editedPosition.width ?? props.width,
height: editedPosition.height ?? props.height,
};
}
return {
x: props.x,
y: props.y,
width: props.width,
height: props.height,
};
});
const positionStyling = computed(() => {
if (dragging.value) {
return {
'--pos-x': editedPosition.position_x ?? props.x,
'--pos-y': editedPosition.position_y ?? props.y,
'--width': editedPosition.width ?? props.width,
'--height': editedPosition.height ?? props.height,
} as StyleValue;
}
return {
'--pos-x': props.x,
'--pos-y': props.y,
'--width': props.width,
'--height': props.height,
} as StyleValue;
});
const iconColor = computed(() => ({
'--v-icon-color': props.color,
}));
function useDragDrop() {
const dragging = ref(false);
let pointerStartPosX = 0;
let pointerStartPosY = 0;
let panelStartPosX = 0;
let panelStartPosY = 0;
let panelStartWidth = 0;
let panelStartHeight = 0;
type Operation =
| 'move'
| 'resize-top'
| 'resize-right'
| 'resize-bottom'
| 'resize-left'
| 'resize-top-left'
| 'resize-top-right'
| 'resize-bottom-right'
| 'resize-bottom-left';
let operation: Operation = 'move';
const onPointerMove = throttle((event: PointerEvent) => {
if (props.editMode === false || dragging.value === false || props.draggable === false) return;
const pointerDeltaX = event.pageX - pointerStartPosX;
const pointerDeltaY = event.pageY - pointerStartPosY;
const gridDeltaX = Math.round(pointerDeltaX / 20);
const gridDeltaY = Math.round(pointerDeltaY / 20);
if (operation === 'move') {
editedPosition.position_x = panelStartPosX + gridDeltaX;
editedPosition.position_y = panelStartPosY + gridDeltaY;
if (editedPosition.position_x < 1) editedPosition.position_x = 1;
if (editedPosition.position_y < 1) editedPosition.position_y = 1;
} else {
if (operation.includes('top')) {
editedPosition.height = panelStartHeight - gridDeltaY;
editedPosition.position_y = panelStartPosY + gridDeltaY;
}
if (operation.includes('right')) {
editedPosition.width = panelStartWidth + gridDeltaX;
}
if (operation.includes('bottom')) {
editedPosition.height = panelStartHeight + gridDeltaY;
}
if (operation.includes('left')) {
editedPosition.width = panelStartWidth - gridDeltaX;
editedPosition.position_x = panelStartPosX + gridDeltaX;
}
const minWidth = props.minWidth;
const minHeight = props.minHeight;
if (editedPosition.position_x && editedPosition.position_x < 1) editedPosition.position_x = 1;
if (editedPosition.position_y && editedPosition.position_y < 1) editedPosition.position_y = 1;
if (editedPosition.width && editedPosition.width < minWidth) editedPosition.width = minWidth;
if (editedPosition.height && editedPosition.height < minHeight) editedPosition.height = minHeight;
}
if (props.alwaysUpdatePosition) emit('update', editedPosition);
}, 20);
return { dragging, onPointerDown, onPointerUp, onPointerMove };
function onPointerDown(op: Operation, event: PointerEvent) {
if (props.editMode === false || props.draggable === false) return;
operation = op;
dragging.value = true;
pointerStartPosX = event.pageX;
pointerStartPosY = event.pageY;
panelStartPosX = props.x;
panelStartPosY = props.y;
panelStartWidth = props.width;
panelStartHeight = props.height;
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointermove', onPointerMove);
}
function onPointerUp() {
dragging.value = false;
if (props.editMode === false || props.draggable === false) return;
emit('update', editedPosition);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointermove', onPointerMove);
editedPosition.position_x = undefined;
editedPosition.position_y = undefined;
editedPosition.width = undefined;
editedPosition.height = undefined;
}
}
</script>
<style scoped lang="scss">
.v-workspace-tile {
--pos-x: 1;
--pos-y: 1;
--width: 6;
--height: 6;
position: relative;
display: block;
grid-row: var(--pos-y) / span var(--height);
grid-column: var(--pos-x) / span var(--width);
background-color: var(--background-page);
border: 1px solid var(--border-subdued);
box-shadow: 0 0 0 1px var(--border-subdued);
z-index: 1;
transition: border var(--fast) var(--transition);
&:hover {
z-index: 3;
}
&.editing {
&.draggable {
border-color: var(--border-normal);
box-shadow: 0 0 0 1px var(--border-normal);
cursor: move;
}
&.draggable:hover {
border-color: var(--border-normal-alt);
box-shadow: 0 0 0 1px var(--border-normal-alt);
}
&.dragging {
z-index: 3 !important;
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
&.dragging .resize-details {
opacity: 1;
}
& .tile-content {
pointer-events: none;
}
}
}
.resize-details {
position: absolute;
top: 0;
right: 0;
z-index: 2;
padding: 17px 14px;
color: var(--foreground-subdued);
font-weight: 500;
font-size: 15px;
font-family: var(--family-monospace);
font-style: normal;
line-height: 1;
text-align: right;
background-color: var(--background-page);
border-top-right-radius: var(--border-radius-outline);
opacity: 0;
transition: opacity var(--fast) var(--transition), color var(--fast) var(--transition);
pointer-events: none;
}
.tile-content {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
overflow: hidden;
}
.tile-content.has-header {
height: calc(100% - 48px);
}
.header {
display: flex;
align-items: center;
height: 42px;
padding: 12px;
}
.footer {
padding: 0 12px;
border-top: 2px solid var(--border-subdued);
margin-top: auto;
padding-top: 8px;
}
.icon {
--v-icon-color: var(--foreground-subdued);
margin-right: 4px;
}
.name {
color: var(--foreground-normal-alt);
font-weight: 600;
font-size: 16px;
font-family: var(--family-sans-serif);
font-style: normal;
}
.spacer {
flex-grow: 1;
}
.more-icon,
.edit-icon,
.note {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
}
.delete-action {
--v-list-item-color: var(--danger);
--v-list-item-color-hover: var(--danger);
--v-list-item-icon-color: var(--danger);
}
.edit-actions {
position: absolute;
top: 0;
right: 0;
z-index: 2;
display: flex;
gap: 4px;
align-items: center;
padding: 12px 12px 8px;
background-color: var(--background-page);
border-top-right-radius: var(--border-radius-outline);
}
.resize-handlers div {
position: absolute;
z-index: 2;
}
.resize-handlers .top {
top: -3px;
width: 100%;
height: 10px;
cursor: ns-resize;
}
.resize-handlers .right {
top: 0;
right: -3px;
width: 10px;
height: 100%;
cursor: ew-resize;
}
.resize-handlers .bottom {
bottom: -3px;
width: 100%;
height: 10px;
cursor: ns-resize;
}
.resize-handlers .left {
top: 0;
left: -3px;
width: 10px;
height: 100%;
cursor: ew-resize;
}
.resize-handlers .top-left {
top: -3px;
left: -3px;
width: 14px;
height: 14px;
cursor: nwse-resize;
}
.resize-handlers .top-right {
top: -3px;
right: -3px;
width: 14px;
height: 14px;
cursor: nesw-resize;
}
.resize-handlers .bottom-right {
right: -3px;
bottom: -3px;
width: 14px;
height: 14px;
cursor: nwse-resize;
}
.resize-handlers .bottom-left {
bottom: -3px;
left: -3px;
width: 14px;
height: 14px;
cursor: nesw-resize;
}
.br-tl {
border-top-left-radius: var(--border-radius-outline);
}
.br-tr {
border-top-right-radius: var(--border-radius-outline);
}
.br-br {
border-bottom-right-radius: var(--border-radius-outline);
}
.br-bl {
border-bottom-left-radius: var(--border-radius-outline);
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<div
class="v-workspace"
:class="{ editing: editMode }"
:style="{ width: workspaceBoxSize.width + 'px', height: workspaceBoxSize.height + 'px' }"
>
<div
class="workspace"
:style="{
transform: `scale(${zoomScale})`,
width: workspaceSize.width + 'px',
height: workspaceSize.height + 'px',
}"
>
<template v-if="!$slots.panel">
<v-workspace-tile
v-for="panel in panels"
:key="panel.id"
v-bind="panel"
:edit-mode="editMode"
:resizable="resizable"
@preview="$emit('preview', panel)"
@edit="$emit('edit', panel)"
@update="$emit('update', { edits: $event, id: panel.id })"
@move="$emit('move', panel.id)"
@delete="$emit('delete', panel.id)"
@duplicate="$emit('duplicate', panel)"
>
<slot :panel="panel"></slot>
</v-workspace-tile>
</template>
<template v-else>
<template v-for="panel in panels" :key="panel.id">
<slot name="panel" :panel="panel"></slot>
</template>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref } from 'vue';
import { useElementSize } from '@/composables/use-element-size';
import { AppTile } from './v-workspace-tile.vue';
import { cssVar } from '@directus/shared/utils/browser';
const props = withDefaults(
defineProps<{
panels: AppTile[];
editMode?: boolean;
zoomToFit?: boolean;
resizable?: boolean;
}>(),
{
editMode: false,
zoomToFit: false,
resizable: true,
}
);
defineEmits(['update', 'move', 'delete', 'duplicate', 'edit', 'preview']);
const mainElement = inject('main-element', ref<Element>());
const mainElementSize = useElementSize(mainElement);
const paddingSize = computed(() => Number(cssVar('--content-padding', mainElement.value)?.slice(0, -2) || 0));
const workspaceSize = computed(() => {
const furthestPanelX = props.panels.reduce(
(aggr, panel) => {
if (panel.x! > aggr.x!) {
aggr.x = panel.x!;
aggr.width = panel.width!;
}
return aggr;
},
{ x: 0, width: 0 }
);
const furthestPanelY = props.panels.reduce(
(aggr, panel) => {
if (panel.y! > aggr.y!) {
aggr.y = panel.y!;
aggr.height = panel.height!;
}
return aggr;
},
{ y: 0, height: 0 }
);
if (props.editMode === true) {
return {
width: (furthestPanelX.x! + furthestPanelX.width! + 25) * 20,
height: (furthestPanelY.y! + furthestPanelY.height! + 25) * 20,
};
}
return {
width: (furthestPanelX.x! + furthestPanelX.width! - 1) * 20,
height: (furthestPanelY.y! + furthestPanelY.height! - 1) * 20,
};
});
const zoomScale = computed(() => {
if (props.zoomToFit === false) return 1;
const { width, height } = mainElementSize;
const scaleWidth: number = (width.value - paddingSize.value * 2) / workspaceSize.value.width;
const scaleHeight: number = (height.value - 114 - paddingSize.value * 2) / workspaceSize.value.height;
return Math.min(scaleWidth, scaleHeight);
});
const workspaceBoxSize = computed(() => {
return {
width: workspaceSize.value.width * zoomScale.value + paddingSize.value * 2,
height: workspaceSize.value.height * zoomScale.value + paddingSize.value * 2,
};
});
</script>
<style scoped>
.v-workspace {
position: relative;
}
.workspace {
position: absolute;
left: var(--content-padding);
display: grid;
grid-template-rows: repeat(auto-fill, 20px);
grid-template-columns: repeat(auto-fill, 20px);
min-width: calc(100%);
min-height: calc(100% - 120px);
transform: scale(1);
transform-origin: top left;
/* This causes the header bar to "unhinge" on the left edge :C */
/* transition: transform var(--slow) var(--transition); */
}
.workspace > * {
z-index: 2;
}
.workspace::before {
position: absolute;
top: -4px;
left: -4px;
display: block;
width: calc(100% + 8px);
height: calc(100% + 8px);
background-image: radial-gradient(var(--border-normal) 10%, transparent 10%);
background-position: -6px -6px;
background-size: 20px 20px;
opacity: 0;
transition: opacity var(--slow) var(--transition);
content: '';
pointer-events: none;
}
.v-workspace.editing .workspace::before {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,197 @@
import api from '@/api';
import { localizedFormat } from '@/utils/localized-format';
import { localizedFormatDistance } from '@/utils/localized-format-distance';
import { unexpectedError } from '@/utils/unexpected-error';
import { Action, Filter } from '@directus/shared/types';
import { isThisYear, isToday, isYesterday, parseISO, format } from 'date-fns';
import { groupBy, orderBy } from 'lodash';
import { ref, Ref, unref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Revision, RevisionsByDate } from '../views/private/components/revisions-drawer-detail/types';
type UseRevisionsOptions = {
action?: Action;
};
export function useRevisions(collection: Ref<string>, primaryKey: Ref<number | string>, options?: UseRevisionsOptions) {
const { t } = useI18n();
const revisions = ref<Revision[] | null>(null);
const revisionsByDate = ref<RevisionsByDate[] | null>(null);
const loading = ref(false);
const revisionsCount = ref(0);
const created = ref<Revision>();
const pagesCount = ref(0);
watch([collection, primaryKey], () => getRevisions(), { immediate: true });
return { created, revisions, revisionsByDate, loading, refresh, revisionsCount, pagesCount };
async function getRevisions(page = 0) {
if (typeof unref(primaryKey) === 'undefined') return;
loading.value = true;
const pageSize = 100;
try {
const filter: Filter = {
_and: [
{
collection: {
_eq: unref(collection),
},
},
{
item: {
_eq: unref(primaryKey),
},
},
],
};
if (options?.action) {
filter._and.push({
activity: {
action: {
_eq: options?.action,
},
},
});
}
const response = await api.get(`/revisions`, {
params: {
filter,
sort: '-id',
limit: pageSize,
page,
fields: [
'id',
'data',
'delta',
'collection',
'item',
'activity.action',
'activity.timestamp',
'activity.user.id',
'activity.user.email',
'activity.user.first_name',
'activity.user.last_name',
'activity.ip',
'activity.user_agent',
],
meta: ['filter_count'],
},
});
const createdResponse = await api.get(`/revisions`, {
params: {
filter: {
collection: {
_eq: unref(collection),
},
item: {
_eq: unref(primaryKey),
},
activity: {
action: {
_eq: 'create',
},
},
},
sort: '-id',
limit: 1,
fields: [
'id',
'data',
'delta',
'collection',
'item',
'activity.action',
'activity.timestamp',
'activity.user.id',
'activity.user.email',
'activity.user.first_name',
'activity.user.last_name',
'activity.ip',
'activity.user_agent',
],
meta: ['filter_count'],
},
});
created.value = createdResponse.data.data?.[0];
const revisionsGroupedByDate = groupBy(
response.data.data.filter((revision: any) => !!revision.activity),
(revision: Revision) => {
// revision's timestamp date is in iso-8601
const date = new Date(new Date(revision.activity.timestamp).toDateString());
return date;
}
);
const revisionsGrouped: RevisionsByDate[] = [];
for (const [key, value] of Object.entries(revisionsGroupedByDate)) {
const date = new Date(key);
const today = isToday(date);
const yesterday = isYesterday(date);
const thisYear = isThisYear(date);
let dateFormatted: string;
if (today) dateFormatted = t('today');
else if (yesterday) dateFormatted = t('yesterday');
else if (thisYear) dateFormatted = await localizedFormat(date, String(t('date-fns_date_short_no_year')));
else dateFormatted = await localizedFormat(date, String(t('date-fns_date_short')));
const revisions = [];
for (const revision of value) {
revisions.push({
...revision,
timestampFormatted: await getFormattedDate(revision.activity?.timestamp),
timeRelative: `${getTime(revision.activity?.timestamp)} (${await localizedFormatDistance(
parseISO(revision.activity?.timestamp),
new Date(),
{
addSuffix: true,
}
)})`,
});
}
revisionsGrouped.push({
date: date,
dateFormatted: String(dateFormatted),
revisions,
});
}
revisionsByDate.value = orderBy(revisionsGrouped, ['date'], ['desc']);
revisions.value = orderBy(response.data.data, ['activity.timestamp'], ['desc']);
revisionsCount.value = response.data.meta.filter_count;
pagesCount.value = Math.ceil(revisionsCount.value / pageSize);
} catch (err: any) {
unexpectedError(err);
} finally {
loading.value = false;
}
}
async function refresh(page = 0) {
await getRevisions(page);
}
function getTime(timestamp: string) {
return format(new Date(timestamp), String(t('date-fns_time')));
}
async function getFormattedDate(timestamp: string) {
const date = await localizedFormat(new Date(timestamp), String(t('date-fns_date_short')));
const time = await localizedFormat(new Date(timestamp), String(t('date-fns_time')));
return `${date} (${time})`;
}
}

View File

@@ -7,6 +7,7 @@ import { getDisplays } from '@/displays';
import { getLayouts } from '@/layouts';
import { getModules } from '@/modules';
import { getPanels } from '@/panels';
import { getOperations } from '@/operations';
export default function useSystem(): void {
provide(STORES_INJECT, stores);
@@ -19,5 +20,6 @@ export default function useSystem(): void {
layouts: getLayouts().layouts,
modules: getModules().modules,
panels: getPanels().panels,
operations: getOperations().operations,
});
}

View File

@@ -7,6 +7,7 @@ import {
useFieldsStore,
useLatencyStore,
useInsightsStore,
useFlowsStore,
usePermissionsStore,
usePresetsStore,
useRelationsStore,
@@ -39,6 +40,7 @@ export function useStores(
useRelationsStore,
usePermissionsStore,
useInsightsStore,
useFlowsStore,
useNotificationsStore,
]
): GenericStore[] {

View File

@@ -16,7 +16,8 @@
<div v-if="filterInfo[index].isField" block class="node field">
<div class="header" :class="{ inline }">
<v-icon name="drag_indicator" class="drag-handle" small></v-icon>
<v-menu placement="bottom-start" show-arrow>
<span v-if="!isExistingField(element)" class="plain-name">{{ getFieldPreview(element) }}</span>
<v-menu v-else placement="bottom-start" show-arrow>
<template #activator="{ toggle }">
<button class="name" @click="toggle">
<span>{{ getFieldPreview(element) }}</span>
@@ -337,6 +338,13 @@ function getCompareOptions(name: string) {
value: `_${type}`,
}));
}
function isExistingField(node: Record<string, any>): boolean {
if (!props.collection) return false;
const fieldKey = getField(node);
const field = fieldsStore.getField(props.collection, fieldKey);
return !!field;
}
</script>
<style lang="scss" scoped>
@@ -390,6 +398,11 @@ function getCompareOptions(name: string) {
}
}
.plain-name {
display: inline-block;
margin-right: 8px;
}
.name {
white-space: nowrap;
}

View File

@@ -1,8 +1,8 @@
<template>
<v-notice v-if="!collectionField && !collection" type="warning">
<v-notice v-if="collectionRequired && !collectionField && !collection" type="warning">
{{ t('collection_field_not_setup') }}
</v-notice>
<v-notice v-else-if="!collection" type="warning">
<v-notice v-else-if="collectionRequired && !collection" type="warning">
{{ t('select_a_collection') }}
</v-notice>
@@ -29,7 +29,7 @@
<button @click="addNode('$group')">{{ t('interfaces.filter.add_group') }}</button>
</div>
<div v-else class="buttons">
<v-menu placement="bottom-start" show-arrow>
<v-menu ref="menuEl" placement="bottom-start" show-arrow>
<template #activator="{ toggle, active }">
<button class="add-filter" :class="{ active }" @click="toggle">
<v-icon v-if="inline" name="add" class="add" small />
@@ -38,7 +38,12 @@
</button>
</template>
<v-field-list :collection="collection" include-functions @select-field="addNode($event)">
<v-field-list
v-if="collectionRequired"
:collection="collection"
include-functions
@select-field="addNode($event)"
>
<template #prepend>
<v-list-item clickable @click="addNode('$group')">
<v-list-item-content>
@@ -48,6 +53,25 @@
<v-divider />
</template>
</v-field-list>
<v-list v-else :mandatory="false">
<v-list-item clickable @click="addNode('$group')">
<v-list-item-content>
<v-text-overflow :text="t('interfaces.filter.add_group')" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item @click.stop>
<v-list-item-content>
<input
v-model="newKey"
class="new-key-input"
:placeholder="t('interfaces.filter.add_key_placeholder')"
@keydown.enter="addKeyAsNode"
/>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
@@ -68,6 +92,7 @@ interface Props {
disabled?: boolean;
collectionName?: string;
collectionField?: string;
collectionRequired?: boolean;
fieldName?: string;
inline?: boolean;
includeValidation?: boolean;
@@ -78,6 +103,7 @@ const props = withDefaults(defineProps<Props>(), {
disabled: false,
collectionName: undefined,
collectionField: undefined,
collectionRequired: true,
fieldName: undefined,
inline: false,
includeValidation: false,
@@ -87,6 +113,8 @@ const emit = defineEmits(['input']);
const { t } = useI18n();
const menuEl = ref();
const values = inject('values', ref<Record<string, any>>({}));
const collection = computed(() => {
@@ -159,6 +187,16 @@ function removeNode(ids: string[]) {
innerValue.value = set(innerValue.value, ids.join('.'), list);
}
// For adding any new fields (eg. flow Validate operation rule)
const newKey = ref<string | null>(null);
function addKeyAsNode() {
if (!newKey.value) return;
if (menuEl.value) menuEl.value.deactivate();
addNode(newKey.value);
newKey.value = null;
}
</script>
<style lang="scss" scoped>
@@ -272,4 +310,17 @@ function removeNode(ids: string[]) {
margin-left: 24px;
}
}
.new-key-input {
margin: 0;
padding: 0;
line-height: 1.2;
background-color: transparent;
border: none;
border-radius: 0;
&::placeholder {
color: var(--v-input-placeholder-color);
}
}
</style>

View File

@@ -7,13 +7,14 @@ import { useCollectionsStore, useFieldsStore } from '@/stores';
import { translate } from '@/utils/translate-object-values';
import availableLanguages from './available-languages.yaml';
import { i18n, Language, loadedLanguages } from './index';
import { getOperations } from '@/operations';
import { useTranslationStrings } from '@/composables/use-translation-strings';
const { modules, modulesRaw } = getModules();
const { layouts, layoutsRaw } = getLayouts();
const { interfaces, interfacesRaw } = getInterfaces();
const { panels, panelsRaw } = getPanels();
const { displays, displaysRaw } = getDisplays();
const { operations, operationsRaw } = getOperations();
export async function setLanguage(lang: Language): Promise<boolean> {
const collectionsStore = useCollectionsStore();
@@ -45,6 +46,7 @@ export async function setLanguage(lang: Language): Promise<boolean> {
interfaces.value = translate(interfacesRaw.value);
panels.value = translate(panelsRaw.value);
displays.value = translate(displaysRaw.value);
operations.value = translate(operationsRaw.value);
collectionsStore.translateCollections();
fieldsStore.translateFields();

View File

@@ -24,15 +24,20 @@ identifier: Identifier
avatar: Avatar
application: Application
default_provider: Default
method: Method
published: Published
draft: Draft
archived: Archived
modules: Modules
module_bar: Module Bar
logs: Logs
reset_width: Reset Width
tile_size: Tile Size
edit_field: Edit Field
conditions: Conditions
location: Location
condition_rules: Condition Rules
input: Input
maps: Maps
switch_user: Switch User
item_creation: Item Creation
@@ -45,6 +50,11 @@ group: Group
export_items: Export Items
and: And
or: Or
init: Init
modifier: Modifier
custom: Custom
hook: Hook
fill_width: Fill Width
field_name_translations: Field Name Translations
enter_password_to_enable_tfa: Enter your password to enable Two-Factor Authentication
@@ -68,6 +78,7 @@ create_preset: Create Preset
create_panel: Create Panel
create_role: Create Role
create_user: Create User
message: Message
delete_panel: Delete Panel
create_webhook: Create Webhook
create_translation_string: Create Translation String
@@ -484,7 +495,9 @@ disabled: Disabled
information: Information
report_bug: Report Bug
request_feature: Request Feature
request_body: Request Body
interface_not_found: 'Interface "{interface}" not found.'
operation_not_found: 'Operation "{operation}" not found.'
reset_interface: Reset Interface
display_not_found: 'Display "{display}" not found.'
reset_display: Reset Display
@@ -740,6 +753,7 @@ on_create: On Create
on_update: On Update
read: Read
update: Update
delete: Delete
select_fields: Select Fields
format_text: Format Text
format_value: Format Value
@@ -750,6 +764,45 @@ toggle: Toggle
icon_on: Icon On
icon_off: Icon Off
label: Label
flows: Flows
flow: Flow
select_a_flow: Select a Flow
run_flow_on_current: Run flow on Current Item
run_flow_on_selected: No Items Selected | Run flow on 1 Selected Item | Run flow on {n} Selected Items
run_flow_success: Flow "{flow}" ran successfully
trigger: Trigger
trigger_options: Trigger Options
view_trigger: View Trigger
create_trigger: Create Trigger
trigger_setup: Trigger Setup
change_trigger: Change Trigger
flow_delete_confirm: Are you sure you want to delete Flow "{flow}"? This action can not be undone.
set_flow_active: Set Flow to Active
set_flow_inactive: Set Flow to Inactive
edit_flow: Edit Flow
delete_flow: Delete Flow
no_other_flows_copy: You don't have any other Flows yet.
no_flows: No Flows
no_flows_copy: You don't have any Flows yet.
create_flow: Create Flow
creating_new_flow: Creating New Flow
updating_flow: Updating Flow
flow_setup: Flow Setup
flow_name: Name
flow_tracking: Activity & Logs Tracking
flow_tracking_all: Track Activity & Logs
flow_tracking_activity: Only Track Activity
flow_tracking_null: Do Not Track Anything
start_flow: Start Flow
stop_flow: Stop Flow
create_operation: Create Operation
edit_operation: Edit Operation
operation_options: Operation Options
operation_name: Name of operation...
operation_key: Identifier for reference...
operation_key_unique_error: Operation keys must be unique in a Flow
operation_handle_resolve: 'Resolve: Click to Add or Drag to Reroute'
operation_handle_reject: 'Reject: Click to Add or Drag to Reroute'
insights: Insights
dashboard: Dashboard
panel: Panel
@@ -767,9 +820,13 @@ suffix_placeholder: Text after value...
decimals_placeholder: Number of decimal places...
no_dashboards: No Dashboards
no_dashboards_copy: You dont have any Dashboards yet.
no_data: No Data
create_dashboard: Create Dashboard
dashboard_name: Dashboard Name
no_data: No Data
no_data_in_flow: No data was produced or accessed within this Flow
accountability: Accountability
payload: Payload
details: Details
full_screen: Full Screen
full_text_search: Full-Text Search
edit_panels: Edit Panels
@@ -867,10 +924,12 @@ settings_data_model: Data Model
settings_permissions: Roles & Permissions
settings_project: Project Settings
settings_webhooks: Webhooks
settings_flows: Flows
settings_presets: Presets & Bookmarks
settings_translation_strings: Translation Strings
one_or_more_options_are_missing: One or more options are missing
scope: Scope
actions: Actions
select: Select...
layout: Layout
tree_view: Tree View
@@ -880,6 +939,7 @@ preset_search_placeholder: Search query...
editing_preset: Editing Preset
layout_preview: Layout Preview
layout_setup: Layout Setup
data: Data
unsaved_changes: Unsaved Changes
unsaved_changes_copy: Are you sure you want to leave this page?
discard_changes: Discard Changes
@@ -919,6 +979,8 @@ page_help_settings_presets_item: >-
**Preset Detail** — A form for managing bookmarks and default collection presets.
page_help_settings_webhooks_collection: '**Browse Webhooks** — Lists all webhooks within the project.'
page_help_settings_webhooks_item: '**Webhook Detail** — A form for creating and managing project webhooks.'
page_help_settings_flows_collection: '**Browse Flows** — Lists all Flows within the project.'
page_help_settings_flows_item: '**Flow Detail** — Workspace for managing one or more operations.'
page_help_settings_translation_strings_collection:
'**Browse Translation Strings** — Lists all translation strings within the project.'
page_help_users_collection: '**User Directory** — Lists all system users within this project.'
@@ -949,11 +1011,13 @@ directus_collection:
directus_dashboards: Dashboards within the Insights module
directus_fields: Additional field configuration and metadata
directus_files: Metadata for all managed file assets
directus_flows: Automation flows
directus_folders: Provides virtual directories for files
directus_migrations: What version of the database you're using
directus_notifications: Notifications sent to users
directus_operations: Operations that run in Flows
directus_panels: Individual panels within Insights dashboards
directus_permissions: Access permissions for each role
directus_notifications: Notifications sent to users
directus_presets: Presets for collection defaults and bookmarks
directus_relations: Relationship configuration and metadata
directus_revisions: Data snapshots for all activity
@@ -1327,6 +1391,7 @@ reset: Reset
reset_password: Reset Password
revisions: Revisions
no_revisions: No Revisions Yet
no_logs: No Logs Yet
revert: Revert
save: Save
schema: Schema
@@ -1370,6 +1435,7 @@ interfaces:
no_rules: No configured rules
change_value: Click to change value
placeholder: Drag rules here
add_key_placeholder: Add a key and press enter...
fields:
name: Fields
group-accordion:
@@ -1910,3 +1976,102 @@ panels:
bottom: Bottom
monochrome: Monochrome
monochrome_color: Monochrome Color
triggers:
common:
response_body: Response Body
response_body_all: All Data
response_body_last: Data of Last Operation
event:
name: Event Hook
description: Triggers on platform or data events
action: 'Action (Non-Blocking)'
filter: 'Filter (Blocking)'
webhook:
name: Webhook
description: Triggers on an incoming HTTP request
method: Method
async: Asynchronous
operation:
name: Another Flow
description: Triggered by another Flow, for chaining
preview: Triggered by an operation in a different Flow
schedule:
name: Schedule (CRON)
description: Triggers at regular points in time
cron: Interval
manual:
name: Manual
description: Triggers on manual run within selected collection(s)
collection_and_item: Collection & Item Pages
collection_only: Collection Page Only
item_only: Item Page Only
a_users_uuid: A User's Primary Key UUID...
a_flow_uuid: A Flow's Primary Key UUID...
any_string_or_json: Any string or JSON...
item_payload_placeholder: This is the JSON used to update the item's field values...
operation_variables_note: You can access flow data with variables such as **{'{'}{'{'}$last{'}'}{'}'}**, **{'{'}{'{'}$trigger{'}'}{'}'}** and **{'{'}{'{'}$accountability{'}'}{'}'}**, or reference previous operations using **{'{'}{'{'}operation_key{'}'}{'}'}**.'
operations:
condition:
name: Condition
description: Route a flow based on If / Else logic
item-create:
name: Create Data
description: Create items in the database
payload: Payload
emit_events: Emit Events
item-delete:
name: Delete Data
description: Delete items in the database
key: IDs
query: Query
item-read:
name: Read Data
description: Read items from the database
key: IDs
query: Query
item-update:
name: Update Data
description: Update items in the database
key: IDs
payload: Payload
query: Query
log:
name: Log to Console
description: Output something to the console
message: Message
message_placeholder: Enter a message to show in the console...
mail:
name: Send Email
description: Send an email to one or more people
to: To
to_placeholder: Add e-mail addresses and press enter...
body: Body
notification:
name: Send Notification
description: Send an in-app notification to a user
recipient: User
message: Message
request:
name: Webhook / Request URL
description: "Make a request to a URL"
url: URL
url_placeholder: https://example.com/example
method: Method
data: Data
headers: Headers
header: Header
header_placeholder: Authorization...
value_placeholder: Bearer eyJhbGciOi...
sleep:
name: Sleep
description: Wait a given number of milliseconds
milliseconds: Milliseconds
transform:
name: Transform Payload
description: Alter the Flow's JSON payload
trigger:
name: Trigger Flow
description: Pass this data to another flow
flows: Flows
flow: Flow
data: Data

View File

@@ -16,6 +16,7 @@ import { loadModules } from './modules/register';
import { router } from './router';
import './styles/main.scss';
import { registerViews } from './views/register';
import { registerOperations } from './operations/register';
init();
@@ -48,6 +49,7 @@ async function init() {
registerPanels(app),
registerDisplays(app),
registerLayouts(app),
registerOperations(app),
loadModules(),
]);

View File

@@ -252,6 +252,7 @@
@download="download"
@refresh="refresh"
/>
<flow-sidebar-detail location="collection" :collection="collection" :selection="selection" @refresh="refresh" />
</template>
<v-dialog :model-value="deleteError !== null">
@@ -281,6 +282,7 @@ import LayoutSidebarDetail from '@/views/private/components/layout-sidebar-detai
import ArchiveSidebarDetail from '@/views/private/components/archive-sidebar-detail';
import RefreshSidebarDetail from '@/views/private/components/refresh-sidebar-detail';
import ExportSidebarDetail from '@/views/private/components/export-sidebar-detail.vue';
import FlowSidebarDetail from '@/views/private/components/flow-sidebar-detail.vue';
import SearchInput from '@/views/private/components/search-input';
import BookmarkAdd from '@/views/private/components/bookmark-add';
import { useRouter } from 'vue-router';
@@ -307,6 +309,7 @@ export default defineComponent({
ArchiveSidebarDetail,
RefreshSidebarDetail,
ExportSidebarDetail,
FlowSidebarDetail,
},
props: {
collection: {

View File

@@ -197,6 +197,12 @@
:primary-key="internalPrimaryKey"
:allowed="shareAllowed"
/>
<flow-sidebar-detail
v-if="isNew === false && internalPrimaryKey"
location="item"
:collection="collection"
:primary-key="internalPrimaryKey"
/>
</template>
</private-view>
</template>
@@ -211,6 +217,7 @@ import { useCollection } from '@directus/shared/composables';
import RevisionsDrawerDetail from '@/views/private/components/revisions-drawer-detail';
import CommentsSidebarDetail from '@/views/private/components/comments-sidebar-detail';
import SharesSidebarDetail from '@/views/private/components/shares-sidebar-detail';
import FlowSidebarDetail from '@/views/private/components/flow-sidebar-detail.vue';
import useItem from '@/composables/use-item';
import SaveOptions from '@/views/private/components/save-options';
import useShortcut from '@/composables/use-shortcut';
@@ -229,6 +236,7 @@ export default defineComponent({
RevisionsDrawerDetail,
CommentsSidebarDetail,
SharesSidebarDetail,
FlowSidebarDetail,
SaveOptions,
},
props: {

View File

@@ -10,8 +10,9 @@
<v-card-text>
<div class="fields">
<v-input v-model="values.name" autofocus :placeholder="t('dashboard_name')" />
<v-input v-model="values.name" class="full" autofocus :placeholder="t('dashboard_name')" />
<interface-select-icon :value="values.icon" @input="values.icon = $event" />
<interface-select-color width="half" :value="values.color" @input="values.color = $event" />
<v-input v-model="values.note" class="full" :placeholder="t('note')" />
</div>
</v-card-text>
@@ -59,6 +60,7 @@ export default defineComponent({
const values = reactive({
name: props.dashboard?.name ?? null,
icon: props.dashboard?.icon ?? 'dashboard',
color: props.dashboard?.color ?? null,
note: props.dashboard?.note ?? null,
});
@@ -68,6 +70,7 @@ export default defineComponent({
if (isEqual(newValue, oldValue) === false) {
values.name = props.dashboard?.name ?? null;
values.icon = props.dashboard?.icon ?? 'dashboard';
values.color = props.dashboard?.color ?? null;
values.note = props.dashboard?.note ?? null;
}
}
@@ -94,7 +97,7 @@ export default defineComponent({
router.push(`/insights/${response.data.data.id}`);
}
emit('update:modelValue', false);
} catch (err) {
} catch (err: any) {
unexpectedError(err);
} finally {
saving.value = false;

View File

@@ -5,7 +5,7 @@
</v-button>
<v-list-item v-for="navItem in navItems" v-else :key="navItem.to" :to="navItem.to">
<v-list-item-icon><v-icon :name="navItem.icon" /></v-list-item-icon>
<v-list-item-icon><v-icon :name="navItem.icon" :color="navItem.color" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="navItem.name" />
</v-list-item-content>
@@ -31,6 +31,7 @@ export default defineComponent({
const navItems = computed(() =>
insightsStore.dashboards.map((dashboard: Dashboard) => ({
icon: dashboard.icon,
color: dashboard.color,
name: dashboard.name,
to: `/insights/${dashboard.id}`,
}))

View File

@@ -1,514 +0,0 @@
<template>
<div
class="panel"
:style="positionStyling"
:class="{
editing: editMode,
dragging,
'br-tl': dragging || panel.borderRadius[0],
'br-tr': dragging || panel.borderRadius[1],
'br-br': dragging || panel.borderRadius[2],
'br-bl': dragging || panel.borderRadius[3],
}"
data-move
@pointerdown="onPointerDown('move', $event)"
>
<div v-if="panel.show_header" class="header">
<v-icon class="icon" :style="iconColor" :name="headerIcon" />
<v-text-overflow class="name selectable" :text="panel.name || ''" />
<div class="spacer" />
<v-icon v-if="panel.note" v-tooltip="panel.note" class="note" name="info" />
</div>
<div v-if="editMode" class="edit-actions" @pointerdown.stop>
<v-icon
v-tooltip="t('edit')"
class="edit-icon"
name="edit"
clickable
@click.stop="$router.push(`/insights/${panel.dashboard}/${panel.id}`)"
/>
<v-menu placement="bottom-end" show-arrow>
<template #activator="{ toggle }">
<v-icon class="more-icon" name="more_vert" clickable @click="toggle" />
</template>
<v-list>
<v-list-item clickable :disabled="panel.id.startsWith('_')" @click="$emit('move')">
<v-list-item-icon>
<v-icon class="move-icon" name="input" />
</v-list-item-icon>
<v-list-item-content>
{{ t('copy_to') }}
</v-list-item-content>
</v-list-item>
<v-list-item clickable @click="$emit('duplicate')">
<v-list-item-icon>
<v-icon name="control_point_duplicate" />
</v-list-item-icon>
<v-list-item-content>{{ t('duplicate') }}</v-list-item-content>
</v-list-item>
<v-list-item class="delete-action" clickable @click="$emit('delete')">
<v-list-item-icon>
<v-icon name="delete" />
</v-list-item-icon>
<v-list-item-content>{{ t('delete_panel') }}</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</div>
<div class="resize-details">
({{ positioning.x - 1 }}:{{ positioning.y - 1 }}) {{ positioning.width }}×{{ positioning.height }}
</div>
<div v-if="editMode" class="resize-handlers">
<div class="top" @pointerdown.stop="onPointerDown('resize-top', $event)" />
<div class="right" @pointerdown.stop="onPointerDown('resize-right', $event)" />
<div class="bottom" @pointerdown.stop="onPointerDown('resize-bottom', $event)" />
<div class="left" @pointerdown.stop="onPointerDown('resize-left', $event)" />
<div class="top-left" @pointerdown.stop="onPointerDown('resize-top-left', $event)" />
<div class="top-right" @pointerdown.stop="onPointerDown('resize-top-right', $event)" />
<div class="bottom-right" @pointerdown.stop="onPointerDown('resize-bottom-right', $event)" />
<div class="bottom-left" @pointerdown.stop="onPointerDown('resize-bottom-left', $event)" />
</div>
<div class="panel-content" :class="{ 'has-header': panel.show_header }">
<component
:is="`panel-${panel.type}`"
v-bind="panel.options"
:id="panel.id"
:show-header="panel.show_header"
:height="panel.height"
:width="panel.width"
:dashboard="panel.dashboard"
:now="now"
/>
</div>
</div>
</template>
<script lang="ts">
import { getPanels } from '@/panels';
import { Panel } from '@directus/shared/types';
import { defineComponent, PropType, computed, ref, reactive } from 'vue';
import { throttle, omit } from 'lodash';
import { useI18n } from 'vue-i18n';
export default defineComponent({
name: 'Panel',
props: {
panel: {
type: Object as PropType<Panel>,
required: true,
},
editMode: {
type: Boolean,
default: false,
},
now: {
type: Date,
required: true,
},
},
emits: ['update', 'move', 'duplicate', 'delete'],
setup(props, { emit }) {
const { t } = useI18n();
const { panels } = getPanels();
const panelTypeInfo = computed(() => {
return panels.value.find((panelConfig) => {
return panelConfig.id === props.panel.type;
});
});
const headerIcon = computed(() => {
return props.panel.icon ? props.panel.icon : panelTypeInfo.value.icon;
});
/**
* When drag-n-dropping for positioning/resizing, we're
*/
const editedPosition = reactive<Partial<Panel>>({
position_x: undefined,
position_y: undefined,
width: undefined,
height: undefined,
});
const { onPointerDown, onPointerUp, onPointerMove, dragging } = useDragDrop();
const positioning = computed(() => {
if (dragging.value) {
return {
x: editedPosition.position_x ?? props.panel.position_x,
y: editedPosition.position_y ?? props.panel.position_y,
width: editedPosition.width ?? props.panel.width,
height: editedPosition.height ?? props.panel.height,
};
}
return {
x: props.panel.position_x,
y: props.panel.position_y,
width: props.panel.width,
height: props.panel.height,
};
});
const positionStyling = computed(() => {
if (dragging.value) {
return {
'--pos-x': editedPosition.position_x ?? props.panel.position_x,
'--pos-y': editedPosition.position_y ?? props.panel.position_y,
'--width': editedPosition.width ?? props.panel.width,
'--height': editedPosition.height ?? props.panel.height,
};
}
return {
'--pos-x': props.panel.position_x,
'--pos-y': props.panel.position_y,
'--width': props.panel.width,
'--height': props.panel.height,
};
});
const iconColor = computed(() => ({
'--v-icon-color': props.panel.color || 'var(--primary)',
}));
return {
headerIcon,
positioning,
positionStyling,
iconColor,
onPointerDown,
onPointerUp,
onPointerMove,
dragging,
editedPosition,
t,
omit,
};
function useDragDrop() {
const dragging = ref(false);
let pointerStartPosX = 0;
let pointerStartPosY = 0;
let panelStartPosX = 0;
let panelStartPosY = 0;
let panelStartWidth = 0;
let panelStartHeight = 0;
type Operation =
| 'move'
| 'resize-top'
| 'resize-right'
| 'resize-bottom'
| 'resize-left'
| 'resize-top-left'
| 'resize-top-right'
| 'resize-bottom-right'
| 'resize-bottom-left';
let operation: Operation = 'move';
const onPointerMove = throttle((event: PointerEvent) => {
if (props.editMode === false) return;
if (dragging.value === false) return;
const pointerDeltaX = event.pageX - pointerStartPosX;
const pointerDeltaY = event.pageY - pointerStartPosY;
const gridDeltaX = Math.round(pointerDeltaX / 20);
const gridDeltaY = Math.round(pointerDeltaY / 20);
if (operation === 'move') {
editedPosition.position_x = panelStartPosX + gridDeltaX;
editedPosition.position_y = panelStartPosY + gridDeltaY;
if (editedPosition.position_x < 1) editedPosition.position_x = 1;
if (editedPosition.position_y < 1) editedPosition.position_y = 1;
} else {
if (operation.includes('top')) {
editedPosition.height = panelStartHeight - gridDeltaY;
editedPosition.position_y = panelStartPosY + gridDeltaY;
}
if (operation.includes('right')) {
editedPosition.width = panelStartWidth + gridDeltaX;
}
if (operation.includes('bottom')) {
editedPosition.height = panelStartHeight + gridDeltaY;
}
if (operation.includes('left')) {
editedPosition.width = panelStartWidth - gridDeltaX;
editedPosition.position_x = panelStartPosX + gridDeltaX;
}
const minWidth = panelTypeInfo.value?.minWidth || 6;
const minHeight = panelTypeInfo.value?.minHeight || 6;
if (editedPosition.position_x && editedPosition.position_x < 1) editedPosition.position_x = 1;
if (editedPosition.position_y && editedPosition.position_y < 1) editedPosition.position_y = 1;
if (editedPosition.width && editedPosition.width < minWidth) editedPosition.width = minWidth;
if (editedPosition.height && editedPosition.height < minHeight) editedPosition.height = minHeight;
}
}, 20);
return { dragging, onPointerDown, onPointerUp, onPointerMove };
function onPointerDown(op: Operation, event: PointerEvent) {
if (props.editMode === false) return;
operation = op;
dragging.value = true;
pointerStartPosX = event.pageX;
pointerStartPosY = event.pageY;
panelStartPosX = props.panel.position_x;
panelStartPosY = props.panel.position_y;
panelStartWidth = props.panel.width;
panelStartHeight = props.panel.height;
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointermove', onPointerMove);
}
function onPointerUp() {
dragging.value = false;
if (props.editMode === false) return;
emit('update', editedPosition);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointermove', onPointerMove);
editedPosition.position_x = undefined;
editedPosition.position_y = undefined;
editedPosition.width = undefined;
editedPosition.height = undefined;
}
}
},
});
</script>
<style scoped>
.panel {
--pos-x: 1;
--pos-y: 1;
--width: 6;
--height: 6;
position: relative;
display: block;
grid-row: var(--pos-y) / span var(--height);
grid-column: var(--pos-x) / span var(--width);
background-color: var(--background-page);
border: 1px solid var(--border-subdued);
box-shadow: 0 0 0 1px var(--border-subdued);
}
.panel:hover {
z-index: 3;
}
.panel.editing {
border-color: var(--border-normal);
box-shadow: 0 0 0 1px var(--border-normal);
cursor: move;
}
.panel.editing:hover {
border-color: var(--border-normal-alt);
box-shadow: 0 0 0 1px var(--border-normal-alt);
}
.panel.editing.dragging {
z-index: 3 !important;
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
}
.resize-details {
position: absolute;
top: 0;
right: 0;
z-index: 2;
padding: 17px 14px;
color: var(--foreground-subdued);
font-weight: 500;
font-size: 15px;
font-family: var(--family-monospace);
font-style: normal;
line-height: 1;
text-align: right;
background-color: var(--background-page);
border-top-right-radius: var(--border-radius-outline);
opacity: 0;
transition: opacity var(--fast) var(--transition), color var(--fast) var(--transition);
pointer-events: none;
}
.panel.editing.dragging .resize-details {
opacity: 1;
}
.panel-content {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.panel-content.has-header {
height: calc(100% - 48px);
}
.panel.editing .panel-content {
pointer-events: none;
}
.header {
display: flex;
align-items: center;
height: 48px;
padding: 12px;
}
.icon {
--v-icon-color: var(--foreground-subdued);
margin-right: 4px;
}
.name {
color: var(--foreground-normal-alt);
font-weight: 600;
font-size: 16px;
font-family: var(--family-sans-serif);
font-style: normal;
}
.spacer {
flex-grow: 1;
}
.more-icon,
.edit-icon,
.note {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
}
.delete-action {
--v-list-item-color: var(--danger);
--v-list-item-color-hover: var(--danger);
--v-list-item-icon-color: var(--danger);
}
.edit-actions {
position: absolute;
top: 0;
right: 0;
z-index: 2;
display: flex;
gap: 4px;
align-items: center;
padding: 12px 12px 8px;
background-color: var(--background-page);
border-top-right-radius: var(--border-radius-outline);
}
.resize-handlers div {
position: absolute;
z-index: 2;
}
.resize-handlers .top {
top: -3px;
width: 100%;
height: 10px;
cursor: ns-resize;
}
.resize-handlers .right {
top: 0;
right: -3px;
width: 10px;
height: 100%;
cursor: ew-resize;
}
.resize-handlers .bottom {
bottom: -3px;
width: 100%;
height: 10px;
cursor: ns-resize;
}
.resize-handlers .left {
top: 0;
left: -3px;
width: 10px;
height: 100%;
cursor: ew-resize;
}
.resize-handlers .top-left {
top: -3px;
left: -3px;
width: 14px;
height: 14px;
cursor: nwse-resize;
}
.resize-handlers .top-right {
top: -3px;
right: -3px;
width: 14px;
height: 14px;
cursor: nesw-resize;
}
.resize-handlers .bottom-right {
right: -3px;
bottom: -3px;
width: 14px;
height: 14px;
cursor: nwse-resize;
}
.resize-handlers .bottom-left {
bottom: -3px;
left: -3px;
width: 14px;
height: 14px;
cursor: nesw-resize;
}
.br-tl {
border-top-left-radius: var(--border-radius-outline);
}
.br-tr {
border-top-right-radius: var(--border-radius-outline);
}
.br-br {
border-bottom-right-radius: var(--border-radius-outline);
}
.br-bl {
border-bottom-left-radius: var(--border-radius-outline);
}
</style>

View File

@@ -1,174 +0,0 @@
<template>
<div
class="workspace-padding-box"
:class="{ editing: editMode }"
:style="{ width: workspaceBoxSize.width + 'px', height: workspaceBoxSize.height + 'px' }"
>
<div
class="workspace"
:style="{
transform: `scale(${zoomScale})`,
width: workspaceSize.width + 'px',
height: workspaceSize.height + 'px',
}"
>
<insights-panel
v-for="panel in panels"
:key="panel.id"
:panel="panel"
:edit-mode="editMode"
:now="now"
@update="$emit('update', { edits: $event, id: panel.id })"
@move="$emit('move', panel.id)"
@delete="$emit('delete', panel.id)"
@duplicate="$emit('duplicate', panel)"
/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, PropType, computed, inject, ref } from 'vue';
import { Panel } from '@directus/shared/types';
import InsightsPanel from '../components/panel.vue';
import { useElementSize } from '@/composables/use-element-size';
export default defineComponent({
name: 'InsightsWorkspace',
components: { InsightsPanel },
props: {
panels: {
type: Array as PropType<Panel[]>,
required: true,
},
editMode: {
type: Boolean,
default: false,
},
zoomToFit: {
type: Boolean,
default: false,
},
now: {
type: Date,
required: true,
},
},
emits: ['update', 'move', 'delete', 'duplicate'],
setup(props) {
const mainElement = inject('main-element', ref<Element>());
const mainElementSize = useElementSize(mainElement);
const paddingSize = computed(() => Number(getVar('--content-padding')?.slice(0, -2) || 0));
const workspaceSize = computed(() => {
const furthestPanelX = props.panels.reduce(
(aggr, panel) => {
if (panel.position_x! > aggr.position_x!) {
aggr.position_x = panel.position_x!;
aggr.width = panel.width!;
}
return aggr;
},
{ position_x: 0, width: 0 }
);
const furthestPanelY = props.panels.reduce(
(aggr, panel) => {
if (panel.position_y! > aggr.position_y!) {
aggr.position_y = panel.position_y!;
aggr.height = panel.height!;
}
return aggr;
},
{ position_y: 0, height: 0 }
);
if (props.editMode === true) {
return {
width: (furthestPanelX.position_x! + furthestPanelX.width! + 25) * 20,
height: (furthestPanelY.position_y! + furthestPanelY.height! + 25) * 20,
};
}
return {
width: (furthestPanelX.position_x! + furthestPanelX.width! - 1) * 20,
height: (furthestPanelY.position_y! + furthestPanelY.height! - 1) * 20,
};
});
const zoomScale = computed(() => {
if (props.zoomToFit === false) return 1;
const { width, height } = mainElementSize;
const scaleWidth: number = (width.value - paddingSize.value * 2) / workspaceSize.value.width;
const scaleHeight: number = (height.value - 114 - paddingSize.value * 2) / workspaceSize.value.height;
return Math.min(scaleWidth, scaleHeight);
});
const workspaceBoxSize = computed(() => {
return {
width: workspaceSize.value.width * zoomScale.value + paddingSize.value * 2,
height: workspaceSize.value.height * zoomScale.value + paddingSize.value * 2,
};
});
return { workspaceSize, workspaceBoxSize, mainElement, zoomScale };
function getVar(cssVar: string) {
if (!mainElement.value) return;
return getComputedStyle(mainElement.value).getPropertyValue(cssVar).trim();
}
},
});
</script>
<style scoped>
.workspace-padding-box {
position: relative;
}
.workspace {
position: absolute;
left: var(--content-padding);
display: grid;
grid-template-rows: repeat(auto-fill, 20px);
grid-template-columns: repeat(auto-fill, 20px);
min-width: calc(100%);
min-height: calc(100% - 120px);
transform: scale(1);
transform-origin: top left;
/* This causes the header bar to "unhinge" on the left edge :C */
/* transition: transform var(--slow) var(--transition); */
}
.workspace > * {
z-index: 2;
}
.workspace::before {
position: absolute;
top: -4px;
left: -4px;
display: block;
width: calc(100% + 8px);
height: calc(100% + 8px);
background-image: radial-gradient(var(--border-normal) 10%, transparent 10%);
background-position: -6px -6px;
background-size: 20px 20px;
opacity: 0;
transition: opacity var(--slow) var(--transition);
content: '';
pointer-events: none;
}
.workspace-padding-box.editing .workspace::before {
opacity: 1;
}
</style>

View File

@@ -82,16 +82,29 @@
<insights-navigation />
</template>
<insights-workspace
<v-workspace
:edit-mode="editMode"
:panels="panels"
:zoom-to-fit="zoomToFit"
:now="now"
@edit="editPanel"
@update="stagePanelEdits"
@move="movePanelID = $event"
@delete="deletePanel"
@duplicate="duplicatePanel"
/>
>
<template #default="{ panel }">
<component
:is="`panel-${panel.type}`"
v-bind="panel.options"
:id="panel.id"
:show-header="panel.show_header"
:height="panel.height"
:width="panel.width"
:now="now"
/>
</template>
</v-workspace>
<router-view
name="detail"
@@ -164,14 +177,13 @@ import { unexpectedError } from '@/utils/unexpected-error';
import api from '@/api';
import { useI18n } from 'vue-i18n';
import { pointOnLine } from '@/utils/point-on-line';
import InsightsWorkspace from '../components/workspace.vue';
import { md } from '@/utils/md';
import useShortcut from '@/composables/use-shortcut';
import { getPanels } from '@/panels';
import useEditsGuard from '@/composables/use-edits-guard';
export default defineComponent({
name: 'InsightsDashboard',
components: { InsightsNotFound, InsightsNavigation, InsightsWorkspace },
components: { InsightsNotFound, InsightsNavigation },
props: {
primaryKey: {
type: String,
@@ -184,6 +196,7 @@ export default defineComponent({
},
setup(props) {
const { t } = useI18n();
const { panels: panelsInfo } = getPanels();
const insightsStore = useInsightsStore();
const appStore = useAppStore();
@@ -287,11 +300,22 @@ export default defineComponent({
return {
...panel,
x: panel.position_x,
y: panel.position_y,
borderRadius: [!topLeftIntersects, !topRightIntersects, !bottomRightIntersects, !bottomLeftIntersects],
};
});
return withBorderRadii;
const withIcons = withBorderRadii.map((panel) => {
if (panel.icon) return panel;
return {
...panel,
icon: panelsInfo.value.find((panelConfig) => panelConfig.id === panel.type)?.icon,
};
});
return withIcons;
});
const hasEdits = computed(() => stagedPanels.value.length > 0 || panelsToBeDeleted.value.length > 0);
@@ -315,13 +339,13 @@ export default defineComponent({
deletePanel,
attemptCancelChanges,
duplicatePanel,
editPanel,
movePanelLoading,
t,
toggleFullScreen,
zoomToFit,
fullScreen,
toggleZoomToFit,
md,
movePanelChoices,
movePanelTo,
confirmLeave,
@@ -441,6 +465,10 @@ export default defineComponent({
stagePanelEdits({ edits: newPanel, id: '+' });
}
function editPanel(panel: Panel) {
router.push(`/insights/${panel.dashboard}/${panel.id}`);
}
function toggleFullScreen() {
fullScreen.value = !fullScreen.value;
}

View File

@@ -41,7 +41,7 @@
@click:row="navigateToDashboard"
>
<template #[`item.icon`]="{ item }">
<v-icon class="icon" :name="item.icon" />
<v-icon class="icon" :name="item.icon" :color="item.color" />
</template>
<template #item-append="{ item }">
@@ -216,12 +216,6 @@ export default defineComponent({
--v-list-item-icon-color: var(--danger);
}
.v-list-item.warning {
--v-list-item-color: var(--warning);
--v-list-item-color-hover: var(--warning);
--v-list-item-icon-color: var(--warning);
}
.header-icon {
--v-button-color-disabled: var(--foreground-normal);
}

View File

@@ -68,6 +68,11 @@ export default defineComponent({
name: t('settings_webhooks'),
to: `/settings/webhooks`,
},
{
icon: 'bolt',
name: t('settings_flows'),
to: `/settings/flows`,
},
];
const externalItems = computed(() => {

View File

@@ -17,6 +17,10 @@ import RolesPermissionsDetail from './routes/roles/permissions-detail/permission
import RolesPublicItem from './routes/roles/public-item.vue';
import WebhooksCollection from './routes/webhooks/collection.vue';
import WebhooksItem from './routes/webhooks/item.vue';
import FlowsOverview from './routes/flows/overview.vue';
import FlowDrawer from './routes/flows/flow-drawer.vue';
import FlowsDetail from './routes/flows/flow.vue';
import FlowOperationDetail from './routes/flows/components/operation-detail.vue';
import TranslationStringsCollection from './routes/translation-strings/collection.vue';
export default defineModule({
@@ -176,6 +180,31 @@ export default defineModule({
},
],
},
{
path: 'flows',
component: RouterPass,
children: [
{
name: 'settings-flows-collection',
path: '',
component: FlowsOverview,
},
{
name: 'settings-flows-item',
path: ':primaryKey',
component: FlowsDetail,
props: true,
children: [
{
name: 'settings-flows-operation',
path: ':operationId',
component: FlowOperationDetail,
props: true,
},
],
},
],
},
{
path: 'translation-strings',
component: RouterPass,

View File

@@ -8,6 +8,8 @@
v-model="optionsValues"
class="extension-options"
:fields="optionsFields"
:initial-values="disabled ? optionsValues : null"
:disabled="disabled"
primary-key="+"
/>
@@ -23,6 +25,7 @@
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
import { getOperation } from '@/operations';
import { getInterface } from '@/interfaces';
import { getDisplay } from '@/displays';
import { getPanel } from '@/panels';
@@ -33,7 +36,7 @@ import { storeToRefs } from 'pinia';
export default defineComponent({
props: {
type: {
type: String as PropType<'interface' | 'display' | 'panel'>,
type: String as PropType<'interface' | 'display' | 'panel' | 'operation'>,
required: true,
},
extension: {
@@ -52,6 +55,10 @@ export default defineComponent({
type: Object,
default: () => ({}),
},
disabled: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
setup(props, { emit }) {
@@ -69,6 +76,8 @@ export default defineComponent({
return getDisplay(props.extension);
case 'panel':
return getPanel(props.extension);
case 'operation':
return getOperation(props.extension);
default:
return null;
}

View File

@@ -0,0 +1,292 @@
<template>
<div class="arrow-container">
<svg :width="size.width" :height="size.height" class="arrows">
<transition-group name="fade">
<path
v-for="arrow in arrows"
:key="arrow.id"
:class="{ [arrow.type]: true, subdued: subdued || arrow.loner, hint: arrow.isHint }"
:d="arrow.d"
stroke-linecap="round"
/>
</transition-group>
</svg>
</div>
</template>
<script setup lang="ts">
import { Vector2 } from '@/utils/vector2';
import { computed } from 'vue';
import { ATTACHMENT_OFFSET, PANEL_HEIGHT, PANEL_WIDTH, REJECT_OFFSET, RESOLVE_OFFSET } from '../constants';
import { ArrowInfo, Target } from './operation.vue';
import { ParentInfo } from '../flow.vue';
const props = withDefaults(
defineProps<{
panels: Record<string, any>[];
arrowInfo?: ArrowInfo;
parentPanels: Record<string, ParentInfo>;
editMode?: boolean;
hoveredPanel?: string | null;
subdued?: boolean;
}>(),
{
arrowInfo: undefined,
editMode: false,
hoveredPanel: undefined,
subdued: false,
}
);
const startOffset = 2;
const endOffset = 13;
const size = computed(() => {
let width = 0,
height = 0;
for (const panel of props.panels) {
width = Math.max(width, (panel.x + PANEL_WIDTH) * 20);
height = Math.max(height, (panel.y + PANEL_HEIGHT) * 20);
}
if (props.arrowInfo) {
width = Math.max(width, props.arrowInfo.pos.x + 10);
height = Math.max(height, props.arrowInfo.pos.y + 10);
}
return { width: width + 100, height: height + 100 };
});
const arrows = computed(() => {
const arrows: { id: string; d: string; type: Target; loner: boolean; isHint?: boolean }[] = [];
for (const panel of props.panels) {
const resolveChild = props.panels.find((pan) => pan.id === panel.resolve);
const rejectChild = props.panels.find((pan) => pan.id === panel.reject);
const parent = props.parentPanels[panel.id];
const loner = (parent === undefined || parent.loner) && panel.id !== '$trigger';
if (props.arrowInfo?.id === panel.id && props.arrowInfo?.type === 'resolve') {
const { x, y } = getPoints(panel, RESOLVE_OFFSET);
arrows.push({
id: panel.id + '_resolve',
d: createLine(x, y, props.arrowInfo.pos.x, props.arrowInfo.pos.y),
type: 'resolve',
loner,
});
} else if (resolveChild) {
const { x, y, toX, toY } = getPoints(panel, RESOLVE_OFFSET, resolveChild);
arrows.push({
id: panel.id + '_resolve',
d: createLine(x, y, toX as number, toY as number),
type: 'resolve',
loner,
});
} else if (props.editMode && !props.arrowInfo && (panel.id === '$trigger' || props.hoveredPanel === panel.id)) {
const { x: resolveX, y: resolveY } = getPoints(panel, RESOLVE_OFFSET);
arrows.push({
id: panel.id + '_resolve',
d: createLine(resolveX, resolveY, resolveX + 3 * 20, resolveY),
type: 'resolve',
loner,
isHint: true,
});
}
if (props.arrowInfo?.id === panel.id && props.arrowInfo?.type === 'reject') {
const { x, y } = getPoints(panel, REJECT_OFFSET);
arrows.push({
id: panel.id + '_reject',
d: createLine(x, y, props.arrowInfo.pos.x, props.arrowInfo.pos.y),
type: 'reject',
loner,
});
} else if (rejectChild) {
const { x, y, toX, toY } = getPoints(panel, REJECT_OFFSET, rejectChild);
arrows.push({
id: panel.id + '_reject',
d: createLine(x, y, toX as number, toY as number),
type: 'reject',
loner,
});
} else if (props.editMode && !props.arrowInfo && panel.id !== '$trigger' && props.hoveredPanel === panel.id) {
const { x: rejectX, y: rejectY } = getPoints(panel, REJECT_OFFSET);
arrows.push({
id: panel.id + '_reject',
d: createLine(rejectX, rejectY, rejectX + 3 * 20, rejectY),
type: 'reject',
loner,
isHint: true,
});
}
}
if (props.arrowInfo) {
arrows.push();
}
return arrows;
function getPoints(panel: Record<string, any>, offset: Vector2, to?: Record<string, any>) {
const x = (panel.x - 1) * 20 + offset.x;
const y = (panel.y - 1) * 20 + offset.y;
if (to) {
const toX = (to.x - 1) * 20 + ATTACHMENT_OFFSET.x;
const toY = (to.y - 1) * 20 + ATTACHMENT_OFFSET.y;
return { x, y, toX, toY };
}
return { x, y };
}
function createLine(x: number, y: number, toX: number, toY: number) {
if (y === toY) return generatePath(Vector2.fromMany({ x: x + startOffset, y }, { x: toX - endOffset, y: toY }));
if (x + 3 * 20 < toX) {
const centerX = findBestPosition(new Vector2(x + 2 * 20, y), new Vector2(toX - 2 * 20, toY), 'x');
return generatePath(
Vector2.fromMany(
{ x: x + startOffset, y },
{ x: centerX, y },
{ x: centerX, y: toY },
{ x: toX - endOffset, y: toY }
)
);
}
const offsetBox = 40;
const centerY = findBestPosition(new Vector2(x + 2 * 20, y), new Vector2(toX - 2 * 20, toY), 'y');
return generatePath(
Vector2.fromMany(
{ x: x + startOffset, y },
{ x: x + offsetBox, y },
{ x: x + offsetBox, y: centerY },
{ x: toX - offsetBox, y: centerY },
{ x: toX - offsetBox, y: toY },
{ x: toX - endOffset, y: toY }
)
);
}
function generatePath(points: Vector2[]) {
// Add 8px to the x axis so that the arrow not overlaps with the icon
let path = `M ${points[0].add(new Vector2(8, 0))}`;
if (points.length >= 3) {
for (let i = 1; i < points.length - 1; i++) {
path += generateCorner(points[i - 1], points[i], points[i + 1]);
}
}
const arrowSize = 8;
const arrow = `M ${points.at(-1)} L ${points
.at(-1)
?.clone()
.add(new Vector2(-arrowSize, -arrowSize))} M ${points.at(-1)} L ${points
.at(-1)
?.clone()
.add(new Vector2(-arrowSize, arrowSize))}`;
return path + ` L ${points.at(-1)} ${arrow}`;
}
function generateCorner(start: Vector2, middle: Vector2, end: Vector2) {
return ` L ${start.moveNextTo(middle)} Q ${middle} ${end.moveNextTo(middle)}`;
}
function findBestPosition(from: Vector2, to: Vector2, axis: 'x' | 'y') {
const possiblePlaces: boolean[] = [];
const otherAxis = axis === 'x' ? 'y' : 'x';
const { min, max } = minMaxPoint(from, to);
const outerPoints = range(min[otherAxis], max[otherAxis], (axis === 'x' ? PANEL_WIDTH : PANEL_HEIGHT) * 20);
const innerPoints = range(min[axis], max[axis], 20);
for (let outer of outerPoints) {
for (let inner = 0; inner < innerPoints.length; inner++) {
const point = axis === 'x' ? new Vector2(innerPoints[inner], outer) : new Vector2(outer, innerPoints[inner]);
possiblePlaces[inner] = (possiblePlaces[inner] ?? true) && !isPointInPanel(point);
}
}
let pointer = Math.floor(possiblePlaces.length / 2);
for (let i = 0; i < possiblePlaces.length; i++) {
pointer += i * (i % 2 == 0 ? -1 : 1);
if (possiblePlaces[pointer]) return min[axis] + pointer * 20;
}
return from[axis] + Math.floor((to[axis] - from[axis]) / 2 / 20) * 20;
}
function range(min: number, max: number, step: number) {
const points: number[] = [];
for (let i = min; i < max; i += step) {
points.push(i);
}
points.push(max);
return points;
}
function isPointInPanel(point: Vector2) {
return (
props.panels.findIndex(
(panel) =>
point.x >= (panel.x - 2) * 20 &&
point.x <= (panel.x - 1 + PANEL_WIDTH) * 20 &&
point.y >= (panel.y - 1) * 20 &&
point.y <= (panel.y - 1 + PANEL_HEIGHT) * 20
) !== -1
);
}
function minMaxPoint(point1: Vector2, point2: Vector2) {
return {
min: new Vector2(Math.min(point1.x, point2.x), Math.min(point1.y, point2.y)),
max: new Vector2(Math.max(point1.x, point2.x), Math.max(point1.y, point2.y)),
};
}
});
</script>
<style scoped lang="scss">
.arrow-container {
position: relative;
.arrows {
position: absolute;
top: 0;
z-index: 1;
left: var(--content-padding);
pointer-events: none;
path {
fill: transparent;
stroke: var(--primary);
stroke-width: 2px;
transition: stroke var(--fast) var(--transition);
transform: translateX(0);
&.reject {
stroke: var(--secondary);
}
&.subdued {
stroke: var(--foreground-subdued);
}
&.fade-enter-active,
&.fade-leave-active {
transition: var(--fast) var(--transition);
transition-property: opacity transform;
}
&.fade-enter-from,
&.fade-leave-to {
position: absolute;
opacity: 0;
transform: translateX(-4px);
}
}
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<v-dialog :model-value="modelValue" persistent @update:modelValue="$emit('update:modelValue', $event)" @esc="cancel">
<template #activator="slotBinding">
<slot name="activator" v-bind="slotBinding" />
</template>
<v-card>
<v-card-title>{{ t('edit_flow') }}</v-card-title>
<v-card-text>
<div class="fields">
<v-input v-model="values.name" class="full" autofocus :placeholder="t('flow_name')" />
<interface-select-icon :value="values.icon" @input="values.icon = $event" />
<interface-select-color width="half" :value="values.color" @input="values.color = $event" />
<v-input v-model="values.description" class="full" />
</div>
</v-card-text>
<v-card-actions>
<v-button secondary @click="cancel">
{{ t('cancel') }}
</v-button>
<v-button :disabled="!values.name" :loading="saving" @click="save">
{{ t('save') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import api from '@/api';
import { unexpectedError } from '@/utils/unexpected-error';
import { ref, reactive, watch } from 'vue';
import { FlowRaw } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
import { isEqual } from 'lodash';
import { useFlowsStore } from '@/stores';
const props = withDefaults(
defineProps<{
modelValue?: boolean;
flow?: FlowRaw;
}>(),
{
modelValue: false,
flow: undefined,
}
);
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const flowsStore = useFlowsStore();
const values = reactive({
name: props.flow?.name ?? null,
icon: props.flow?.icon ?? 'bolt',
color: props.flow?.color ?? null,
description: props.flow?.description ?? null,
});
watch(
() => props.modelValue,
(newValue, oldValue) => {
if (isEqual(newValue, oldValue) === false) {
values.name = props.flow?.name ?? null;
values.icon = props.flow?.icon ?? 'bolt';
values.color = props.flow?.color ?? null;
values.description = props.flow?.description ?? null;
}
}
);
const saving = ref(false);
function cancel() {
emit('update:modelValue', false);
}
async function save() {
saving.value = true;
try {
await api.patch(`/flows/${props.flow.id}`, values, { params: { fields: ['id'] } });
await flowsStore.hydrate();
emit('update:modelValue', false);
} catch (err: any) {
unexpectedError(err);
} finally {
saving.value = false;
}
}
</script>
<style scoped>
.fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.full {
grid-column: 1 / span 2;
}
</style>

View File

@@ -0,0 +1,291 @@
<template>
<sidebar-detail :title="t('logs')" icon="fact_check" :badge="revisionsCount">
<div v-if="revisionsCount === 0" class="empty">{{ t('no_logs') }}</div>
<v-detail
v-for="group in revisionsByDate"
:key="group.dateFormatted"
:label="group.dateFormatted"
class="revisions-date-group"
start-open
>
<div class="scroll-container">
<div v-for="revision in group.revisions" :key="revision.id" class="log">
<button @click="previewing = revision">
<v-icon name="play_arrow" color="var(--primary)" small />
{{ revision.timeRelative }}
</button>
</div>
</div>
</v-detail>
</sidebar-detail>
<v-drawer
:model-value="!!previewing"
:title="previewing ? previewing.timestampFormatted : t('logs')"
icon="fact_check"
@cancel="previewing = null"
@esc="previewing = null"
>
<div class="content">
<div class="steps">
<div class="step">
<div class="header">
<span class="dot" />
<span class="type-label">
{{ t('trigger') }}
<span class="subdued">&nbsp;{{ usedTrigger?.name }}</span>
</span>
</div>
<div class="inset">
<v-detail v-if="triggerData.options" :label="t('options')">
<pre class="json selectable">{{ triggerData.options }}</pre>
</v-detail>
<v-detail v-if="triggerData.trigger" :label="t('payload')">
<pre class="json selectable">{{ triggerData.trigger }}</pre>
</v-detail>
<v-detail v-if="triggerData.accountability" :label="t('accountability')">
<pre class="json selectable">{{ triggerData.accountability }}</pre>
</v-detail>
</div>
</div>
<div v-for="step of steps" :key="step.id" class="step">
<div class="header">
<span class="dot" :class="step.status" />
<span v-tooltip="step.key" class="type-label">
{{ step.name }}
<span class="subdued">&nbsp;{{ step.operationType }}</span>
</span>
</div>
<div class="inset">
<v-detail v-if="step.options" :label="t('options')">
<pre class="json selectable">{{ step.options }}</pre>
</v-detail>
<v-detail v-if="step.data" :label="t('payload')">
<pre class="json selectable">{{ step.data }}</pre>
</v-detail>
</div>
</div>
</div>
</div>
</v-drawer>
</template>
<script lang="ts" setup>
import { useRevisions } from '@/composables/use-revisions';
import { getOperations } from '@/operations';
import { Action, FlowRaw } from '@directus/shared/types';
import { computed, ref, toRefs, unref } from 'vue';
import { useI18n } from 'vue-i18n';
import { getTriggers } from '../triggers';
const { t } = useI18n();
interface Props {
flow: FlowRaw;
}
const props = defineProps<Props>();
const { flow } = toRefs(props);
const { triggers } = getTriggers();
const { operations } = getOperations();
const usedTrigger = computed(() => {
return triggers.find((trigger) => trigger.id === unref(flow).trigger);
});
const { revisionsByDate, revisionsCount } = useRevisions(
ref('directus_flows'),
computed(() => unref(flow).id),
{
action: Action.RUN,
}
);
const previewing = ref();
const triggerData = computed(() => {
if (!unref(previewing)?.data) return { trigger: null, accountability: null, options: null };
const { data } = unref(previewing).data;
return {
trigger: data.$trigger,
accountability: data.$accountability,
options: props.flow.options,
};
});
const steps = computed(() => {
if (!unref(previewing)?.data?.steps) return [];
const { steps } = unref(previewing).data;
return steps.map(
({
operation,
status,
key,
options,
}: {
operation: string;
status: 'reject' | 'resolve' | 'unknown';
key: string;
options: Record<string, any>;
}) => {
const operationConfiguration = props.flow.operations.find((operationConfig) => operationConfig.id === operation);
const operationType = operations.value.find((operation) => operation.id === operationConfiguration?.type);
return {
id: operation,
name: operationConfiguration?.name ?? key,
data: unref(previewing).data?.data?.[key] ?? null,
options: options ?? null,
operationType: operationType?.name ?? operationConfiguration?.type ?? '--',
key,
status,
};
}
);
});
</script>
<style lang="scss" scoped>
.content {
padding: var(--content-padding);
}
.log {
position: relative;
display: block;
button {
position: relative;
z-index: 2;
display: block;
width: 100%;
text-align: left;
}
&::before {
position: absolute;
top: -4px;
left: -4px;
z-index: 1;
width: calc(100% + 8px);
height: calc(100% + 8px);
background-color: var(--background-normal-alt);
border-radius: var(--border-radius);
opacity: 0;
transition: opacity var(--fast) var(--transition);
content: '';
pointer-events: none;
}
&:hover {
cursor: pointer;
.header {
.dot {
border-color: var(--background-normal-alt);
}
}
&::before {
opacity: 1;
}
}
& + & {
margin-top: 8px;
}
}
.json {
background-color: var(--background-subdued);
font-family: var(--family-monospace);
border-radius: var(--border-radius);
padding: 20px;
margin-top: 20px;
white-space: pre-wrap;
}
.steps {
position: relative;
.step {
position: relative;
&::after {
content: '';
position: absolute;
width: var(--border-width);
left: -11px;
top: 0;
background-color: var(--border-subdued);
height: 100%;
}
&:first-child::after {
top: 8px;
height: calc(100% - 8px);
}
&:last-child::after {
height: 12px;
}
.inset {
padding-top: 12px;
padding-bottom: 32px;
.v-detail + .v-detail {
margin-top: 12px;
}
}
.subdued {
color: var(--foreground-subdued);
}
}
.mono {
font-family: var(--family-monospace);
color: var(--foreground-subdued);
}
.dot {
position: absolute;
top: 6px;
left: -16px;
z-index: 2;
width: 12px;
height: 12px;
background-color: var(--primary);
border: 2px solid var(--background-page);
border-radius: 8px;
&.resolve {
background-color: var(--primary);
}
&.reject {
background-color: var(--secondary);
}
}
}
.empty {
margin-left: 2px;
color: var(--foreground-subdued);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,229 @@
<template>
<v-drawer
:model-value="isOpen"
:title="t(operationId === '+' ? 'create_operation' : 'edit_operation')"
:subtitle="flow.name"
icon="offline_bolt"
persistent
@cancel="$emit('cancel')"
>
<template #actions>
<v-button v-tooltip.bottom="t('done')" icon rounded :disabled="saveDisabled" @click="saveOperation">
<v-icon name="check" />
</v-button>
</template>
<div class="content">
<div class="grid">
<div class="field half">
<div class="type-label">
{{ t('name') }}
</div>
<v-input v-model="operationName" autofocus :placeholder="generatedName">
<template #append>
<v-icon name="title" />
</template>
</v-input>
</div>
<div class="field half">
<div class="type-label">
{{ t('key') }}
</div>
<v-input v-model="operationKey" db-safe :placeholder="generatedKey">
<template #append>
<v-icon name="vpn_key" />
</template>
</v-input>
<small v-if="!isOperationKeyUnique" class="error selectable">{{ t('operation_key_unique_error') }}</small>
</div>
</div>
<v-divider />
<v-fancy-select v-model="operationType" class="select" :items="displayOperations" />
<v-notice v-if="operationType && !selectedOperation" class="not-found" type="danger">
{{ t('operation_not_found', { operation: operationType }) }}
<div class="spacer" />
<button @click="operationType = undefined">{{ t('reset_interface') }}</button>
</v-notice>
<extension-options
v-if="operationType && selectedOperation && operationOptions"
v-model="options"
:extension="operationType"
:options="operationOptions"
type="operation"
></extension-options>
<component
:is="`operation-options-${operationType}`"
v-else-if="operationType && selectedOperation"
:options="operation"
/>
</div>
</v-drawer>
</template>
<script setup lang="ts">
import { useDialogRoute } from '@/composables/use-dialog-route';
import ExtensionOptions from '@/modules/settings/routes/data-model/field-detail/shared/extension-options.vue';
import { getOperation, getOperations } from '@/operations';
import { translate } from '@/utils/translate-object-values';
import { FlowRaw } from '@directus/shared/types';
import slugify from '@sindresorhus/slugify';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { customAlphabet } from 'nanoid';
const generateSuffix = customAlphabet('abcdefghijklmnopqrstuvwxyz1234567890', 5);
const props = withDefaults(
defineProps<{
primaryKey: string;
operationId: string;
operation?: Record<string, any>;
existingOperationKeys?: string[];
flow: FlowRaw;
}>(),
{
operation: undefined,
existingOperationKeys: undefined,
}
);
const emit = defineEmits(['save', 'cancel']);
const isOpen = useDialogRoute();
const { t } = useI18n();
const options = ref<Record<string, any>>(props.operation?.options ?? {});
const operationType = ref<string | undefined>(props.operation?.type);
const operationKey = ref<string | null>(props.operation?.key ?? null);
const operationName = ref<string | null>(props.operation?.name ?? null);
const saving = ref(false);
const isOperationKeyUnique = computed(
() =>
saving.value ||
operationKey.value === null ||
!(props.operation?.key !== operationKey.value && props.existingOperationKeys?.includes(operationKey.value))
);
const saveDisabled = computed(() => {
return !operationType.value || !isOperationKeyUnique.value;
});
watch(operationType, () => {
options.value = {};
});
watch(
operationName,
(newName, oldName) => {
if (
newName === null ||
operationKey.value ===
slugify(oldName ?? '', {
separator: '_',
})
)
operationKey.value = slugify(newName ?? '', {
separator: '_',
});
},
{ immediate: true }
);
const selectedOperation = computed(() => getOperation(operationType.value));
const generatedName = computed(() => (selectedOperation.value ? selectedOperation.value?.name : t('operation_name')));
const generatedKey = computed(() =>
selectedOperation.value ? selectedOperation.value?.id + '_' + generateSuffix() : t('operation_key')
);
const { operations } = getOperations();
const displayOperations = computed(() => {
return operations.value.map((operation) => ({
value: operation.id,
icon: operation.icon,
text: operation.name,
description: operation.description,
}));
});
const operationOptions = computed(() => {
if (typeof selectedOperation.value?.options === 'function') {
return translate(selectedOperation.value.options(options.value));
} else if (typeof selectedOperation.value?.options === 'object') {
return selectedOperation.value.options;
}
return undefined;
});
function saveOperation() {
saving.value = true;
emit('save', {
flow: props.primaryKey,
name: operationName.value || generatedName.value,
key: operationKey.value || generatedKey.value,
type: operationType.value,
options: options.value,
});
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.content {
padding: var(--content-padding);
padding-top: 0;
padding-bottom: var(--content-padding-bottom);
.grid {
@include form-grid;
}
}
.v-divider {
margin: 52px 0;
}
.type-label {
margin-bottom: 8px;
}
.type-title,
.select {
margin-bottom: 32px;
}
.not-found {
.spacer {
flex-grow: 1;
}
button {
text-decoration: underline;
}
}
.v-notice {
margin-bottom: 36px;
}
.required {
--v-icon-color: var(--primary);
margin-top: -12px;
margin-left: -4px;
}
.error {
display: block;
margin-top: 4px;
color: var(--danger);
font-style: italic;
}
</style>

View File

@@ -0,0 +1,525 @@
<template>
<v-workspace-tile
v-bind="panel"
:name="panel.panel_name"
:icon="type === 'trigger' ? panel.icon : currentOperation?.icon"
class="block-container"
:class="[
type,
{
'edit-mode': editMode,
subdued: subdued || ((parent === undefined || parent.loner) && type === 'operation'),
reject: isReject,
},
]"
:edit-mode="editMode"
:resizable="false"
:show-options="type !== 'trigger'"
:style="styleVars"
always-update-position
@edit="$emit('edit', panel)"
@update="$emit('update', { edits: $event, id: panel.id })"
@move="$emit('move', panel.id)"
@delete="$emit('delete', panel.id)"
@duplicate="$emit('duplicate', panel)"
@pointerenter="pointerEnter"
@pointerleave="pointerLeave"
>
<template #body>
<div
v-if="editMode || panel?.resolve"
class="button add-resolve"
x-small
icon
rounded
@pointerdown.stop="pointerdown('resolve')"
>
<v-icon v-tooltip="editMode && t('operation_handle_resolve')" name="check_circle" />
</div>
<transition name="fade">
<div
v-if="editMode && !panel?.resolve && !moving && (panel.id === '$trigger' || isHovered)"
class="hint resolve-hint"
>
<div x-small icon rounded class="button-hint" @pointerdown.stop="pointerdown('resolve')">
<v-icon v-tooltip="t('operation_handle_resolve')" name="add_circle_outline" />
</div>
</div>
</transition>
<div
v-if="panel.id !== '$trigger' && (editMode || panel?.reject)"
x-small
icon
rounded
class="button add-reject"
@pointerdown.stop="pointerdown('reject')"
>
<v-icon v-tooltip="editMode && t('operation_handle_reject')" name="cancel" />
</div>
<transition name="fade">
<div
v-if="editMode && !panel?.reject && !moving && panel.id !== '$trigger' && isHovered"
class="hint reject-hint"
>
<div x-small icon rounded class="button-hint" @pointerdown.stop="pointerdown('reject')">
<v-icon v-tooltip="t('operation_handle_reject')" name="add_circle_outline" />
</div>
</div>
</transition>
<div
v-if="panel.id !== '$trigger'"
x-small
icon
rounded
class="button attachment"
:class="{ reject: parent?.type === 'reject' }"
@pointerdown.stop="pointerdown('parent')"
>
<v-icon name="adjust" />
</div>
</template>
<div v-if="typeof currentOperation?.overview === 'function'" class="block">
<div v-tooltip="panel.key" class="name">
{{ panel.id === '$trigger' ? t(`triggers.${panel.type}.name`) : panel.name }}
</div>
<dl class="options-overview selectable">
<div
v-for="{ label, text, copyable } of translate(currentOperation?.overview(panel.options ?? {}, { flow }))"
:key="label"
>
<dt>{{ label }}</dt>
<dd>{{ text }}</dd>
<v-icon
v-if="isCopySupported && copyable"
name="copy"
small
clickable
class="clipboard-icon"
@click="copyToClipboard(text)"
/>
</div>
</dl>
</div>
<component
:is="`operation-overview-${currentOperation.id}`"
v-else-if="currentOperation && 'id' in currentOperation"
:options="currentOperation"
/>
<template v-if="panel.id === '$trigger'" #footer>
<div class="status-footer" :class="flowStatus">
<display-color
v-tooltip="flowStatus === 'active' ? t('active') : t('inactive')"
class="status-dot"
:value="flowStatus === 'active' ? 'var(--primary)' : 'var(--foreground-subdued)'"
/>
<v-select
v-if="editMode"
class="flow-status-select"
inline
:model-value="flowStatus"
:items="[
{ text: t('active'), value: 'active' },
{ text: t('inactive'), value: 'inactive' },
]"
@update:model-value="flowStatus = $event"
/>
<span v-else>
{{ flowStatus === 'active' ? t('active') : t('inactive') }}
</span>
</div>
</template>
</v-workspace-tile>
</template>
<script lang="ts" setup>
import useClipboard from '@/composables/use-clipboard';
import { getOperations } from '@/operations';
import { translate } from '@/utils/translate-object-values';
import { Vector2 } from '@/utils/vector2';
import { FlowRaw } from '@directus/shared/types';
import { computed, ref, toRefs } from 'vue';
import { useI18n } from 'vue-i18n';
import { ATTACHMENT_OFFSET, REJECT_OFFSET, RESOLVE_OFFSET } from '../constants';
import { getTriggers } from '../triggers';
export type Target = 'resolve' | 'reject';
export type ArrowInfo = {
id: string;
pos: Vector2;
type: Target;
};
const props = withDefaults(
defineProps<{
panel: Record<string, any>;
type?: 'trigger' | 'operation';
editMode?: boolean;
parent?: { id: string; type: Target; loner: boolean };
flow: FlowRaw;
panelsToBeDeleted: string[];
isHovered: boolean;
subdued?: boolean;
}>(),
{
type: 'operation',
editMode: false,
parent: undefined,
isHovered: false,
subdued: false,
}
);
const { panelsToBeDeleted } = toRefs(props);
const { operations } = getOperations();
const { triggers } = getTriggers();
const emit = defineEmits([
'create',
'preview',
'edit',
'update',
'delete',
'move',
'duplicate',
'arrow-move',
'arrow-stop',
'show-hint',
'hide-hint',
'flow-status',
]);
const { t } = useI18n();
const { isCopySupported, copyToClipboard } = useClipboard();
const styleVars = {
'--reject-left': REJECT_OFFSET.x + 'px',
'--reject-top': REJECT_OFFSET.y + 'px',
'--resolve-left': RESOLVE_OFFSET.x + 'px',
'--resolve-top': RESOLVE_OFFSET.y + 'px',
'--attachment-x': ATTACHMENT_OFFSET.x + 'px',
'--attachment-y': ATTACHMENT_OFFSET.y + 'px',
};
const currentOperation = computed(() => {
if (props.type === 'operation') return operations.value.find((operation) => operation.id === props.panel.type);
else return triggers.find((trigger) => trigger.id === props.panel.type);
});
let down: Target | 'parent' | undefined = undefined;
let rafId: number | null = null;
let moving = ref(false);
let workspaceOffset: Vector2 = new Vector2(0, 0);
const isReject = computed(() => props.parent?.type === 'reject');
function pointerdown(target: Target | 'parent') {
if (!props.editMode || (target === 'parent' && props.parent === undefined)) return;
down = target;
const rect = document.getElementsByClassName('workspace').item(0)?.getBoundingClientRect();
if (rect) {
workspaceOffset = new Vector2(rect.left, rect.top);
}
window.addEventListener('pointermove', pointermove);
window.addEventListener('pointerup', pointerup);
}
const pointermove = (event: PointerEvent) => {
rafId = window.requestAnimationFrame(() => {
moving.value = true;
if (!down) return;
const arrowInfo: ArrowInfo =
down === 'parent'
? {
id: props.parent?.id,
type: props.parent?.type as Target,
pos: new Vector2(
Math.round((event.pageX - workspaceOffset.x) / 20) * 20,
Math.round((event.pageY - workspaceOffset.y) / 20) * 20
),
}
: {
id: props.panel.id,
type: down,
pos: new Vector2(
Math.round((event.pageX - workspaceOffset.x) / 20) * 20,
Math.round((event.pageY - workspaceOffset.y) / 20) * 20
),
};
emit('arrow-move', arrowInfo);
});
};
function pointerup() {
if (
!moving.value &&
((down === 'reject' && (!props.panel.reject || panelsToBeDeleted.value.includes(props.panel.reject))) ||
(down === 'resolve' && (!props.panel.resolve || panelsToBeDeleted.value.includes(props.panel.resolve))))
)
emit('create', props.panel.id, down);
moving.value = false;
down = undefined;
if (rafId) window.cancelAnimationFrame(rafId);
emit('arrow-stop');
window.removeEventListener('pointermove', pointermove);
window.removeEventListener('pointerup', pointerup);
}
const flowStatus = computed({
get() {
return props.flow.status;
},
set(newVal: string) {
emit('flow-status', newVal);
},
});
/* show hint buttons */
function pointerEnter() {
if (!props.editMode) return;
emit('show-hint', props.panel.id);
}
function pointerLeave() {
if (!props.editMode) return;
emit('hide-hint');
}
</script>
<style lang="scss" scoped>
.v-workspace-tile.block-container {
position: relative;
overflow: visible;
padding: 4px;
:deep(.header .name) {
color: var(--primary);
}
.flow-status-select {
pointer-events: all;
}
.block {
padding: 0 12px;
height: 100%;
overflow-y: auto;
.name {
display: inline-block;
font-size: 20px;
color: var(--foreground-normal-alt);
font-weight: 600;
margin-bottom: 8px;
}
}
&.trigger {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary);
transition: var(--fast) var(--transition);
transition-property: border-color, box-shadow;
&::before {
position: absolute;
pointer-events: none;
content: '';
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 4px;
z-index: -1;
opacity: 0.2;
box-shadow: 0 0 0 10px var(--primary);
animation-name: floating;
animation-duration: 3s;
animation-iteration-count: infinite;
animation-timing-function: cubic-bezier(0.5, 0, 0.5, 1);
@keyframes floating {
0% {
box-shadow: 0 0 0 10px var(--primary);
opacity: 0.2;
}
50% {
box-shadow: 0 0 0 8px var(--primary);
opacity: 0.3;
}
100% {
box-shadow: 0 0 0 10px var(--primary);
opacity: 0.2;
}
}
}
&.subdued {
border-color: var(--border-subdued);
box-shadow: 0 0 0 1px var(--border-subdued);
&::before {
box-shadow: 0 0 0 7px var(--background-subdued);
opacity: 1;
}
}
}
&:not(.edit-mode) .button {
cursor: default;
}
.button-hint,
.button {
position: absolute;
border-radius: 50%;
cursor: pointer;
z-index: 10;
}
.button-hint {
width: 32px;
height: 32px;
padding: 4px;
}
.hint {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
padding: 20px 20px 20px 60px;
transform: translate(-1px, calc(-50% - 2.5px));
}
.button {
width: 20px;
height: 20px;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--background-page);
transform: translate(calc(-50% - 1px), calc(-50% - 1px));
--v-icon-color: var(--primary);
}
.add-resolve,
.resolve-hint {
top: var(--resolve-top);
left: var(--resolve-left);
.button-hint {
--v-icon-color: var(--primary);
}
}
.add-reject,
.reject-hint {
top: var(--reject-top);
left: var(--reject-left);
--v-icon-color: var(--secondary);
.button-hint {
--v-icon-color: var(--secondary);
}
}
.attachment {
top: var(--attachment-y);
left: var(--attachment-x);
}
&.reject {
:deep(.header) {
.v-icon {
color: var(--secondary);
}
.name {
color: var(--secondary);
}
}
.attachment {
--v-icon-color: var(--secondary);
}
}
&.subdued {
color: var(--foreground-subdued);
:deep(.header) {
.v-icon {
color: var(--foreground-subdued);
}
.name {
color: var(--foreground-subdued);
}
}
.button {
border-color: var(--foreground-subdued);
--v-icon-color: var(--foreground-subdued);
.dot {
background-color: var(--foreground-subdued);
}
}
.button-hint {
--v-icon-color: var(--foreground-subdued);
}
}
}
.options-overview {
> div {
flex-wrap: wrap;
align-items: center;
margin-bottom: 6px;
}
dt {
flex-basis: 100%;
margin-bottom: -2px;
}
dd {
font-family: var(--family-monospace);
flex-basis: 0;
}
.clipboard-icon {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
margin-left: 4px;
}
}
.status-footer {
display: flex;
gap: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity var(--fast) var(--transition);
}
.fade-enter-from,
.fade-leave-to {
position: absolute;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<v-drawer
:model-value="open"
:title="t('change_trigger')"
:subtitle="t('trigger_options')"
icon="offline_bolt"
persistent
@cancel="$emit('update:open', false)"
>
<template #actions>
<v-button v-tooltip.bottom="t('done')" icon rounded :disabled="!currentTrigger" @click="saveTrigger">
<v-icon name="check" />
</v-button>
</template>
<div class="content">
<v-fancy-select v-model="flowEdits.trigger" class="select" :items="triggers" item-text="name" item-value="id" />
<v-form
v-if="flowEdits.trigger"
v-model="flowEdits.options"
class="extension-options"
:fields="currentTriggerOptionFields"
:initial-values="flow?.options"
primary-key="+"
/>
</div>
</v-drawer>
</template>
<script setup lang="ts">
import { FlowRaw, TriggerType } from '@directus/shared/types';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { getTriggers } from '../triggers';
const { t } = useI18n();
const props = defineProps<{
open: boolean;
flow?: FlowRaw;
preview?: boolean;
}>();
const emit = defineEmits(['update:open', 'update:flow', 'first-save']);
const flowEdits = ref<{
trigger?: TriggerType;
options: Record<string, any>;
}>({
trigger: props.flow?.trigger ?? undefined,
options: props.flow?.options ?? {
name: '',
},
});
function saveTrigger() {
if (!currentTrigger.value) return;
emit('update:flow', {
...(props.flow ?? {}),
...flowEdits.value,
});
emit('update:open', false);
}
const { triggers } = getTriggers();
const currentTrigger = computed(() => triggers.find((trigger) => trigger.id === flowEdits.value.trigger));
const currentTriggerOptionFields = computed(() => {
if (!currentTrigger.value) return [];
if (typeof currentTrigger.value.options === 'function') {
return currentTrigger.value.options(flowEdits.value.options);
}
return currentTrigger.value.options;
});
</script>
<style scoped lang="scss">
@import '@/styles/mixins/form-grid';
.content {
padding: var(--content-padding);
padding-top: 0;
padding-bottom: var(--content-padding-bottom);
.grid {
@include form-grid;
}
}
.v-divider {
margin: 52px 0;
}
.type-label {
margin-bottom: 8px;
}
.type-title,
.select {
margin-bottom: 32px;
}
.not-found {
.spacer {
flex-grow: 1;
}
button {
text-decoration: underline;
}
}
.v-notice {
margin-bottom: 36px;
}
</style>

View File

@@ -0,0 +1,8 @@
import { Vector2 } from '@/utils/vector2';
const PANEL_WIDTH = 14;
const PANEL_HEIGHT = 14;
const ATTACHMENT_OFFSET = new Vector2(0, 3 * 20);
const RESOLVE_OFFSET = new Vector2(PANEL_WIDTH * 20, 10 * 20);
const REJECT_OFFSET = new Vector2(PANEL_WIDTH * 20, 12 * 20);
export { PANEL_HEIGHT, PANEL_WIDTH, ATTACHMENT_OFFSET, RESOLVE_OFFSET, REJECT_OFFSET };

View File

@@ -0,0 +1,276 @@
<template>
<v-drawer
:title="isNew ? t('creating_new_flow') : t('updating_flow')"
class="new-flow"
persistent
:model-value="active"
:sidebar-label="t(currentTab[0])"
@cancel="$emit('cancel')"
@esc="$emit('cancel')"
>
<template #sidebar>
<v-tabs v-model="currentTab" vertical>
<v-tab value="flow_setup">{{ t('flow_setup') }}</v-tab>
<v-tab value="trigger_setup" :disabled="!values.name">
{{ t('trigger_setup') }}
</v-tab>
</v-tabs>
</template>
<v-tabs-items v-model="currentTab" class="content">
<v-tab-item value="flow_setup">
<div class="fields">
<div class="field half">
<div class="type-label">
{{ t('flow_name') }}
<v-icon v-tooltip="t('required')" class="required" name="star" sup />
</div>
<v-input v-model="values.name" autofocus :placeholder="t('flow_name')" />
</div>
<div class="field half">
<div class="type-label">{{ t('status') }}</div>
<v-select
v-model="values.status"
:items="[
{
text: t('active'),
value: 'active',
},
{
text: t('inactive'),
value: 'inactive',
},
]"
/>
</div>
<div class="field full">
<div class="type-label">{{ t('description') }}</div>
<v-input v-model="values.description" :placeholder="t('description')" />
</div>
<div class="field half">
<div class="type-label">{{ t('icon') }}</div>
<interface-select-icon :value="values.icon" @input="values.icon = $event" />
</div>
<div class="field half">
<div class="type-label">{{ t('color') }}</div>
<interface-select-color width="half" :value="values.color" @input="values.color = $event" />
</div>
<v-divider class="full" />
<div class="field full">
<div class="type-label">{{ t('flow_tracking') }}</div>
<v-select
v-model="values.accountability"
:items="[
{
text: t('flow_tracking_all'),
value: 'all',
},
{
text: t('flow_tracking_activity'),
value: 'activity',
},
{
text: t('flow_tracking_null'),
value: null,
},
]"
/>
</div>
</div>
</v-tab-item>
<v-tab-item value="trigger_setup">
<v-fancy-select v-model="values.trigger" class="select" :items="triggers" item-text="name" item-value="id" />
<v-form
v-if="values.trigger"
v-model="values.options"
class="extension-options"
:fields="currentTriggerOptionFields"
primary-key="+"
/>
</v-tab-item>
</v-tabs-items>
<template #actions>
<v-button
v-if="currentTab[0] === 'flow_setup'"
v-tooltip.bottom="t('next')"
:disabled="!values.name || values.name.length === 0"
icon
rounded
@click="currentTab = ['trigger_setup']"
>
<v-icon name="arrow_forward" />
</v-button>
<v-button
v-if="currentTab[0] === 'trigger_setup'"
v-tooltip.bottom="t('finish_setup')"
:disabled="!values.trigger"
:loading="saving"
icon
rounded
@click="save"
>
<v-icon name="check" />
</v-button>
</template>
</v-drawer>
</template>
<script lang="ts" setup>
import api from '@/api';
import { useFlowsStore } from '@/stores';
import { unexpectedError } from '@/utils/unexpected-error';
import { TriggerType } from '@directus/shared/types';
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { getTriggers } from './triggers';
interface Values {
name: string | null;
icon: string | null;
color: string | null;
description: string | null;
status: string;
accountability: string | null;
trigger?: TriggerType | null;
options: Record<string, any>;
}
interface Props {
primaryKey?: string;
active: boolean;
startTab?: string;
}
const props = withDefaults(defineProps<Props>(), { primaryKey: '+', startTab: 'flow_setup' });
const emit = defineEmits(['cancel', 'done']);
const { t } = useI18n();
const flowsStore = useFlowsStore();
const currentTab = ref(['flow_setup']);
const isNew = computed(() => props.primaryKey === '+');
const values: Values = reactive({
name: null,
icon: 'bolt',
color: null,
description: null,
status: 'active',
accountability: 'all',
trigger: undefined,
options: {},
});
watch(
() => props.primaryKey,
(newKey) => {
currentTab.value = [props.startTab];
if (newKey === '+') {
values.name = null;
values.icon = 'bolt';
values.color = null;
values.description = null;
values.status = 'active';
values.accountability = 'all';
values.trigger = undefined;
values.options = {};
} else {
const existing = flowsStore.flows.find((existingFlow) => existingFlow.id === newKey)!;
values.name = existing.name;
values.icon = existing.icon;
values.color = existing.color;
values.description = existing.description;
values.status = existing.status;
values.accountability = existing.accountability;
values.trigger = existing.trigger;
values.options = existing.options;
}
},
{ immediate: true }
);
watch(
() => values.trigger,
() => {
values.options = {};
}
);
watch(
() => values.options?.type,
(type) => {
values.options = {
type,
};
}
);
const { triggers } = getTriggers();
const currentTrigger = computed(() => triggers.find((trigger) => trigger.id === values.trigger));
const currentTriggerOptionFields = computed(() => {
if (!currentTrigger.value) return [];
if (typeof currentTrigger.value.options === 'function') {
return currentTrigger.value.options(values.options);
}
return currentTrigger.value.options;
});
const saving = ref(false);
async function save() {
saving.value = true;
try {
let id: string;
if (isNew.value) {
id = await api.post('/flows', values, { params: { fields: ['id'] } }).then((res) => res.data.data.id);
} else {
id = await api
.patch(`/flows/${props.primaryKey}`, values, { params: { fields: ['id'] } })
.then((res) => res.data.data.id);
}
await flowsStore.hydrate();
emit('done', id);
} catch (err: any) {
unexpectedError(err);
} finally {
saving.value = false;
}
}
</script>
<style lang="scss" scoped>
@import '@/styles/mixins/form-grid';
.fields {
@include form-grid;
}
.v-icon.required {
color: var(--primary);
}
.content {
padding: var(--content-padding);
padding-top: 0;
padding-bottom: var(--content-padding);
}
.select {
margin-bottom: 32px;
}
</style>

View File

@@ -0,0 +1,716 @@
<template>
<settings-not-found v-if="!flow" />
<private-view v-else :title="flow?.name ?? t('loading')">
<template #title-outer:prepend>
<v-button class="header-icon" rounded icon exact to="/settings/flows">
<v-icon name="arrow_back" />
</v-button>
</template>
<template #headline>
<v-breadcrumb :items="[{ name: t('flows'), to: '/settings/flows' }]" />
</template>
<template #title:append>
<display-color
v-tooltip="flow.status === 'active' ? t('active') : t('inactive')"
class="status-dot"
:value="flow.status === 'active' ? 'var(--primary)' : 'var(--foreground-subdued)'"
/>
</template>
<template #actions>
<template v-if="editMode">
<v-button
v-tooltip.bottom="t('clear_changes')"
class="clear-changes"
rounded
icon
outlined
@click="attemptCancelChanges"
>
<v-icon name="clear" />
</v-button>
<v-button v-tooltip.bottom="t('save')" rounded icon :loading="saving" @click="saveChanges">
<v-icon name="check" />
</v-button>
</template>
<template v-else>
<v-button
v-tooltip.bottom="t('delete_flow')"
class="delete-flow"
rounded
icon
secondary
@click="confirmDelete = true"
>
<v-icon name="delete" />
</v-button>
<v-button v-tooltip.bottom="t('edit_flow')" rounded icon outlined @click="editMode = !editMode">
<v-icon name="edit" />
</v-button>
</template>
</template>
<template #sidebar>
<sidebar-detail icon="info_outline" :title="t('information')" close>
<div v-md="t('page_help_settings_flows_item')" class="page-description" />
</sidebar-detail>
<logs-sidebar-detail :flow="flow" />
</template>
<template #navigation>
<settings-navigation />
</template>
<div class="container">
<arrows
:panels="panels"
:arrow-info="arrowInfo"
:parent-panels="parentPanels"
:edit-mode="editMode"
:hovered-panel="hoveredPanelID"
:subdued="flow.status === 'inactive'"
/>
<v-workspace :panels="panels" :edit-mode="editMode">
<template #panel="{ panel }">
<operation
v-if="flow"
:edit-mode="editMode"
:panel="panel"
:type="panel.id === '$trigger' ? 'trigger' : 'operation'"
:parent="parentPanels[panel.id]"
:flow="flow"
:panels-to-be-deleted="panelsToBeDeleted"
:is-hovered="hoveredPanelID === panel.id"
:subdued="flow.status === 'inactive'"
@create="createPanel"
@edit="editPanel"
@move="movePanelID = $event"
@update="stageOperationEdits"
@delete="deletePanel"
@duplicate="duplicatePanel"
@arrow-move="arrowMove"
@arrow-stop="arrowStop"
@show-hint="hoveredPanelID = $event"
@hide-hint="hoveredPanelID = null"
@flow-status="stagedFlow.status = $event"
/>
</template>
</v-workspace>
</div>
<flow-drawer
:active="triggerDetailOpen"
:primary-key="flow.id"
:start-tab="'trigger_setup'"
@cancel="triggerDetailOpen = false"
@done="triggerDetailOpen = false"
/>
<v-dialog v-model="confirmLeave" @esc="confirmLeave = false">
<v-card>
<v-card-title>{{ t('unsaved_changes') }}</v-card-title>
<v-card-text>{{ t('unsaved_changes_copy') }}</v-card-text>
<v-card-actions>
<v-button secondary @click="discardAndLeave">{{ t('discard_changes') }}</v-button>
<v-button @click="confirmLeave = false">{{ t('keep_editing') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="confirmCancel" @esc="confirmCancel = false">
<v-card>
<v-card-title>{{ t('unsaved_changes') }}</v-card-title>
<v-card-text>{{ t('discard_changes_copy') }}</v-card-text>
<v-card-actions>
<v-button secondary @click="cancelChanges">{{ t('discard_changes') }}</v-button>
<v-button @click="confirmCancel = false">{{ t('keep_editing') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog :model-value="confirmDelete" @esc="confirmDelete = false">
<v-card>
<v-card-title>{{ t('flow_delete_confirm', { flow: flow.name }) }}</v-card-title>
<v-card-actions>
<v-button secondary @click="confirmDelete = false">{{ t('cancel') }}</v-button>
<v-button danger :loading="deleting" @click="deleteFlow">{{ t('delete_label') }}</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog :model-value="!!movePanelID" @update:model-value="movePanelID = undefined" @esc="movePanelID = undefined">
<v-card>
<v-card-title>{{ t('copy_to') }}</v-card-title>
<v-card-text>
<v-notice v-if="movePanelChoices.length === 0">
{{ t('no_other_flows_copy') }}
</v-notice>
<v-select v-else v-model="movePanelTo" :items="movePanelChoices" item-text="name" item-value="id" />
</v-card-text>
<v-card-actions>
<v-button secondary @click="movePanelID = undefined">
{{ t('cancel') }}
</v-button>
<v-button :loading="movePanelLoading" :disabled="movePanelChoices.length === 0" @click="movePanel">
{{ t('copy') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<router-view
:operation="panels.find((panel) => panel.id === props.operationId)"
:existing-operation-keys="exitingOperationKeys"
:flow="flow"
@save="stageOperation"
@cancel="cancelOperation"
/>
</private-view>
</template>
<script setup lang="ts">
import { FlowRaw, OperationRaw } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
import { computed, ref } from 'vue';
import { useFlowsStore } from '@/stores';
import { unexpectedError } from '@/utils/unexpected-error';
import api from '@/api';
import useEditsGuard from '@/composables/use-edits-guard';
import useShortcut from '@/composables/use-shortcut';
import { isEmpty, merge, omit, cloneDeep } from 'lodash';
import { router } from '@/router';
import { nanoid, customAlphabet } from 'nanoid';
import SettingsNotFound from '../not-found.vue';
import SettingsNavigation from '../../components/navigation.vue';
import Operation, { ArrowInfo, Target } from './components/operation.vue';
import { AppTile } from '@/components/v-workspace-tile.vue';
import { ATTACHMENT_OFFSET, PANEL_HEIGHT, PANEL_WIDTH } from './constants';
import Arrows from './components/arrows.vue';
import { Vector2 } from '@/utils/vector2';
import FlowDrawer from './flow-drawer.vue';
import LogsSidebarDetail from './components/logs-sidebar-detail.vue';
// Maps the x and y coordinates of attachments of panels to their id
export type Attachments = Record<number, Record<number, string>>;
export type ParentInfo = { id: string; type: Target; loner: boolean };
const { t } = useI18n();
const props = defineProps<{
primaryKey: string;
operationId?: string;
}>();
const saving = ref(false);
useShortcut('meta+s', () => {
saveChanges();
});
// ------------- Manage Current Flow ------------- //
const flowsStore = useFlowsStore();
const stagedFlow = ref<Partial<FlowRaw>>({});
const flow = computed<FlowRaw | undefined>({
get() {
const existing = flowsStore.flows.find((flow) => flow.id === props.primaryKey);
return merge({}, existing, stagedFlow.value);
},
set(newFlow) {
stagedFlow.value = newFlow ?? {};
},
});
const exitingOperationKeys = computed(() => [
...(flow.value?.operations || []).map((operation) => operation.key),
...stagedPanels.value.filter((stagedPanel) => stagedPanel.key !== undefined).map((stagedPanel) => stagedPanel.key!),
]);
const editMode = ref(flow.value?.operations.length === 0 || props.operationId !== undefined);
const confirmDelete = ref(false);
const deleting = ref(false);
async function deleteFlow() {
if (!flow.value?.id) return;
deleting.value = true;
try {
await api.delete(`/flows/${flow.value.id}`);
await flowsStore.hydrate();
} catch (err: any) {
unexpectedError(err);
} finally {
deleting.value = false;
router.push('/settings/flows');
}
}
// ------------- Manage Panels ------------- //
const triggerDetailOpen = ref(false);
const stagedPanels = ref<Partial<OperationRaw & { borderRadius: [boolean, boolean, boolean, boolean] }>[]>([]);
const panelsToBeDeleted = ref<string[]>([]);
const hoveredPanelID = ref<string | null>(null);
const panels = computed(() => {
const savedPanels = (flow.value?.operations || []).filter(
(panel) => panelsToBeDeleted.value.includes(panel.id) === false
);
const raw = [
...savedPanels.map((panel) => {
const updates = stagedPanels.value.find((updatedPanel) => updatedPanel.id === panel.id);
if (updates) {
return merge({}, panel, updates);
}
return panel;
}),
...stagedPanels.value.filter((panel) => panel.id?.startsWith('_')),
];
const panels: Record<string, any>[] = raw.map((panel) => ({
...panel,
width: PANEL_WIDTH,
height: PANEL_HEIGHT,
x: panel.position_x,
y: panel.position_y,
panel_name: t(`operations.${panel.type}.name`),
}));
const trigger: Record<string, any> = {
id: '$trigger',
panel_name: t('trigger'),
icon: 'offline_bolt',
x: 1,
y: 1,
width: PANEL_WIDTH,
height: PANEL_HEIGHT,
showHeader: true,
draggable: false,
flow: props.primaryKey,
type: flow.value?.trigger,
options: flow.value?.options,
};
if (flow.value?.operation) trigger.resolve = flow.value.operation;
panels.push(trigger);
return panels;
});
const parentPanels = computed(() => {
const parents = panels.value.reduce<Record<string, ParentInfo>>((acc, panel) => {
if (panel.resolve)
acc[panel.resolve] = {
id: panel.id,
type: 'resolve',
loner: true,
};
if (panel.reject)
acc[panel.reject] = {
id: panel.id,
type: 'reject',
loner: true,
};
return acc;
}, {});
return Object.fromEntries(
Object.entries(parents).map(([key, value]) => {
return [key, { ...value, loner: !connectedToTrigger(key) }];
})
);
function connectedToTrigger(id: string) {
let parent = parents[id];
while (parent?.id !== '$trigger') {
if (parent === undefined) return false;
parent = parents[parent.id];
}
return true;
}
});
let parentId: string | undefined = undefined;
let attachType: 'resolve' | 'reject' | undefined = undefined;
function stageOperationEdits(event: { edits: Partial<OperationRaw>; id?: string }) {
const key = event.id ?? props.operationId;
if (key === '+') {
const attach: Record<string, any> = {};
const tempId = `_${nanoid()}`;
if (parentId !== undefined && attachType !== undefined) {
const parent = panels.value.find((panel) => panel.id === parentId);
if (parent) {
if (parentId === '$trigger') {
stagedFlow.value = { ...stagedFlow.value, operation: tempId };
} else {
stageOperationEdits({ edits: { [attachType]: tempId }, id: parentId });
}
if (attachType === 'resolve') {
attach.position_x = parent.x + PANEL_WIDTH + 4;
attach.position_y = parent.y;
} else {
attach.position_x = parent.x + PANEL_WIDTH + 4;
attach.position_y = parent.y + PANEL_HEIGHT + 2;
}
}
}
stagedPanels.value = [
...stagedPanels.value,
{
id: tempId,
flow: props.primaryKey,
position_x: 15,
position_y: 15,
...event.edits,
...attach,
},
];
} else {
if (stagedPanels.value.some((panel) => panel.id === key)) {
stagedPanels.value = stagedPanels.value.map((panel) => {
if (panel.id === key) {
return merge({ id: key, flow: props.primaryKey }, panel, event.edits);
}
return panel;
});
} else {
stagedPanels.value = [...stagedPanels.value, { id: key, flow: props.primaryKey, ...event.edits }];
}
}
}
function stageOperation(edits: Partial<OperationRaw>) {
stageOperationEdits({ edits });
parentId = undefined;
attachType = undefined;
router.replace(`/settings/flows/${props.primaryKey}`);
}
function cancelOperation() {
parentId = undefined;
attachType = undefined;
router.replace(`/settings/flows/${props.primaryKey}`);
}
async function saveChanges() {
const trees = getTrees().map(addChangesToTree);
if (!flow.value) return;
if (stagedPanels.value.length === 0 && panelsToBeDeleted.value.length === 0 && isEmpty(stagedFlow.value)) {
editMode.value = false;
return;
}
saving.value = true;
try {
if (trees.length > 0) {
const changes: Record<string, any> = {
...stagedFlow.value,
operations: {
create: trees.filter((tree) => !('id' in tree)),
update: trees.filter((tree) => 'id' in tree && tree.id !== '$trigger'),
delete: panelsToBeDeleted.value,
},
};
const trigger = trees.find((tree) => tree.id === '$trigger');
if (trigger && trigger.resolve !== undefined) changes.operation = trigger.resolve;
await api.patch(`/flows/${props.primaryKey}`, changes);
}
await flowsStore.hydrate();
stagedPanels.value = [];
panelsToBeDeleted.value = [];
stagedFlow.value = {};
editMode.value = false;
} catch (error) {
unexpectedError(error as Error);
} finally {
saving.value = false;
}
}
type Tree = {
id: string;
reject?: Tree;
resolve?: Tree;
};
function getTrees() {
const rejectResolveIds = panels.value.reduce<Set<string>>((acc, panel) => {
if (panel.resolve) acc.add(panel.resolve);
if (panel.reject) acc.add(panel.reject);
return acc;
}, new Set());
const topOperations = panels.value.filter((panel) => !rejectResolveIds.has(panel.id));
const trees = topOperations.map(constructTree);
return trees;
function constructTree(root: Record<string, any>): Tree {
const resolve = panels.value.find((panel) => panel.id === root.resolve);
const reject = panels.value.find((panel) => panel.id === root.reject);
return {
id: root.id,
reject: reject ? constructTree(reject) : undefined,
resolve: resolve ? constructTree(resolve) : undefined,
};
}
}
function addChangesToTree(tree: Tree): Record<string, any> {
const edits = stagedPanels.value.find((panel) => panel.id === tree.id);
const newTree = edits ? cloneDeep(edits) : ({ id: tree.id } as Record<string, any>);
if (tree.reject) newTree.reject = addChangesToTree(tree.reject);
if (tree.resolve) newTree.resolve = addChangesToTree(tree.resolve);
if (tree.id.startsWith('_')) delete newTree.id;
newTree.flow = props.primaryKey;
return newTree;
}
async function deletePanel(id: string) {
if (!flow.value) return;
stagedPanels.value = stagedPanels.value.filter((panel) => panel.id !== id);
if (!id.startsWith('_')) {
panelsToBeDeleted.value.push(id);
}
if (flow.value.operation === id) {
stagedFlow.value = { operation: null };
} else {
const parent = parentPanels.value[id];
if (parent) {
stageOperationEdits({ edits: { [parent.type]: null }, id: parent.id });
}
}
}
function createPanel(parent: string, type: 'resolve' | 'reject') {
parentId = parent;
attachType = type;
router.push(`/settings/flows/${props.primaryKey}/+`);
}
function duplicatePanel(panel: OperationRaw) {
const newPanel = omit(merge({}, panel), 'id', 'resolve', 'reject');
const newKey = customAlphabet('abcdefghijklmnopqrstuvwxyz', 5)();
newPanel.position_x = newPanel.position_x + 2;
newPanel.position_y = newPanel.position_y + 2;
newPanel.key = `${newPanel.key}_${newKey}`;
stageOperationEdits({ edits: newPanel, id: '+' });
}
function editPanel(panel: AppTile) {
if (panel.id === '$trigger') triggerDetailOpen.value = true;
else router.push(`/settings/flows/${props.primaryKey}/${panel.id}`);
}
// ------------- Move Panel To ------------- //
const movePanelID = ref<string | undefined>();
const movePanelTo = ref<string | undefined>();
const movePanelLoading = ref(false);
const movePanelChoices = computed(() => flowsStore.flows.filter((flow) => flow.id !== props.primaryKey));
async function movePanel() {
movePanelLoading.value = true;
const currentPanel = panels.value.find((panel) => panel.id === movePanelID.value);
try {
await api.post(`/operations`, {
...omit(currentPanel, ['id']),
flow: movePanelTo.value,
});
await flowsStore.hydrate();
movePanelID.value = undefined;
} catch (err: any) {
unexpectedError(err);
} finally {
movePanelLoading.value = false;
}
}
// ------------- Drag&Drop Arrows ------------- //
const arrowInfo = ref<ArrowInfo | undefined>();
function arrowMove(info: ArrowInfo) {
arrowInfo.value = info;
}
function arrowStop() {
if (!arrowInfo.value) {
arrowInfo.value = undefined;
return;
}
const nearPanel = getNearAttachment(arrowInfo.value?.pos);
if (nearPanel && isLoop(arrowInfo.value.id, nearPanel)) {
arrowInfo.value = undefined;
return;
}
// make sure only one arrow can be connected to an attachment
if (nearPanel && parentPanels.value[nearPanel]) {
const currentlyConnected = parentPanels.value[nearPanel];
if (currentlyConnected.id === '$trigger') {
flow.value = merge({}, flow.value, { operation: null });
} else {
stageOperationEdits({
edits: {
[currentlyConnected.type]: null,
},
id: currentlyConnected.id,
});
}
}
if (arrowInfo.value.id === '$trigger') {
flow.value = merge({}, flow.value, { operation: nearPanel ?? null });
} else {
stageOperationEdits({
edits: {
[arrowInfo.value.type]: nearPanel ?? null,
},
id: arrowInfo.value.id,
});
}
arrowInfo.value = undefined;
}
function isLoop(currentId: string, attachTo: string) {
let parent = currentId;
while (parent !== undefined) {
if (parent === attachTo) return true;
parent = parentPanels.value[parent]?.id ?? undefined;
}
return false;
}
function getNearAttachment(pos: Vector2) {
for (const panel of panels.value) {
const attachmentPos = new Vector2(
(panel.x - 1) * 20 + ATTACHMENT_OFFSET.x,
(panel.y - 1) * 20 + ATTACHMENT_OFFSET.y
);
if (attachmentPos.distanceTo(pos) <= 40) return panel.id as string;
}
return undefined;
}
// ------------- Navigation Guard ------------- //
const hasEdits = computed(() => stagedPanels.value.length > 0 || panelsToBeDeleted.value.length > 0);
const { confirmLeave, leaveTo } = useEditsGuard(hasEdits);
const confirmCancel = ref(false);
function attemptCancelChanges(): void {
if (hasEdits.value) {
confirmCancel.value = true;
} else {
cancelChanges();
}
}
function cancelChanges() {
confirmCancel.value = false;
stagedPanels.value = [];
stagedFlow.value = {};
panelsToBeDeleted.value = [];
editMode.value = false;
}
function discardAndLeave() {
if (!leaveTo.value) return;
cancelChanges();
confirmLeave.value = false;
router.push(leaveTo.value);
}
</script>
<style scoped lang="scss">
.header-icon {
--v-button-background-color: var(--primary-10);
--v-button-color: var(--primary);
--v-button-background-color-hover: var(--primary-25);
--v-button-color-hover: var(--primary);
}
.status-dot {
margin-left: 6px;
}
.container {
--column-size: 200px;
--row-size: 100px;
--gap-size: 40px;
}
.clear-changes {
--v-button-background-color: var(--foreground-subdued);
--v-button-background-color-hover: var(--foreground-normal);
}
.delete-flow {
--v-button-background-color-hover: var(--danger) !important;
--v-button-color-hover: var(--white) !important;
}
.grid {
display: grid;
grid-template-rows: repeat(auto-fit, var(--row-size));
grid-template-columns: repeat(auto-fit, var(--column-size));
gap: var(--gap-size);
min-width: calc(var(--column-size) * 2);
min-height: calc(var(--row-size) * 2);
}
</style>

View File

@@ -0,0 +1,264 @@
<template>
<private-view :title="t('flows')">
<template #title-outer:prepend>
<v-button class="header-icon" rounded disabled icon>
<v-icon name="bolt" />
</v-button>
</template>
<template #headline>
<v-breadcrumb :items="[{ name: t('settings'), to: '/settings' }]" />
</template>
<template #navigation>
<settings-navigation />
</template>
<template #actions>
<v-button
v-tooltip.bottom="createAllowed ? t('create_flow') : t('not_allowed')"
rounded
icon
:disabled="createAllowed === false"
@click="editFlow = '+'"
>
<v-icon name="add" />
</v-button>
</template>
<template #sidebar>
<sidebar-detail icon="info_outline" :title="t('information')" close>
<div v-md="t('page_help_settings_flows_collection')" class="page-description" />
</sidebar-detail>
</template>
<v-info v-if="flows.length === 0" icon="bolt" :title="t('no_flows')" center>
{{ t('no_flows_copy') }}
<template v-if="createAllowed" #append>
<v-button @click="editFlow = '+'">{{ t('create_flow') }}</v-button>
</template>
</v-info>
<v-table
v-else
v-model:headers="tableHeaders"
:items="flows"
:sort="internalSort"
show-resize
fixed-header
@click:row="navigateToFlow"
@update:sort="internalSort = $event"
>
<template #[`item.icon`]="{ item }">
<v-icon class="icon" :name="item.icon ?? 'bolt'" :color="item.color ?? 'var(--primary)'" />
</template>
<template #[`item.status`]="{ item }">
<display-formatted-value
type="string"
:item="item"
:value="item.status"
:conditional-formatting="conditionalFormatting"
/>
</template>
<template #item-append="{ item }">
<v-menu placement="left-start" show-arrow>
<template #activator="{ toggle }">
<v-icon name="more_vert" class="ctx-toggle" @click="toggle" />
</template>
<v-list>
<v-list-item clickable @click="toggleFlowStatusById(item.id, item.status)">
<template v-if="item.status === 'active'">
<v-list-item-icon><v-icon name="block" /></v-list-item-icon>
<v-list-item-content>{{ t('set_flow_inactive') }}</v-list-item-content>
</template>
<template v-else>
<v-list-item-icon><v-icon name="check" /></v-list-item-icon>
<v-list-item-content>{{ t('set_flow_active') }}</v-list-item-content>
</template>
</v-list-item>
<v-list-item clickable @click="editFlow = item.id">
<v-list-item-icon>
<v-icon name="edit" outline />
</v-list-item-icon>
<v-list-item-content>
{{ t('edit_flow') }}
</v-list-item-content>
</v-list-item>
<v-list-item class="danger" clickable @click="confirmDelete = item">
<v-list-item-icon>
<v-icon name="delete" outline />
</v-list-item-icon>
<v-list-item-content>
{{ t('delete_flow') }}
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</template>
</v-table>
<v-dialog :model-value="!!confirmDelete" @esc="confirmDelete = null">
<v-card>
<v-card-title>{{ t('flow_delete_confirm', { flow: confirmDelete!.name }) }}</v-card-title>
<v-card-actions>
<v-button secondary @click="confirmDelete = null">
{{ t('cancel') }}
</v-button>
<v-button danger :loading="deletingFlow" @click="deleteFlow">
{{ t('delete_label') }}
</v-button>
</v-card-actions>
</v-card>
</v-dialog>
<flow-drawer
:active="editFlow !== undefined"
:primary-key="editFlow"
@cancel="editFlow = undefined"
@done="onFlowDrawerCompletion"
/>
<router-view name="add" />
</private-view>
</template>
<script lang="ts" setup>
import api from '@/api';
import { Sort } from '@/components/v-table/types';
import { router } from '@/router';
import { useFlowsStore, usePermissionsStore } from '@/stores';
import { unexpectedError } from '@/utils/unexpected-error';
import { FlowRaw } from '@directus/shared/types';
import { computed, ref, Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import SettingsNavigation from '../../components/navigation.vue';
import FlowDrawer from './flow-drawer.vue';
const { t } = useI18n();
const permissionsStore = usePermissionsStore();
const confirmDelete = ref<FlowRaw | null>(null);
const deletingFlow = ref(false);
const editFlow = ref<string | undefined>();
const createAllowed = computed<boolean>(() => {
return permissionsStore.hasPermission('directus_flows', 'create');
});
const conditionalFormatting = ref([
{
operator: 'eq',
value: 'active',
text: t('active'),
color: 'var(--foreground-inverted)',
background: 'var(--primary)',
},
{
operator: 'eq',
value: 'inactive',
text: t('inactive'),
color: 'var(--foreground-subdued)',
background: 'var(--background-normal)',
},
]);
const tableHeaders = [
{
text: '',
value: 'icon',
width: 42,
sortable: false,
},
{
text: t('status'),
value: 'status',
width: 100,
},
{
text: t('name'),
value: 'name',
width: 240,
},
{
text: t('description'),
value: 'description',
width: 360,
},
];
const internalSort: Ref<Sort> = ref({ by: 'name', desc: false });
const flowsStore = useFlowsStore();
const flows = computed(() => flowsStore.flows);
function navigateToFlow({ item: flow }: { item: FlowRaw }) {
router.push(`/settings/flows/${flow.id}`);
}
async function deleteFlow() {
if (!confirmDelete.value) return;
deletingFlow.value = true;
try {
await api.delete(`/flows/${confirmDelete.value.id}`);
await flowsStore.hydrate();
confirmDelete.value = null;
} catch (err: any) {
unexpectedError(err);
} finally {
deletingFlow.value = false;
}
}
async function toggleFlowStatusById(id: string, value: string) {
try {
await api.patch(`/flows/${id}`, {
status: value === 'active' ? 'inactive' : 'active',
});
await flowsStore.hydrate();
} catch (error) {
unexpectedError(error as Error);
}
}
function onFlowDrawerCompletion(id: string) {
if (editFlow.value === '+') {
router.push(`/settings/flows/${id}`);
}
editFlow.value = undefined;
}
</script>
<style scoped>
.v-table {
padding: var(--content-padding);
padding-top: 0;
}
.ctx-toggle {
--v-icon-color: var(--foreground-subdued);
--v-icon-color-hover: var(--foreground-normal);
}
.v-list-item.danger {
--v-list-item-color: var(--danger);
--v-list-item-color-hover: var(--danger);
--v-list-item-icon-color: var(--danger);
}
.header-icon {
--v-button-color-disabled: var(--primary);
--v-button-background-color-disabled: var(--primary-10);
}
</style>

View File

@@ -0,0 +1,391 @@
import { DeepPartial, Field, FlowRaw, TriggerType, Width } from '@directus/shared/types';
import { useI18n } from 'vue-i18n';
import { getPublicURL } from '../../../../utils/get-root-path';
export type Trigger = {
name: string;
id: TriggerType;
icon: string;
description: string;
overview: (
options: Record<string, any>,
{ flow }: { flow: FlowRaw }
) => { text: string; label: string; copyable?: boolean }[];
options: DeepPartial<Field>[] | ((options: Record<string, any>) => DeepPartial<Field>[]);
};
export function getTriggers() {
const { t } = useI18n();
const triggers: Trigger[] = [
{
id: 'event',
name: t('triggers.event.name'),
icon: 'anchor',
description: t('triggers.event.description'),
overview: ({ type, scope, collections }) => {
const labels = [
{
label: t('type'),
text: type,
},
];
labels.push({
label: t('scope'),
text: scope.join(', '),
});
if (collections?.length) {
labels.push({
label: t('collections'),
text: collections.join(', '),
});
}
return labels;
},
options: ({ type, scope }) => {
const fields = [
{
field: 'type',
name: t('type'),
meta: {
interface: 'select-radio',
options: {
choices: [
{
text: t('triggers.event.filter'),
value: 'filter',
},
{
text: t('triggers.event.action'),
value: 'action',
},
],
},
},
},
];
const actionFields = [
{
field: 'scope',
name: t('scope'),
meta: {
interface: 'select-multiple-dropdown',
options: {
placeholder: t('scope'),
choices: [
'items.create',
'items.update',
'items.delete',
{ divider: true },
'server.start',
'server.stop',
'response',
'auth.login',
'files.upload',
],
font: 'monospace',
},
width: 'full' as Width,
},
},
{
field: 'collections',
name: t('collections'),
meta: {
interface: 'system-collections',
width: 'full' as Width,
readonly:
!scope || ['items.create', 'items.update', 'items.delete'].every((t) => scope?.includes(t) === false),
options: {
includeSystem: true,
},
},
},
];
const filterFields = [
{
field: 'scope',
name: t('scope'),
meta: {
interface: 'select-multiple-dropdown',
options: {
placeholder: t('scope'),
choices: [
'items.create',
'items.update',
'items.delete',
{ divider: true },
'request.not_found',
'request.error',
'database.error',
'auth.login',
'auth.jwt',
'authenticate',
],
font: 'monospace',
},
width: 'full' as Width,
},
},
{
field: 'collections',
name: t('collections'),
meta: {
interface: 'system-collections',
width: 'full' as Width,
readonly:
!scope || ['items.create', 'items.update', 'items.delete'].every((t) => scope?.includes(t) === false),
options: {
includeSystem: true,
},
},
},
{
field: 'return',
name: t('triggers.common.response_body'),
type: 'string',
meta: {
width: 'full',
interface: 'select-radio',
options: {
choices: [
{
text: '$t:triggers.common.response_body_last',
value: '$last',
},
{
text: '$t:triggers.common.response_body_all',
value: '$all',
},
],
allowOther: true,
},
},
},
];
if (type === 'action') {
return [...fields, ...actionFields];
}
if (type === 'filter') {
return [...fields, ...filterFields];
}
return fields;
},
},
{
id: 'webhook',
name: t('triggers.webhook.name'),
icon: 'link',
description: t('triggers.webhook.description'),
overview: ({ method }, { flow }) => [
{
label: t('method'),
text: `${method ?? 'GET'}`,
},
{
label: t('url'),
text: `${getPublicURL()}flows/trigger/${flow.id}`,
copyable: true,
},
],
options: ({ async }) => [
{
field: 'method',
name: t('triggers.webhook.method'),
type: 'string',
meta: {
width: 'half',
interface: 'select-dropdown',
options: {
choices: [
{ text: 'GET', value: 'GET' },
{ text: 'POST', value: 'POST' },
],
},
},
schema: {
default_value: 'GET',
},
},
{
field: 'async',
name: t('triggers.webhook.async'),
type: 'boolean',
meta: {
width: 'half',
interface: 'toggle',
required: true,
},
schema: {
default_value: false,
},
},
{
field: 'return',
name: t('triggers.common.response_body'),
type: 'string',
schema: {
default_value: '$last',
},
meta: {
width: 'full',
interface: 'select-radio',
options: {
choices: [
{
text: '$t:triggers.common.response_body_last',
value: '$last',
},
{
text: '$t:triggers.common.response_body_all',
value: '$all',
},
],
allowOther: true,
},
hidden: async,
},
},
],
},
{
id: 'schedule',
name: t('triggers.schedule.name'),
icon: 'schedule',
description: t('triggers.schedule.description'),
overview: ({ cron }) => [
{
label: t('triggers.schedule.cron'),
text: cron,
},
],
options: [
{
field: 'cron',
name: t('triggers.schedule.cron'),
type: 'string',
meta: {
width: 'full',
interface: 'input',
options: {
placeholder: '* * 1 * * *',
},
},
},
],
},
{
id: 'operation',
name: t('triggers.operation.name'),
icon: 'bolt',
description: t('triggers.operation.description'),
overview: () => [],
options: [
{
field: 'return',
name: t('triggers.common.response_body'),
type: 'string',
meta: {
width: 'full',
interface: 'select-radio',
options: {
choices: [
{
text: '$t:triggers.common.response_body_last',
value: '$last',
},
{
text: '$t:triggers.common.response_body_all',
value: '$all',
},
],
allowOther: true,
},
},
},
],
},
{
id: 'manual',
name: t('triggers.manual.name'),
icon: 'touch_app',
description: t('triggers.manual.description'),
overview: ({ collections }) => {
const labels = [
{
label: t('triggers.manual.description'),
text: '',
},
];
if (collections?.length) {
labels.push({
label: t('collections'),
text: collections.join(', '),
});
}
return labels;
},
options: [
{
field: 'collections',
name: t('collections'),
meta: {
interface: 'system-collections',
width: 'full' as Width,
},
},
{
field: 'location',
name: t('location'),
meta: {
interface: 'select-dropdown',
width: 'half' as Width,
options: {
choices: [
{
text: t('triggers.manual.collection_and_item'),
value: 'both',
},
{
text: t('triggers.manual.collection_only'),
value: 'collection',
},
{
text: t('triggers.manual.item_only'),
value: 'item',
},
],
},
},
schema: {
default_value: 'both',
},
},
{
field: 'async',
name: t('triggers.webhook.async'),
type: 'boolean',
meta: {
width: 'half' as Width,
interface: 'toggle',
},
schema: {
default_value: false,
},
},
],
},
];
return { triggers };
}

View File

@@ -174,6 +174,16 @@ export const appRecommendedPermissions: Partial<Permission>[] = [
},
fields: ['*'],
},
{
collection: 'directus_flows',
action: 'read',
permissions: {
trigger: {
_eq: 'manual',
},
},
fields: ['id', 'name', 'icon', 'color', 'options', 'trigger'],
},
];
export const appMinimalPermissions: Partial<Permission>[] = [

View File

@@ -0,0 +1,35 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'condition',
icon: 'rule',
name: '$t:operations.condition.name',
description: '$t:operations.condition.description',
overview: ({ filter }) => [
{
label: '$t:condition_rules',
text: filter,
},
],
options: () => [
{
field: 'filter',
name: '$t:condition_rules',
type: 'json',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: `{
"$trigger": {
"category": {
"_eq": "Example"
}
}
}`,
},
},
},
],
});

View File

@@ -0,0 +1,13 @@
import { shallowRef, Ref } from 'vue';
import { OperationAppConfig } from '@directus/shared/types';
const operationsRaw: Ref<OperationAppConfig[]> = shallowRef([]);
const operations: Ref<OperationAppConfig[]> = shallowRef([]);
export function getOperations(): { operations: Ref<OperationAppConfig[]>; operationsRaw: Ref<OperationAppConfig[]> } {
return { operations, operationsRaw };
}
export function getOperation(name?: string | null): OperationAppConfig | undefined {
return !name ? undefined : operations.value.find(({ id }) => id === name);
}

View File

@@ -0,0 +1,83 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'item-create',
icon: 'add',
name: '$t:operations.item-create.name',
description: '$t:operations.item-create.description',
overview: ({ collection, payload }) => [
{
label: '$t:collection',
text: collection,
},
{
label: '$t:operations.item-create.payload',
text: payload,
},
],
options: [
{
field: 'collection',
name: '$t:collection',
type: 'string',
meta: {
width: 'half',
interface: 'system-collection',
},
},
{
field: 'permissions',
name: '$t:permissions',
type: 'string',
schema: {
default_value: '$trigger',
},
meta: {
width: 'half',
interface: 'select-dropdown',
options: {
choices: [
{
text: 'From Trigger',
value: '$trigger',
},
{
text: 'Public Role',
value: '$public',
},
{
text: 'Full Access',
value: '$full',
},
],
allowOther: true,
},
},
},
{
field: 'emitEvents',
name: '$t:operations.item-create.emit_events',
type: 'boolean',
meta: {
width: 'half',
interface: 'boolean',
},
schema: {
default_value: true,
},
},
{
field: 'payload',
name: '$t:operations.item-create.payload',
type: 'string',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: '$t:item_payload_placeholder',
},
},
},
],
});

View File

@@ -0,0 +1,122 @@
import { defineOperationApp } from '@directus/shared/utils';
import { toArray } from '@directus/shared/utils';
export default defineOperationApp({
id: 'item-delete',
icon: 'delete',
name: '$t:operations.item-delete.name',
description: '$t:operations.item-delete.description',
overview: ({ mode, collection, key }) => {
const overviewItems = [
{
label: '$t:collection',
text: collection,
},
];
if (mode !== 'query') {
overviewItems.push({
label: '$t:operations.item-delete.key',
text: key ? toArray(key).join(', ') : '--',
});
}
return overviewItems;
},
options: [
{
field: 'permissions',
name: '$t:permissions',
type: 'string',
schema: {
default_value: '$trigger',
},
meta: {
width: 'full',
interface: 'select-dropdown',
options: {
choices: [
{
text: 'From Trigger',
value: '$trigger',
},
{
text: 'Public Role',
value: '$public',
},
{
text: 'Full Access',
value: '$full',
},
],
allowOther: true,
},
},
},
{
field: 'collection',
name: '$t:collection',
type: 'string',
meta: {
width: 'half',
interface: 'system-collection',
},
},
{
field: 'key',
name: '$t:operations.item-delete.key',
type: 'csv',
meta: {
width: 'half',
interface: 'tags',
options: {
iconRight: 'vpn_key',
},
conditions: [
{
rule: {
mode: {
_eq: 'query',
},
},
hidden: true,
},
],
},
},
{
field: 'query',
name: '$t:operations.item-delete.query',
type: 'string',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: JSON.stringify(
{
filter: {
status: {
_eq: 'active',
},
},
},
null,
2
),
template: JSON.stringify(
{
filter: {
status: {
_eq: 'active',
},
},
},
null,
2
),
},
},
},
],
});

View File

@@ -0,0 +1,122 @@
import { defineOperationApp } from '@directus/shared/utils';
import { toArray } from '@directus/shared/utils';
export default defineOperationApp({
id: 'item-read',
icon: 'visibility',
name: '$t:operations.item-read.name',
description: '$t:operations.item-read.description',
overview: ({ mode, collection, key }) => {
const overviewItems = [
{
label: '$t:collection',
text: collection,
},
];
if (mode !== 'query') {
overviewItems.push({
label: '$t:operations.item-read.key',
text: key ? toArray(key).join(', ') : '--',
});
}
return overviewItems;
},
options: [
{
field: 'permissions',
name: '$t:permissions',
type: 'string',
schema: {
default_value: '$trigger',
},
meta: {
width: 'full',
interface: 'select-dropdown',
options: {
choices: [
{
text: 'From Trigger',
value: '$trigger',
},
{
text: 'Public Role',
value: '$public',
},
{
text: 'Full Access',
value: '$full',
},
],
allowOther: true,
},
},
},
{
field: 'collection',
name: '$t:collection',
type: 'string',
meta: {
width: 'half',
interface: 'system-collection',
},
},
{
field: 'key',
name: '$t:operations.item-read.key',
type: 'csv',
meta: {
width: 'half',
interface: 'tags',
options: {
iconRight: 'vpn_key',
},
conditions: [
{
rule: {
mode: {
_eq: 'query',
},
},
hidden: true,
},
],
},
},
{
field: 'query',
name: '$t:operations.item-read.query',
type: 'string',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: JSON.stringify(
{
filter: {
status: {
_eq: 'active',
},
},
},
null,
2
),
template: JSON.stringify(
{
filter: {
status: {
_eq: 'active',
},
},
},
null,
2
),
},
},
},
],
});

View File

@@ -0,0 +1,144 @@
import { defineOperationApp, toArray } from '@directus/shared/utils';
export default defineOperationApp({
id: 'item-update',
icon: 'edit',
name: '$t:operations.item-update.name',
description: '$t:operations.item-update.description',
overview: ({ mode, collection, key }) => {
const overviewItems = [
{
label: '$t:collection',
text: collection,
},
];
if (mode !== 'query') {
overviewItems.push({
label: '$t:operations.item-update.key',
text: key ? toArray(key).join(', ') : '--',
});
}
return overviewItems;
},
options: [
{
field: 'permissions',
name: '$t:permissions',
type: 'string',
schema: {
default_value: '$trigger',
},
meta: {
width: 'full',
interface: 'select-dropdown',
options: {
choices: [
{
text: 'From Trigger',
value: '$trigger',
},
{
text: 'Public Role',
value: '$public',
},
{
text: 'Full Access',
value: '$full',
},
],
allowOther: true,
},
},
},
{
field: 'collection',
name: '$t:collection',
type: 'string',
meta: {
width: 'half',
interface: 'system-collection',
},
},
{
field: 'key',
name: '$t:operations.item-update.key',
type: 'csv',
meta: {
width: 'half',
interface: 'tags',
options: {
iconRight: 'vpn_key',
},
conditions: [
{
rule: {
mode: {
_eq: 'query',
},
},
hidden: true,
},
],
},
},
{
field: 'payload',
name: '$t:operations.item-update.payload',
type: 'string',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: '$t:item_payload_placeholder',
},
},
},
{
field: 'query',
name: '$t:operations.item-update.query',
type: 'string',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: JSON.stringify(
{
filter: {
status: {
_eq: 'active',
},
},
},
null,
2
),
template: JSON.stringify(
{
filter: {
status: {
_eq: 'active',
},
},
},
null,
2
),
},
conditions: [
{
rule: {
mode: {
_neq: 'query',
},
},
hidden: true,
},
],
},
},
],
});

View File

@@ -0,0 +1,29 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'log',
icon: 'terminal',
name: '$t:operations.log.name',
description: '$t:operations.log.description',
overview: ({ message }) => [
{
label: '$t:operations.log.message',
text: message,
},
],
options: [
{
field: 'message',
name: '$t:operations.log.message',
type: 'string',
meta: {
width: 'full',
interface: 'input',
options: {
placeholder: '$t:operations.log.message_placeholder',
},
note: '$t:operation_variables_note',
},
},
],
});

View File

@@ -0,0 +1,58 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'mail',
icon: 'mail',
name: '$t:operations.mail.name',
description: '$t:operations.mail.description',
overview: ({ subject, to, body }) => [
{
label: '$t:subject',
text: subject,
},
{
label: '$t:operations.mail.to',
text: Array.isArray(to) ? to.join(', ') : to,
},
{
label: '$t:operations.mail.body',
text: body,
},
],
options: [
{
field: 'to',
name: '$t:operations.mail.to',
type: 'string',
meta: {
width: 'full',
interface: 'tags',
options: {
placeholder: '$t:operations.mail.to_placeholder',
iconRight: 'alternate_email',
},
},
},
{
field: 'subject',
name: '$t:subject',
type: 'string',
meta: {
width: 'full',
interface: 'input',
options: {
iconRight: 'title',
},
},
},
{
field: 'body',
name: '$t:operations.mail.body',
type: 'string',
meta: {
width: 'full',
interface: 'input-rich-text-md',
},
},
],
});

View File

@@ -0,0 +1,83 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'notification',
icon: 'notifications',
name: '$t:operations.notification.name',
description: '$t:operations.notification.description',
overview: ({ subject, message }) => [
{
label: '$t:subject',
text: subject,
},
{
label: '$t:message',
text: message,
},
],
options: [
{
field: 'recipient',
name: '$t:operations.notification.recipient',
type: 'string',
meta: {
width: 'half',
interface: 'input',
options: {
iconRight: 'people_alt',
placeholder: '$t:a_users_uuid',
},
},
},
{
field: 'permissions',
name: '$t:permissions',
type: 'string',
schema: {
default_value: '$trigger',
},
meta: {
width: 'half',
interface: 'select-dropdown',
options: {
choices: [
{
text: 'From Trigger',
value: '$trigger',
},
{
text: 'Public Role',
value: '$public',
},
{
text: 'Full Access',
value: '$full',
},
],
allowOther: true,
},
},
},
{
field: 'subject',
name: '$t:title',
type: 'string',
meta: {
width: 'full',
interface: 'input',
options: {
iconRight: 'title',
},
},
},
{
field: 'message',
name: '$t:operations.notification.message',
type: 'string',
meta: {
width: 'full',
interface: 'input-rich-text-md',
},
},
],
});

View File

@@ -0,0 +1,45 @@
import { getRootPath } from '@/utils/get-root-path';
import { App } from 'vue';
import { getOperations } from './index';
import { OperationAppConfig } from '@directus/shared/types';
const { operationsRaw } = getOperations();
export async function registerOperations(app: App): Promise<void> {
const operationModules = import.meta.globEager('./*/**/index.ts');
const operations: OperationAppConfig[] = Object.values(operationModules).map((module) => module.default);
try {
const customOperations: { default: OperationAppConfig[] } = import.meta.env.DEV
? await import('@directus-extensions-operation')
: await import(/* @vite-ignore */ `${getRootPath()}extensions/operations/index.js`);
operations.push(...customOperations.default);
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn(`Couldn't load custom operations`);
// eslint-disable-next-line no-console
console.warn(err);
}
operationsRaw.value = operations;
operationsRaw.value.forEach((operation: OperationAppConfig) => {
if (
typeof operation.overview !== 'function' &&
Array.isArray(operation.overview) === false &&
operation.overview !== null
) {
app.component(`operation-overview-${operation.id}`, operation.overview);
}
if (
typeof operation.options !== 'function' &&
Array.isArray(operation.options) === false &&
operation.options !== null
) {
app.component(`operation-options-${operation.id}`, operation.options);
}
});
}

View File

@@ -0,0 +1,103 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'request',
icon: 'link',
name: '$t:operations.request.name',
description: '$t:operations.request.description',
overview: ({ url, method }) => [
{
label: '$t:operations.request.url',
text: url,
},
{
label: '$t:operations.request.method',
text: method ?? 'GET',
},
],
options: [
{
field: 'method',
name: '$t:operations.request.method',
type: 'string',
meta: {
width: 'half',
interface: 'select-dropdown',
options: {
choices: [
{ value: 'GET', text: 'GET' },
{ value: 'POST', text: 'POST' },
{ value: 'PATCH', text: 'PATCH' },
{ value: 'DELETE', text: 'DELETE' },
],
allowOther: true,
},
},
schema: {
default_value: 'GET',
},
},
{
field: 'url',
name: '$t:operations.request.url',
type: 'string',
meta: {
width: 'half',
interface: 'input',
options: {
placeholder: '$t:operations.request.url_placeholder',
},
},
},
{
field: 'headers',
name: '$t:operations.request.headers',
type: 'string',
meta: {
width: 'full',
interface: 'list',
options: {
fields: [
{
field: 'header',
name: '$t:operations.request.header',
type: 'string',
meta: {
width: 'half',
interface: 'input',
options: {
placeholder: '$t:operations.request.header_placeholder',
},
},
},
{
field: 'value',
name: '$t:value',
type: 'string',
meta: {
width: 'half',
interface: 'input',
options: {
placeholder: '$t:operations.request.value_placeholder',
},
},
},
],
},
},
},
{
field: 'body',
name: '$t:request_body',
type: 'string',
meta: {
width: 'full',
interface: 'input-multiline',
options: {
font: 'monospace',
placeholder: '$t:any_string_or_json',
},
},
},
],
});

View File

@@ -0,0 +1,30 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'sleep',
icon: 'schedule',
name: '$t:operations.sleep.name',
description: '$t:operations.sleep.description',
overview: ({ milliseconds }) => [
{
label: '$t:operations.sleep.milliseconds',
text: milliseconds,
},
],
options: [
{
field: 'milliseconds',
name: '$t:operations.sleep.milliseconds',
type: 'integer',
meta: {
width: 'full',
interface: 'input',
options: {
min: 0,
type: 'integer',
placeholder: '1000',
},
},
},
],
});

View File

@@ -0,0 +1,44 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'transform',
icon: 'move_down',
name: '$t:operations.transform.name',
description: '$t:operations.transform.description',
overview: ({ json }) => [
{
label: '$t:json',
text: json,
},
],
options: [
{
field: 'json',
name: '$t:json',
type: 'string',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: JSON.stringify(
{
user: '{{ $accountability.user }}',
data: '{{ $last }}',
},
null,
2
),
template: JSON.stringify(
{
user: '{{ $accountability.user }}',
data: '{{ $last }}',
},
null,
2
),
},
},
},
],
});

View File

@@ -0,0 +1,57 @@
import { defineOperationApp } from '@directus/shared/utils';
export default defineOperationApp({
id: 'trigger',
icon: 'flag',
name: '$t:operations.trigger.name',
description: '$t:operations.trigger.description',
overview: ({ flow }) => [
{
label: '$t:operations.trigger.flow',
text: flow,
},
],
options: [
{
field: 'flow',
name: '$t:operations.trigger.flow',
type: 'string',
meta: {
width: 'full',
interface: 'input',
options: {
iconRight: 'bolt',
placeholder: '$t:a_flow_uuid',
},
},
},
{
field: 'payload',
name: '$t:payload',
type: 'string',
meta: {
width: 'full',
interface: 'input-code',
options: {
language: 'json',
placeholder: JSON.stringify(
{
user: '{{ $accountability.user }}',
data: '{{ $last }}',
},
null,
2
),
template: JSON.stringify(
{
user: '{{ $accountability.user }}',
data: '{{ $last }}',
},
null,
2
),
},
},
},
],
});

View File

@@ -29,15 +29,10 @@ export default defineComponent({
type: String,
required: true,
},
dashboard: {
type: String,
required: true,
},
now: {
type: Date,
required: true,
},
collection: {
type: String,
required: true,

6
app/src/shims.d.ts vendored
View File

@@ -33,6 +33,12 @@ declare module 'frappe-charts/src/js/charts/AxisChart' {
}
}
declare module '@directus-extensions-operation' {
import { OperationAppConfig } from '@directus/shared/types';
const operations: OperationAppConfig[];
export default operations;
}
declare module '@directus-extensions-interface' {
import { InterfaceConfig } from '@directus/shared/types';
const interfaces: InterfaceConfig[];

37
app/src/stores/flows.ts Normal file
View File

@@ -0,0 +1,37 @@
import { FlowRaw } from '@directus/shared/types';
import api from '@/api';
import { defineStore } from 'pinia';
import { useUserStore, usePermissionsStore } from '@/stores';
export const useFlowsStore = defineStore({
id: 'flowsStore',
state: () => ({
flows: [] as FlowRaw[],
}),
actions: {
async hydrate() {
const { isAdmin } = useUserStore();
const { hasPermission } = usePermissionsStore();
if (isAdmin !== true && !hasPermission('directus_flows', 'read')) {
this.flows = [];
} else {
try {
const response = await api.get<any>('/flows', {
params: { limit: -1, fields: ['*', 'operations.*'] },
});
this.flows = response.data.data;
} catch {
this.flows = [];
}
}
},
async dehydrate() {
this.$reset();
},
getManualFlowsForCollection(collection: string): FlowRaw[] {
return this.flows.filter((flow) => flow.trigger === 'manual' && flow.options?.collections.includes(collection));
},
},
});

Some files were not shown because too many files have changed in this diff Show More