diff --git a/api/src/auth/drivers/oauth2.ts b/api/src/auth/drivers/oauth2.ts index 17a1b72188..3fb637169d 100644 --- a/api/src/auth/drivers/oauth2.ts +++ b/api/src/auth/drivers/oauth2.ts @@ -1,3 +1,5 @@ +import { BaseException } from '@directus/shared/exceptions'; +import { parseJSON } from '@directus/shared/utils'; import { Router } from 'express'; import flatten from 'flat'; import jwt from 'jsonwebtoken'; @@ -5,7 +7,6 @@ import ms from 'ms'; import { Client, errors, generators, Issuer } from 'openid-client'; import { getAuthProvider } from '../../auth'; import env from '../../env'; -import { BaseException } from '@directus/shared/exceptions'; import { InvalidConfigException, InvalidCredentialsException, @@ -19,7 +20,6 @@ import { AuthData, AuthDriverOptions, User } from '../../types'; import asyncHandler from '../../utils/async-handler'; import { getConfigFromEnv } from '../../utils/get-config-from-env'; import { getIPFromReq } from '../../utils/get-ip-from-req'; -import { parseJSON } from '../../utils/parse-json'; import { Url } from '../../utils/url'; import { LocalAuthDriver } from './local'; diff --git a/api/src/auth/drivers/openid.ts b/api/src/auth/drivers/openid.ts index a4b97d661e..9d1dbd3934 100644 --- a/api/src/auth/drivers/openid.ts +++ b/api/src/auth/drivers/openid.ts @@ -1,3 +1,5 @@ +import { BaseException } from '@directus/shared/exceptions'; +import { parseJSON } from '@directus/shared/utils'; import { Router } from 'express'; import flatten from 'flat'; import jwt from 'jsonwebtoken'; @@ -5,7 +7,6 @@ import ms from 'ms'; import { Client, errors, generators, Issuer } from 'openid-client'; import { getAuthProvider } from '../../auth'; import env from '../../env'; -import { BaseException } from '@directus/shared/exceptions'; import { InvalidConfigException, InvalidCredentialsException, @@ -19,7 +20,6 @@ import { AuthData, AuthDriverOptions, User } from '../../types'; import asyncHandler from '../../utils/async-handler'; import { getConfigFromEnv } from '../../utils/get-config-from-env'; import { getIPFromReq } from '../../utils/get-ip-from-req'; -import { parseJSON } from '../../utils/parse-json'; import { Url } from '../../utils/url'; import { LocalAuthDriver } from './local'; diff --git a/api/src/cli/commands/schema/apply.ts b/api/src/cli/commands/schema/apply.ts index 1fa534e54b..d8169d78fe 100644 --- a/api/src/cli/commands/schema/apply.ts +++ b/api/src/cli/commands/schema/apply.ts @@ -1,3 +1,4 @@ +import { parseJSON } from '@directus/shared/utils'; import chalk from 'chalk'; import { promises as fs } from 'fs'; import inquirer from 'inquirer'; @@ -10,7 +11,6 @@ import { Snapshot } from '../../../types'; import { applySnapshot, isNestedMetaUpdate } from '../../../utils/apply-snapshot'; import { getSnapshot } from '../../../utils/get-snapshot'; import { getSnapshotDiff } from '../../../utils/get-snapshot-diff'; -import { parseJSON } from '../../../utils/parse-json'; export async function apply(snapshotPath: string, options?: { yes: boolean; dryRun: boolean }): Promise { const filename = path.resolve(process.cwd(), snapshotPath); diff --git a/api/src/controllers/assets.ts b/api/src/controllers/assets.ts index 041dbf450e..e33f17cf1e 100644 --- a/api/src/controllers/assets.ts +++ b/api/src/controllers/assets.ts @@ -1,4 +1,5 @@ import { Range } from '@directus/drive'; +import { parseJSON } from '@directus/shared/utils'; import { Router } from 'express'; import helmet from 'helmet'; import { merge, pick } from 'lodash'; @@ -12,7 +13,6 @@ import { AssetsService, PayloadService } from '../services'; import { TransformationMethods, TransformationParams, TransformationPreset } from '../types/assets'; import asyncHandler from '../utils/async-handler'; import { getConfigFromEnv } from '../utils/get-config-from-env'; -import { parseJSON } from '../utils/parse-json'; const router = Router(); diff --git a/api/src/controllers/dashboards.ts b/api/src/controllers/dashboards.ts index 73c14d037e..319f54ebdc 100644 --- a/api/src/controllers/dashboards.ts +++ b/api/src/controllers/dashboards.ts @@ -97,7 +97,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/files.ts b/api/src/controllers/files.ts index 64c829a3bc..1d6fc95c0c 100644 --- a/api/src/controllers/files.ts +++ b/api/src/controllers/files.ts @@ -258,7 +258,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/flows.ts b/api/src/controllers/flows.ts index 8a1697d03e..37bbbbbbef 100644 --- a/api/src/controllers/flows.ts +++ b/api/src/controllers/flows.ts @@ -124,7 +124,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/folders.ts b/api/src/controllers/folders.ts index 6eed9ec3ee..1e93dda861 100644 --- a/api/src/controllers/folders.ts +++ b/api/src/controllers/folders.ts @@ -105,7 +105,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/items.ts b/api/src/controllers/items.ts index c0e1fe8ad4..3126aa5ea3 100644 --- a/api/src/controllers/items.ts +++ b/api/src/controllers/items.ts @@ -135,7 +135,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/notifications.ts b/api/src/controllers/notifications.ts index 9b0a52910d..2b8101e7c3 100644 --- a/api/src/controllers/notifications.ts +++ b/api/src/controllers/notifications.ts @@ -106,7 +106,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/operations.ts b/api/src/controllers/operations.ts index b39e86f212..00f6ce750a 100644 --- a/api/src/controllers/operations.ts +++ b/api/src/controllers/operations.ts @@ -97,7 +97,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/panels.ts b/api/src/controllers/panels.ts index 5b6a667bbe..2296818ade 100644 --- a/api/src/controllers/panels.ts +++ b/api/src/controllers/panels.ts @@ -97,7 +97,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/permissions.ts b/api/src/controllers/permissions.ts index b26b40f389..94ee28d144 100644 --- a/api/src/controllers/permissions.ts +++ b/api/src/controllers/permissions.ts @@ -107,7 +107,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/presets.ts b/api/src/controllers/presets.ts index b1cff328a4..3612c9bfb0 100644 --- a/api/src/controllers/presets.ts +++ b/api/src/controllers/presets.ts @@ -106,7 +106,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/roles.ts b/api/src/controllers/roles.ts index be9b402b02..e2c37f0043 100644 --- a/api/src/controllers/roles.ts +++ b/api/src/controllers/roles.ts @@ -98,7 +98,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/shares.ts b/api/src/controllers/shares.ts index 1164e34ff7..a4ed1fd1cf 100644 --- a/api/src/controllers/shares.ts +++ b/api/src/controllers/shares.ts @@ -200,7 +200,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/controllers/users.ts b/api/src/controllers/users.ts index 2f6f7436cf..e3035235b9 100644 --- a/api/src/controllers/users.ts +++ b/api/src/controllers/users.ts @@ -183,7 +183,9 @@ router.patch( let keys: PrimaryKey[] = []; - if (req.body.keys) { + if (Array.isArray(req.body)) { + keys = await service.updateBatch(req.body); + } else 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); diff --git a/api/src/database/migrations/20210225A-add-relations-sort-field.ts b/api/src/database/migrations/20210225A-add-relations-sort-field.ts index 5626a1b172..3753b95ee0 100644 --- a/api/src/database/migrations/20210225A-add-relations-sort-field.ts +++ b/api/src/database/migrations/20210225A-add-relations-sort-field.ts @@ -1,5 +1,5 @@ +import { parseJSON } from '@directus/shared/utils'; import { Knex } from 'knex'; -import { parseJSON } from '../../utils/parse-json'; export async function up(knex: Knex): Promise { await knex.schema.alterTable('directus_relations', (table) => { diff --git a/api/src/database/migrations/20210506A-rename-interfaces.ts b/api/src/database/migrations/20210506A-rename-interfaces.ts index 745446c067..93aea09ea1 100644 --- a/api/src/database/migrations/20210506A-rename-interfaces.ts +++ b/api/src/database/migrations/20210506A-rename-interfaces.ts @@ -1,5 +1,5 @@ +import { parseJSON } from '@directus/shared/utils'; import { Knex } from 'knex'; -import { parseJSON } from '../../utils/parse-json'; // [before, after, after-option additions] const changes: [string, string, Record?][] = [ diff --git a/api/src/database/migrations/20210802A-replace-groups.ts b/api/src/database/migrations/20210802A-replace-groups.ts index aace64b486..c22bd3fca2 100644 --- a/api/src/database/migrations/20210802A-replace-groups.ts +++ b/api/src/database/migrations/20210802A-replace-groups.ts @@ -1,6 +1,6 @@ +import { parseJSON } from '@directus/shared/utils'; import { Knex } from 'knex'; import logger from '../../logger'; -import { parseJSON } from '../../utils/parse-json'; export async function up(knex: Knex): Promise { const dividerGroups = await knex.select('*').from('directus_fields').where('interface', '=', 'group-divider'); diff --git a/api/src/database/migrations/20210805A-update-groups.ts b/api/src/database/migrations/20210805A-update-groups.ts index b94b983c79..f917a75369 100644 --- a/api/src/database/migrations/20210805A-update-groups.ts +++ b/api/src/database/migrations/20210805A-update-groups.ts @@ -1,5 +1,5 @@ +import { parseJSON } from '@directus/shared/utils'; import { Knex } from 'knex'; -import { parseJSON } from '../../utils/parse-json'; export async function up(knex: Knex): Promise { const groups = await knex.select('*').from('directus_fields').where({ interface: 'group-standard' }); diff --git a/api/src/database/migrations/20210805B-change-image-metadata-structure.ts b/api/src/database/migrations/20210805B-change-image-metadata-structure.ts index d1b2c912af..3248592fa3 100644 --- a/api/src/database/migrations/20210805B-change-image-metadata-structure.ts +++ b/api/src/database/migrations/20210805B-change-image-metadata-structure.ts @@ -1,5 +1,5 @@ +import { parseJSON } from '@directus/shared/utils'; import { Knex } from 'knex'; -import { parseJSON } from '../../utils/parse-json'; // Change image metadata structure to match the output from 'exifr' export async function up(knex: Knex): Promise { diff --git a/api/src/database/migrations/20211007A-update-presets.ts b/api/src/database/migrations/20211007A-update-presets.ts index d19ea00b8f..11f5590424 100644 --- a/api/src/database/migrations/20211007A-update-presets.ts +++ b/api/src/database/migrations/20211007A-update-presets.ts @@ -1,7 +1,7 @@ import { Filter, LogicalFilterAND } from '@directus/shared/types'; +import { parseJSON } from '@directus/shared/utils'; import { Knex } from 'knex'; import { nanoid } from 'nanoid'; -import { parseJSON } from '../../utils/parse-json'; type OldFilter = { key: string; diff --git a/api/src/database/migrations/20220429A-add-flows.ts b/api/src/database/migrations/20220429A-add-flows.ts index 443c70c8bf..9442d01357 100644 --- a/api/src/database/migrations/20220429A-add-flows.ts +++ b/api/src/database/migrations/20220429A-add-flows.ts @@ -1,7 +1,6 @@ +import { parseJSON, toArray } from '@directus/shared/utils'; 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 { await knex.schema.createTable('directus_flows', (table) => { diff --git a/api/src/env.ts b/api/src/env.ts index cf20ff8bff..28c55de10d 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -9,7 +9,7 @@ import { clone, toNumber, toString } from 'lodash'; import path from 'path'; import { requireYAML } from './utils/require-yaml'; import { toArray } from '@directus/shared/utils'; -import { parseJSON } from './utils/parse-json'; +import { parseJSON } from '@directus/shared/utils'; // keeping this here for now to prevent a circular import to constants.ts const allowedEnvironmentVars = [ diff --git a/api/src/flows.ts b/api/src/flows.ts index 1f16b987b4..7b34a3c79a 100644 --- a/api/src/flows.ts +++ b/api/src/flows.ts @@ -1,14 +1,18 @@ import * as sharedExceptions from '@directus/shared/exceptions'; import { + Accountability, + Action, ActionHandler, FilterHandler, Flow, Operation, OperationHandler, SchemaOverview, - Accountability, - Action, } from '@directus/shared/types'; +import { applyOptionsData } from '@directus/shared/utils'; +import fastRedact from 'fast-redact'; +import { Knex } from 'knex'; +import { omit } from 'lodash'; import { get } from 'micromustache'; import { schedule, validate } from 'node-cron'; import getDatabase from './database'; @@ -16,18 +20,14 @@ import emitter from './emitter'; import env from './env'; import * as exceptions from './exceptions'; import logger from './logger'; +import { getMessenger } from './messenger'; import * as services from './services'; import { FlowsService } from './services'; +import { ActivityService } from './services/activity'; +import { RevisionsService } from './services/revisions'; 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'; import { JobQueue } from './utils/job-queue'; let flowManager: FlowManager | undefined; @@ -376,7 +376,7 @@ class FlowManager { const handler = this.operations[operation.type]; - const options = applyOperationOptions(operation.options, keyedData); + const options = applyOptionsData(operation.options, keyedData); try { const result = await handler(options, { diff --git a/api/src/messenger.ts b/api/src/messenger.ts index 7a734959ef..f7204694c3 100644 --- a/api/src/messenger.ts +++ b/api/src/messenger.ts @@ -1,7 +1,7 @@ +import { parseJSON } from '@directus/shared/utils'; import IORedis from 'ioredis'; import env from './env'; import { getConfigFromEnv } from './utils/get-config-from-env'; -import { parseJSON } from './utils/parse-json'; export type MessengerSubscriptionCallback = (payload: Record) => void; diff --git a/api/src/middleware/graphql.ts b/api/src/middleware/graphql.ts index d7afe14755..03224c60df 100644 --- a/api/src/middleware/graphql.ts +++ b/api/src/middleware/graphql.ts @@ -1,9 +1,9 @@ +import { parseJSON } from '@directus/shared/utils'; import { RequestHandler } from 'express'; import { DocumentNode, getOperationAST, parse, Source } from 'graphql'; import { InvalidPayloadException, InvalidQueryException, MethodNotAllowedException } from '../exceptions'; import { GraphQLParams } from '../types'; import asyncHandler from '../utils/async-handler'; -import { parseJSON } from '../utils/parse-json'; export const parseGraphQL: RequestHandler = asyncHandler(async (req, res, next) => { if (req.method !== 'GET' && req.method !== 'POST') { diff --git a/api/src/middleware/validate-batch.ts b/api/src/middleware/validate-batch.ts index 594e2e8754..43f8c2beb9 100644 --- a/api/src/middleware/validate-batch.ts +++ b/api/src/middleware/validate-batch.ts @@ -27,8 +27,10 @@ export const validateBatch = (scope: 'read' | 'update' | 'delete'): RequestHandl batchSchema = batchSchema.xor('query', 'keys'); } - // In updates, we add a required `data` that holds the update payload + // In updates, we add a required `data` that holds the update payload if an array isn't used if (scope === 'update') { + if (Array.isArray(req.body)) return next(); + batchSchema = batchSchema.keys({ data: Joi.object().unknown().required(), }); diff --git a/api/src/operations/item-create/index.ts b/api/src/operations/item-create/index.ts index fbd9a97559..c251551ff7 100644 --- a/api/src/operations/item-create/index.ts +++ b/api/src/operations/item-create/index.ts @@ -1,8 +1,7 @@ import { Accountability, PrimaryKey } from '@directus/shared/types'; -import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { defineOperationApi, optionToObject, 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 = { diff --git a/api/src/operations/item-delete/index.ts b/api/src/operations/item-delete/index.ts index 55460d594d..69aaf9b100 100644 --- a/api/src/operations/item-delete/index.ts +++ b/api/src/operations/item-delete/index.ts @@ -1,8 +1,7 @@ import { Accountability, PrimaryKey } from '@directus/shared/types'; -import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { defineOperationApi, optionToObject, toArray } from '@directus/shared/utils'; import { ItemsService } from '../../services'; import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; -import { optionToObject } from '../../utils/operation-options'; import { sanitizeQuery } from '../../utils/sanitize-query'; type Options = { diff --git a/api/src/operations/item-read/index.ts b/api/src/operations/item-read/index.ts index 3313d22c17..24ad6843f8 100644 --- a/api/src/operations/item-read/index.ts +++ b/api/src/operations/item-read/index.ts @@ -1,9 +1,8 @@ import { Accountability, PrimaryKey } from '@directus/shared/types'; -import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { defineOperationApi, optionToObject, toArray } from '@directus/shared/utils'; import { ItemsService } from '../../services'; import { Item } from '../../types'; import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; -import { optionToObject } from '../../utils/operation-options'; import { sanitizeQuery } from '../../utils/sanitize-query'; type Options = { diff --git a/api/src/operations/item-update/index.ts b/api/src/operations/item-update/index.ts index e77ceb6ca1..b6e00739b1 100644 --- a/api/src/operations/item-update/index.ts +++ b/api/src/operations/item-update/index.ts @@ -1,9 +1,8 @@ import { Accountability, PrimaryKey } from '@directus/shared/types'; -import { defineOperationApi, toArray } from '@directus/shared/utils'; +import { defineOperationApi, optionToObject, toArray } from '@directus/shared/utils'; import { ItemsService } from '../../services'; import { Item } from '../../types'; import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; -import { optionToObject } from '../../utils/operation-options'; import { sanitizeQuery } from '../../utils/sanitize-query'; type Options = { diff --git a/api/src/operations/log/index.ts b/api/src/operations/log/index.ts index 1a66f14dd7..db49fba373 100644 --- a/api/src/operations/log/index.ts +++ b/api/src/operations/log/index.ts @@ -1,6 +1,5 @@ -import { defineOperationApi } from '@directus/shared/utils'; +import { defineOperationApi, optionToString } from '@directus/shared/utils'; import logger from '../../logger'; -import { optionToString } from '../../utils/operation-options'; type Options = { message: unknown; diff --git a/api/src/operations/notification/index.ts b/api/src/operations/notification/index.ts index 6f204a6225..14b03f9430 100644 --- a/api/src/operations/notification/index.ts +++ b/api/src/operations/notification/index.ts @@ -1,7 +1,6 @@ import { Accountability } from '@directus/shared/types'; -import { defineOperationApi } from '@directus/shared/utils'; +import { defineOperationApi, optionToString } from '@directus/shared/utils'; import { NotificationsService } from '../../services'; -import { optionToString } from '../../utils/operation-options'; import { getAccountabilityForRole } from '../../utils/get-accountability-for-role'; type Options = { diff --git a/api/src/operations/transform/index.ts b/api/src/operations/transform/index.ts index 094dde4583..d90f13dee3 100644 --- a/api/src/operations/transform/index.ts +++ b/api/src/operations/transform/index.ts @@ -1,5 +1,4 @@ -import { defineOperationApi } from '@directus/shared/utils'; -import { parseJSON } from '../../utils/parse-json'; +import { defineOperationApi, parseJSON } from '@directus/shared/utils'; type Options = { json: string; diff --git a/api/src/operations/trigger/index.ts b/api/src/operations/trigger/index.ts index 7625f1b0ac..aa159cf867 100644 --- a/api/src/operations/trigger/index.ts +++ b/api/src/operations/trigger/index.ts @@ -1,6 +1,5 @@ -import { defineOperationApi } from '@directus/shared/utils'; +import { defineOperationApi, optionToObject } from '@directus/shared/utils'; import { getFlowManager } from '../../flows'; -import { optionToObject } from '../../utils/operation-options'; type Options = { flow: string; diff --git a/api/src/services/flows.ts b/api/src/services/flows.ts index 5c0e749f68..47b8ebdb09 100644 --- a/api/src/services/flows.ts +++ b/api/src/services/flows.ts @@ -35,6 +35,15 @@ export class FlowsService extends ItemsService { return result; } + async updateBatch(data: Partial[], opts?: MutationOptions): Promise { + const flowManager = getFlowManager(); + + const result = await super.updateBatch(data, opts); + await flowManager.reload(); + + return result; + } + async updateMany(keys: PrimaryKey[], data: Partial, opts?: MutationOptions): Promise { const flowManager = getFlowManager(); diff --git a/api/src/services/graphql.ts b/api/src/services/graphql/index.ts similarity index 94% rename from api/src/services/graphql.ts rename to api/src/services/graphql/index.ts index b6b450cf4e..852cfcd459 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql/index.ts @@ -27,6 +27,7 @@ import { GraphQLUnionType, InlineFragmentNode, IntValueNode, + NoSchemaIntrospectionCustomRule, ObjectFieldNode, ObjectValueNode, SelectionNode, @@ -46,71 +47,56 @@ import { import { Knex } from 'knex'; import { flatten, get, isObject, mapKeys, merge, omit, pick, set, transform, uniq } from 'lodash'; import ms from 'ms'; -import { clearSystemCache, getCache } from '../cache'; -import { DEFAULT_AUTH_PROVIDER } from '../constants'; -import getDatabase from '../database'; -import env from '../env'; -import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions'; -import { getExtensionManager } from '../extensions'; -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'; -import { sanitizeQuery } from '../utils/sanitize-query'; -import { validateQuery } from '../utils/validate-query'; -import { ActivityService } from './activity'; -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'; -import { RevisionsService } from './revisions'; -import { RolesService } from './roles'; -import { ServerService } from './server'; -import { SettingsService } from './settings'; -import { SharesService } from './shares'; -import { SpecificationService } from './specifications'; -import { TFAService } from './tfa'; -import { UsersService } from './users'; -import { UtilsService } from './utils'; -import { WebhooksService } from './webhooks'; +import { clearSystemCache, getCache } from '../../cache'; +import { DEFAULT_AUTH_PROVIDER } from '../../constants'; +import getDatabase from '../../database'; +import env from '../../env'; +import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../../exceptions'; +import { getExtensionManager } from '../../extensions'; +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'; +import { sanitizeQuery } from '../../utils/sanitize-query'; +import { validateQuery } from '../../utils/validate-query'; +import { ActivityService } from '../activity'; +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'; +import { RevisionsService } from '../revisions'; +import { RolesService } from '../roles'; +import { ServerService } from '../server'; +import { SettingsService } from '../settings'; +import { SharesService } from '../shares'; +import { SpecificationService } from '../specifications'; +import { TFAService } from '../tfa'; +import { UsersService } from '../users'; +import { UtilsService } from '../utils'; +import { WebhooksService } from '../webhooks'; -const GraphQLVoid = new GraphQLScalarType({ - name: 'Void', +import { GraphQLDate } from './types/date'; +import { GraphQLGeoJSON } from './types/geojson'; +import { GraphQLStringOrFloat } from './types/string-or-float'; +import { GraphQLVoid } from './types/void'; - description: 'Represents NULL values', +import { PrimaryKey } from '@directus/shared/types'; - serialize() { - return null; - }, +import { addPathToValidationError } from './utils/add-path-to-validation-error'; - parseValue() { - return null; - }, +const validationRules = Array.from(specifiedRules); - parseLiteral() { - return null; - }, -}); - -export const GraphQLGeoJSON = new GraphQLScalarType({ - ...GraphQLJSON, - name: 'GraphQLGeoJSON', - description: 'GeoJSON value', -}); - -export const GraphQLDate = new GraphQLScalarType({ - ...GraphQLString, - name: 'Date', - description: 'ISO8601 Date values', -}); +if (env.GRAPHQL_INTROSPECTION === false) { + validationRules.push(NoSchemaIntrospectionCustomRule); +} /** * These should be ignored in the context of GraphQL, and/or are replaced by a custom resolver (for non-standard structures) @@ -122,6 +108,7 @@ const SYSTEM_DENY_LIST = [ 'directus_migrations', 'directus_sessions', ]; + const READ_ONLY = ['directus_activity', 'directus_revisions']; export class GraphQLService { @@ -148,18 +135,9 @@ export class GraphQLService { }: GraphQLParams): Promise { const schema = this.getSchema(); - const validationErrors = validate(schema, document, [ - ...specifiedRules, - (context) => ({ - Field(node) { - if (env.GRAPHQL_INTROSPECTION === false && (node.name.value === '__schema' || node.name.value === '__type')) { - context.reportError( - new GraphQLError('GraphQL introspection is not allowed. The query contained __schema or __type.', [node]) - ); - } - }, - }), - ]); + const validationErrors = validate(schema, document, validationRules).map((validationError) => + addPathToValidationError(validationError) + ); if (validationErrors.length > 0) { throw new GraphQLValidationException({ graphqlErrors: validationErrors }); @@ -312,6 +290,10 @@ export class GraphQLService { `update_${collection.collection}_items` ); + acc[`update_${collectionName}_batch`] = UpdateCollectionTypes[collection.collection].getResolver( + `update_${collection.collection}_batch` + ); + acc[`update_${collectionName}_item`] = UpdateCollectionTypes[collection.collection].getResolver( `update_${collection.collection}_item` ); @@ -653,32 +635,33 @@ export class GraphQLService { }, }); + // Uses StringOrFloat rather than Float to support api dynamic variables (like `$NOW`) const NumberFilterOperators = schemaComposer.createInputTC({ name: 'number_filter_operators', fields: { _eq: { - type: GraphQLFloat, + type: GraphQLStringOrFloat, }, _neq: { - type: GraphQLFloat, + type: GraphQLStringOrFloat, }, _in: { - type: new GraphQLList(GraphQLFloat), + type: new GraphQLList(GraphQLStringOrFloat), }, _nin: { - type: new GraphQLList(GraphQLFloat), + type: new GraphQLList(GraphQLStringOrFloat), }, _gt: { - type: GraphQLFloat, + type: GraphQLStringOrFloat, }, _gte: { - type: GraphQLFloat, + type: GraphQLStringOrFloat, }, _lt: { - type: GraphQLFloat, + type: GraphQLStringOrFloat, }, _lte: { - type: GraphQLFloat, + type: GraphQLStringOrFloat, }, _null: { type: GraphQLBoolean, @@ -1143,6 +1126,27 @@ export class GraphQLService { await self.resolveMutation(args, info), }); } else { + UpdateCollectionTypes[collection.collection].addResolver({ + name: `update_${collection.collection}_batch`, + type: collectionIsReadable + ? new GraphQLNonNull( + new GraphQLList(new GraphQLNonNull(ReadCollectionTypes[collection.collection].getType())) + ) + : GraphQLBoolean, + args: { + ...(collectionIsReadable + ? ReadCollectionTypes[collection.collection].getResolver(collection.collection).getArgs() + : {}), + data: [ + toInputObjectType(UpdateCollectionTypes[collection.collection]).setTypeName( + `update_${collection.collection}_input` + ).NonNull, + ], + }, + resolve: async ({ args, info }: { args: Record; info: GraphQLResolveInfo }) => + await self.resolveMutation(args, info), + }); + UpdateCollectionTypes[collection.collection].addResolver({ name: `update_${collection.collection}_items`, type: collectionIsReadable @@ -1290,12 +1294,15 @@ export class GraphQLService { const query = this.getQuery(args, selections || [], info.variableValues); const singleton = + collection.endsWith('_batch') === false && collection.endsWith('_items') === false && collection.endsWith('_item') === false && collection in this.schema.collections; - const single = collection.endsWith('_items') === false; + const single = collection.endsWith('_items') === false && collection.endsWith('_batch') === false; + const batchUpdate = action === 'update' && collection.endsWith('_batch'); + if (collection.endsWith('_batch')) collection = collection.slice(0, -6); if (collection.endsWith('_items')) collection = collection.slice(0, -6); if (collection.endsWith('_item')) collection = collection.slice(0, -5); @@ -1329,7 +1336,14 @@ export class GraphQLService { } if (action === 'update') { - const keys = await service.updateMany(args.ids, args.data); + const keys: PrimaryKey[] = []; + + if (batchUpdate) { + keys.push(...(await service.updateBatch(args.data))); + } else { + keys.push(...(await service.updateMany(args.ids, args.data))); + } + return hasQuery ? await service.readMany(keys, query) : true; } diff --git a/api/src/services/graphql/types/date.ts b/api/src/services/graphql/types/date.ts new file mode 100644 index 0000000000..b16750697f --- /dev/null +++ b/api/src/services/graphql/types/date.ts @@ -0,0 +1,7 @@ +import { GraphQLString, GraphQLScalarType } from 'graphql'; + +export const GraphQLDate = new GraphQLScalarType({ + ...GraphQLString, + name: 'Date', + description: 'ISO8601 Date values', +}); diff --git a/api/src/services/graphql/types/geojson.ts b/api/src/services/graphql/types/geojson.ts new file mode 100644 index 0000000000..6fff76753f --- /dev/null +++ b/api/src/services/graphql/types/geojson.ts @@ -0,0 +1,8 @@ +import { GraphQLScalarType } from 'graphql'; +import { GraphQLJSON } from 'graphql-compose'; + +export const GraphQLGeoJSON = new GraphQLScalarType({ + ...GraphQLJSON, + name: 'GraphQLGeoJSON', + description: 'GeoJSON value', +}); diff --git a/api/src/services/graphql/types/string-or-float.ts b/api/src/services/graphql/types/string-or-float.ts new file mode 100644 index 0000000000..fc8bd617a0 --- /dev/null +++ b/api/src/services/graphql/types/string-or-float.ts @@ -0,0 +1,35 @@ +import { GraphQLScalarType, Kind } from 'graphql'; + +/** + * Adopted from https://kamranicus.com/handling-multiple-scalar-types-in-graphql/ + */ + +export const GraphQLStringOrFloat = new GraphQLScalarType({ + name: 'GraphQLStringOrFloat', + description: 'A Float or a String', + serialize(value) { + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error('Value must be either a String or a Float'); + } + + return value; + }, + parseValue(value) { + if (typeof value !== 'string' && typeof value !== 'number') { + throw new Error('Value must be either a String or a Float'); + } + + return value; + }, + parseLiteral(ast) { + switch (ast.kind) { + case Kind.INT: + case Kind.FLOAT: + return Number(ast.value); + case Kind.STRING: + return ast.value; + default: + throw new Error('Value must be either a String or a Float'); + } + }, +}); diff --git a/api/src/services/graphql/types/void.ts b/api/src/services/graphql/types/void.ts new file mode 100644 index 0000000000..35d4323e4a --- /dev/null +++ b/api/src/services/graphql/types/void.ts @@ -0,0 +1,19 @@ +import { GraphQLScalarType } from 'graphql'; + +export const GraphQLVoid = new GraphQLScalarType({ + name: 'Void', + + description: 'Represents NULL values', + + serialize() { + return null; + }, + + parseValue() { + return null; + }, + + parseLiteral() { + return null; + }, +}); diff --git a/api/src/services/graphql/utils/add-path-to-validation-error.ts b/api/src/services/graphql/utils/add-path-to-validation-error.ts new file mode 100644 index 0000000000..59b716f020 --- /dev/null +++ b/api/src/services/graphql/utils/add-path-to-validation-error.ts @@ -0,0 +1,21 @@ +import { GraphQLError, Token, locatedError } from 'graphql'; + +export function addPathToValidationError(validationError: GraphQLError): GraphQLError { + const token = validationError.nodes?.[0]?.loc?.startToken; + + if (!token) return validationError; + + let prev: Token | null = token; + + const queryRegex = /query_[A-Za-z0-9]{8}/; + + while (prev) { + if (prev.kind === 'Name' && prev.value && queryRegex.test(prev.value)) { + return locatedError(validationError, validationError.nodes, [prev.value]); + } + + prev = prev.prev; + } + + return locatedError(validationError, validationError.nodes); +} diff --git a/api/src/services/import-export.ts b/api/src/services/import-export.ts index 0841e42a98..fca52ee175 100644 --- a/api/src/services/import-export.ts +++ b/api/src/services/import-export.ts @@ -1,5 +1,5 @@ import { Accountability, Query, SchemaOverview } from '@directus/shared/types'; -import { toArray } from '@directus/shared/utils'; +import { parseJSON, toArray } from '@directus/shared/utils'; import { queue } from 'async'; import csv from 'csv-parser'; import destroyStream from 'destroy'; @@ -22,7 +22,6 @@ import { import logger from '../logger'; import { AbstractServiceOptions, File } from '../types'; import { getDateFormatted } from '../utils/get-date-formatted'; -import { parseJSON } from '../utils/parse-json'; import { FilesService } from './files'; import { ItemsService } from './items'; import { NotificationsService } from './notifications'; diff --git a/api/src/services/items.ts b/api/src/services/items.ts index 6d24fede3d..691f3f5220 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -1,20 +1,20 @@ 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'; +import { assign, clone, cloneDeep, omit, pick, without } from 'lodash'; import { getCache } from '../cache'; import getDatabase from '../database'; import runAST from '../database/run-ast'; import emitter from '../emitter'; import env from '../env'; -import { ForbiddenException } from '../exceptions'; +import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import { translateDatabaseError } from '../exceptions/database/translate'; import { AbstractService, AbstractServiceOptions, Item as AnyItem, MutationOptions, PrimaryKey } from '../types'; import getASTFromQuery from '../utils/get-ast-from-query'; +import { validateKeys } from '../utils/validate-keys'; import { AuthorizationService } from './authorization'; import { ActivityService, RevisionsService } from './index'; import { PayloadService } from './payload'; -import { validateKeys } from '../utils/validate-keys'; export type QueryOptions = { stripNonRequested?: boolean; @@ -371,7 +371,31 @@ export class ItemsService implements AbstractSer } /** - * Update many items by primary key + * Update multiple items in a single transaction + */ + async updateBatch(data: Partial[], opts?: MutationOptions): Promise { + const primaryKeyField = this.schema.collections[this.collection].primary; + + const keys: PrimaryKey[] = []; + + await this.knex.transaction(async (trx) => { + const service = new ItemsService(this.collection, { + accountability: this.accountability, + knex: trx, + schema: this.schema, + }); + + for (const item of data) { + if (!item[primaryKeyField]) throw new InvalidPayloadException(`Item in update misses primary key.`); + keys.push(await service.updateOne(item[primaryKeyField]!, omit(item, primaryKeyField), opts)); + } + }); + + return keys; + } + + /** + * Update many items by primary key, setting all items to the same change */ async updateMany(keys: PrimaryKey[], data: Partial, opts?: MutationOptions): Promise { const primaryKeyField = this.schema.collections[this.collection].primary; diff --git a/api/src/services/operations.ts b/api/src/services/operations.ts index deb85b646f..5187a42896 100644 --- a/api/src/services/operations.ts +++ b/api/src/services/operations.ts @@ -35,6 +35,15 @@ export class OperationsService extends ItemsService { return result; } + async updateBatch(data: Partial[], opts?: MutationOptions): Promise { + const flowManager = getFlowManager(); + + const result = await super.updateBatch(data, opts); + await flowManager.reload(); + + return result; + } + async updateMany(keys: PrimaryKey[], data: Partial, opts?: MutationOptions): Promise { const flowManager = getFlowManager(); diff --git a/api/src/services/payload.ts b/api/src/services/payload.ts index 70b8be7cca..f018c9907e 100644 --- a/api/src/services/payload.ts +++ b/api/src/services/payload.ts @@ -1,5 +1,5 @@ import { Accountability, Query, SchemaOverview } from '@directus/shared/types'; -import { toArray } from '@directus/shared/utils'; +import { parseJSON, toArray } from '@directus/shared/utils'; import { format } from 'date-fns'; import { unflatten } from 'flat'; import Joi from 'joi'; @@ -12,7 +12,6 @@ import { getHelpers, Helpers } from '../database/helpers'; import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import { AbstractServiceOptions, Alterations, Item, PrimaryKey } from '../types'; import { generateHash } from '../utils/generate-hash'; -import { parseJSON } from '../utils/parse-json'; import { ItemsService } from './items'; type Action = 'create' | 'read' | 'update'; diff --git a/api/src/services/permissions.ts b/api/src/services/permissions.ts index fa283fde28..a2f2385e99 100644 --- a/api/src/services/permissions.ts +++ b/api/src/services/permissions.ts @@ -102,6 +102,12 @@ export class PermissionsService extends ItemsService { return res; } + async updateBatch(data: Partial[], opts?: MutationOptions) { + const res = await super.updateBatch(data, opts); + await clearSystemCache(); + return res; + } + async updateMany(keys: PrimaryKey[], data: Partial, opts?: MutationOptions) { const res = await super.updateMany(keys, data, opts); await clearSystemCache(); diff --git a/api/src/services/roles.ts b/api/src/services/roles.ts index 56a486ad19..b0ecb7b207 100644 --- a/api/src/services/roles.ts +++ b/api/src/services/roles.ts @@ -79,6 +79,19 @@ export class RolesService extends ItemsService { return super.updateOne(key, data, opts); } + async updateBatch(data: Record[], opts?: MutationOptions): Promise { + const primaryKeyField = this.schema.collections[this.collection].primary; + + const keys = data.map((item) => item[primaryKeyField]); + const setsToNoAdmin = data.some((item) => item.admin_access === false); + + if (setsToNoAdmin) { + await this.checkForOtherAdminRoles(keys); + } + + return super.updateBatch(data, opts); + } + async updateMany(keys: PrimaryKey[], data: Record, opts?: MutationOptions): Promise { if ('admin_access' in data && data.admin_access === false) { await this.checkForOtherAdminRoles(keys); diff --git a/api/src/services/users.ts b/api/src/services/users.ts index 7afddf879f..1ea415a46e 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -182,6 +182,27 @@ export class UsersService extends ItemsService { return key; } + async updateBatch(data: Partial[], opts?: MutationOptions): Promise { + const primaryKeyField = this.schema.collections[this.collection].primary; + + const keys: PrimaryKey[] = []; + + await this.knex.transaction(async (trx) => { + const service = new UsersService({ + accountability: this.accountability, + knex: trx, + schema: this.schema, + }); + + for (const item of data) { + if (!item[primaryKeyField]) throw new InvalidPayloadException(`User in update misses primary key.`); + keys.push(await service.updateOne(item[primaryKeyField]!, item, opts)); + } + }); + + return keys; + } + /** * Update many users by primary key */ diff --git a/api/src/utils/get-default-value.ts b/api/src/utils/get-default-value.ts index 163e8faa4d..38fd8f59dd 100644 --- a/api/src/utils/get-default-value.ts +++ b/api/src/utils/get-default-value.ts @@ -1,9 +1,9 @@ import { SchemaOverview } from '@directus/schema/dist/types/overview'; +import { parseJSON } from '@directus/shared/utils'; import { Column } from 'knex-schema-inspector/dist/types/column'; import env from '../env'; import logger from '../logger'; import getLocalType from './get-local-type'; -import { parseJSON } from './parse-json'; export default function getDefaultValue( column: SchemaOverview[string]['columns'][string] | Column diff --git a/api/src/utils/get-graphql-type.ts b/api/src/utils/get-graphql-type.ts index 707a1519fa..8b99c040b0 100644 --- a/api/src/utils/get-graphql-type.ts +++ b/api/src/utils/get-graphql-type.ts @@ -8,7 +8,8 @@ import { GraphQLType, } from 'graphql'; import { GraphQLJSON } from 'graphql-compose'; -import { GraphQLDate, GraphQLGeoJSON } from '../services/graphql'; +import { GraphQLDate } from '../services/graphql/types/date'; +import { GraphQLGeoJSON } from '../services/graphql/types/geojson'; import { Type } from '@directus/shared/types'; export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLScalarType | GraphQLList { diff --git a/api/src/utils/get-permissions.ts b/api/src/utils/get-permissions.ts index de60930522..5be90eb1ac 100644 --- a/api/src/utils/get-permissions.ts +++ b/api/src/utils/get-permissions.ts @@ -1,5 +1,5 @@ import { Accountability, Permission, SchemaOverview } from '@directus/shared/types'; -import { deepMap, parseFilter, parsePreset } from '@directus/shared/utils'; +import { deepMap, parseFilter, parseJSON, parsePreset } from '@directus/shared/utils'; import { cloneDeep } from 'lodash'; import hash from 'object-hash'; import { getCache, setSystemCache } from '../cache'; @@ -10,7 +10,6 @@ import { RolesService } from '../services/roles'; import { UsersService } from '../services/users'; import { mergePermissions } from '../utils/merge-permissions'; import { mergePermissionsForShare } from './merge-permissions-for-share'; -import { parseJSON } from './parse-json'; export async function getPermissions(accountability: Accountability, schema: SchemaOverview) { const database = getDatabase(); diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index fa1580e6ff..bfb78acb8b 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -1,6 +1,6 @@ import SchemaInspector from '@directus/schema'; import { Accountability, Filter, SchemaOverview } from '@directus/shared/types'; -import { toArray } from '@directus/shared/utils'; +import { parseJSON, toArray } from '@directus/shared/utils'; import { Knex } from 'knex'; import { mapValues } from 'lodash'; import { getCache, setSystemCache } from '../cache'; @@ -13,7 +13,6 @@ import logger from '../logger'; import { RelationsService } from '../services'; import getDefaultValue from './get-default-value'; import getLocalType from './get-local-type'; -import { parseJSON } from './parse-json'; export async function getSchema(options?: { accountability?: Accountability; diff --git a/api/src/utils/operation-options.ts b/api/src/utils/operation-options.ts deleted file mode 100644 index 0164321c2d..0000000000 --- a/api/src/utils/operation-options.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { renderFn, get, Scope } from 'micromustache'; -import { parseJSON } from './parse-json'; - -type Mustacheable = string | number | boolean | null | Mustacheable[] | { [key: string]: Mustacheable }; -type GenericString = 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(item: T, scope: Scope): GenericString { - if (typeof item === 'string') { - return renderFn(item, resolveFn, scope, { explicit: true }) as GenericString; - } else if (Array.isArray(item)) { - return item.map((element) => renderMustache(element, scope)) as GenericString; - } else if (typeof item === 'object' && item !== null) { - return Object.fromEntries( - Object.entries(item).map(([key, value]) => [key, renderMustache(value, scope)]) - ) as GenericString; - } else { - return item as GenericString; - } -} - -export function applyOperationOptions(options: Record, data: Record): Record { - 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(option: T): Exclude { - return typeof option === 'string' ? parseJSON(option) : option; -} - -export function optionToString(option: unknown): string { - return typeof option === 'object' ? JSON.stringify(option) : String(option); -} diff --git a/api/src/utils/sanitize-query.ts b/api/src/utils/sanitize-query.ts index db063f3183..98ef0f911c 100644 --- a/api/src/utils/sanitize-query.ts +++ b/api/src/utils/sanitize-query.ts @@ -1,9 +1,8 @@ import { Accountability, Aggregate, Filter, Query } from '@directus/shared/types'; -import { parseFilter } from '@directus/shared/utils'; +import { parseFilter, parseJSON } from '@directus/shared/utils'; import { flatten, get, isPlainObject, merge, set } from 'lodash'; import logger from '../logger'; import { Meta } from '../types'; -import { parseJSON } from './parse-json'; export function sanitizeQuery(rawQuery: Record, accountability?: Accountability | null): Query { const query: Query = {}; diff --git a/app/jest.config.js b/app/jest.config.js index d59a9936d4..9cc18d9f67 100644 --- a/app/jest.config.js +++ b/app/jest.config.js @@ -1,5 +1,14 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ +const base = require('../jest.config.js'); + +require('dotenv').config(); + module.exports = { + ...base, preset: 'ts-jest', testEnvironment: 'node', + moduleNameMapper: { + ...base.moduleNameMapper, + '@/(.*)': `${__dirname}/src/$1`, + }, }; diff --git a/app/package.json b/app/package.json index f6b53f2255..851967d228 100644 --- a/app/package.json +++ b/app/package.json @@ -89,6 +89,7 @@ "front-matter": "4.0.2", "html-entities": "2.3.2", "jest": "27.4.7", + "json-to-graphql-query": "^2.2.4", "json2csv": "^5.0.7", "jsonlint-mod": "1.7.6", "maplibre-gl": "1.15.2", diff --git a/app/src/components/v-workspace-tile.vue b/app/src/components/v-workspace-tile.vue index c1f8273678..ac5419b697 100644 --- a/app/src/components/v-workspace-tile.vue +++ b/app/src/components/v-workspace-tile.vue @@ -103,6 +103,7 @@ export type AppTile = { minHeight?: number; draggable?: boolean; borderRadius?: [boolean, boolean, boolean, boolean]; + data?: Record; }; // Right now, it is not possible to do type Props = AppTile & {resizable?: boolean; editMode?: boolean} diff --git a/app/src/components/v-workspace.vue b/app/src/components/v-workspace.vue index 2a6191f2b8..08f7568c86 100644 --- a/app/src/components/v-workspace.vue +++ b/app/src/components/v-workspace.vue @@ -12,26 +12,26 @@ height: workspaceSize.height + 'px', }" > - - diff --git a/app/src/modules/insights/routes/panel-configuration.vue b/app/src/modules/insights/routes/panel-configuration.vue index 91818359fe..a2f1ead0a1 100644 --- a/app/src/modules/insights/routes/panel-configuration.vue +++ b/app/src/modules/insights/routes/panel-configuration.vue @@ -5,10 +5,10 @@ :subtitle="t('panel_options')" :icon="panel?.icon || 'insert_chart'" persistent - @cancel="$emit('cancel')" + @cancel="router.push(`/insights/${dashboardKey}`)" > @@ -16,14 +16,20 @@

{{ t('type') }}

- + @@ -34,24 +40,30 @@

{{ t('visible') }}

- +

{{ t('name') }}

{{ t('icon') }}

@@ -59,8 +71,8 @@

{{ t('color') }}

@@ -69,9 +81,10 @@

{{ t('note') }}

@@ -79,101 +92,103 @@ - diff --git a/app/src/panels/list/panel-list.vue b/app/src/panels/list/panel-list.vue new file mode 100644 index 0000000000..f00172cd05 --- /dev/null +++ b/app/src/panels/list/panel-list.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/app/src/panels/metric/index.ts b/app/src/panels/metric/index.ts index d532dddcee..7233b9678d 100644 --- a/app/src/panels/metric/index.ts +++ b/app/src/panels/metric/index.ts @@ -1,7 +1,8 @@ -import { computed } from 'vue'; import { useFieldsStore } from '@/stores'; +import { PanelQuery } from '@directus/shared/types'; import { definePanel } from '@directus/shared/utils'; -import PanelMetric from './metric.vue'; +import { computed } from 'vue'; +import PanelMetric from './panel-metric.vue'; export default definePanel({ id: 'metric', @@ -9,6 +10,38 @@ export default definePanel({ description: '$t:panels.metric.description', icon: 'functions', component: PanelMetric, + query(options) { + if (!options || !options.function) return; + const isRawValue = ['first', 'last'].includes(options.function); + + const sort = options.sortField && `${options.function === 'last' ? '-' : ''}${options.sortField}`; + + const aggregate = isRawValue + ? undefined + : { + [options.function]: [options.field || '*'], + }; + + const panelQuery: PanelQuery = { + collection: options.collection, + query: { + sort, + limit: 1, + fields: [options.field], + }, + }; + + if (options.filter && Object.keys(options.filter).length > 0) { + panelQuery.query.filter = options.filter; + } + + if (aggregate) { + panelQuery.query.aggregate = aggregate; + delete panelQuery.query.fields; + } + + return panelQuery; + }, options: ({ options }) => { const fieldsStore = useFieldsStore(); @@ -18,7 +51,7 @@ export default definePanel({ : null; }); - const supportsAggregate = computed(() => + const fieldIsNumber = computed(() => fieldType.value ? ['integer', 'bigInteger', 'float', 'decimal'].includes(fieldType.value) : false ); @@ -82,37 +115,37 @@ export default definePanel({ { text: 'Count (Distinct)', value: 'countDistinct', - disabled: !supportsAggregate.value, + disabled: !fieldIsNumber.value, }, { text: 'Average', value: 'avg', - disabled: !supportsAggregate.value, + disabled: !fieldIsNumber.value, }, { text: 'Average (Distinct)', value: 'avgDistinct', - disabled: !supportsAggregate.value, + disabled: !fieldIsNumber.value, }, { text: 'Sum', value: 'sum', - disabled: !supportsAggregate.value, + disabled: !fieldIsNumber.value, }, { text: 'Sum (Distinct)', value: 'sumDistinct', - disabled: !supportsAggregate.value, + disabled: !fieldIsNumber.value, }, { text: 'Minimum', value: 'min', - disabled: !supportsAggregate.value, + disabled: !fieldIsNumber.value, }, { text: 'Maximum', value: 'max', - disabled: !supportsAggregate.value, + disabled: !fieldIsNumber.value, }, ], }, @@ -240,18 +273,22 @@ export default definePanel({ { text: '$t:operators.gt', value: '>', + disabled: !fieldIsNumber.value, }, { text: '$t:operators.gte', value: '>=', + disabled: !fieldIsNumber.value, }, { text: '$t:operators.lt', value: '<', + disabled: !fieldIsNumber.value, }, { text: '$t:operators.lte', value: '<=', + disabled: !fieldIsNumber.value, }, ], }, @@ -261,7 +298,7 @@ export default definePanel({ { field: 'value', name: '$t:value', - type: 'integer', + type: 'string', schema: { default_value: 0, }, diff --git a/app/src/panels/metric/metric.vue b/app/src/panels/metric/metric.vue deleted file mode 100644 index 836d5bdf1e..0000000000 --- a/app/src/panels/metric/metric.vue +++ /dev/null @@ -1,198 +0,0 @@ - - - - - diff --git a/app/src/panels/metric/panel-metric.vue b/app/src/panels/metric/panel-metric.vue new file mode 100644 index 0000000000..21dda55484 --- /dev/null +++ b/app/src/panels/metric/panel-metric.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/app/src/panels/time-series/index.ts b/app/src/panels/time-series/index.ts index 430626e986..fd25b5232b 100644 --- a/app/src/panels/time-series/index.ts +++ b/app/src/panels/time-series/index.ts @@ -1,11 +1,43 @@ +import { getGroups } from '@/utils/get-groups'; import { definePanel } from '@directus/shared/utils'; -import PanelTimeSeries from './time-series.vue'; +import PanelTimeSeries from './panel-time-series.vue'; export default definePanel({ id: 'time-series', name: '$t:panels.time_series.name', description: '$t:panels.time_series.description', icon: 'show_chart', + query(options) { + if (!options?.function || !options.valueField || !options.dateField) { + return; + } + + return { + collection: options.collection, + query: { + group: getGroups(options.precision, options.dateField), + aggregate: { + [options.function]: [options.valueField], + }, + filter: { + _and: [ + { + [options.dateField]: { + _gte: `$NOW(-${options.range || '1 week'})`, + }, + }, + { + [options.dateField]: { + _lte: `$NOW`, + }, + }, + options.filter || {}, + ], + }, + limit: -1, + }, + }; + }, component: PanelTimeSeries, options: [ { diff --git a/app/src/panels/time-series/panel-time-series.vue b/app/src/panels/time-series/panel-time-series.vue new file mode 100644 index 0000000000..b363bac4b9 --- /dev/null +++ b/app/src/panels/time-series/panel-time-series.vue @@ -0,0 +1,350 @@ + + + + + + + diff --git a/app/src/panels/time-series/time-series.vue b/app/src/panels/time-series/time-series.vue deleted file mode 100644 index ef663d3c46..0000000000 --- a/app/src/panels/time-series/time-series.vue +++ /dev/null @@ -1,450 +0,0 @@ - - - - - - - diff --git a/app/src/panels/variable/index.ts b/app/src/panels/variable/index.ts new file mode 100644 index 0000000000..0f94ebc015 --- /dev/null +++ b/app/src/panels/variable/index.ts @@ -0,0 +1,87 @@ +import { definePanel } from '@directus/shared/utils'; +import PanelVariable from './panel-variable.vue'; +import { useI18n } from 'vue-i18n'; +import { FIELD_TYPES_SELECT } from '@/constants'; +import { translate } from '@/utils/translate-object-values'; +import { getDefaultInterfaceForType } from '@/utils/get-default-interface-for-type'; + +export default definePanel({ + id: 'variable', + name: '$t:panels.variable.name', + description: '$t:panels.variable.description', + icon: 'science', + component: PanelVariable, + variable: true, + options: (panel) => { + const { t } = useI18n(); + + return [ + { + name: t('panels.variable.variable_key'), + field: 'field', + type: 'string', + meta: { + interface: 'input', + width: 'full', + options: { + dbSafe: true, + font: 'monospace', + placeholder: t('interfaces.list.field_name_placeholder'), + }, + }, + schema: null, + }, + { + name: t('type'), + field: 'type', + type: 'string', + meta: { + interface: 'select-dropdown', + width: 'half', + options: { + choices: translate(FIELD_TYPES_SELECT), + }, + }, + schema: null, + }, + { + name: t('default_value'), + field: 'defaultValue', + type: panel.options?.type, + meta: { + interface: panel.options?.type ? getDefaultInterfaceForType(panel.options.type) : 'input', + readonly: !panel.options?.type, + width: 'half', + }, + schema: {}, + }, + { + name: t('interface_label'), + field: 'inter', + type: 'string', + meta: { + interface: 'system-interface', + width: 'half', + options: { + typeField: 'type', + }, + }, + schema: null, + }, + { + name: t('options'), + field: 'options', + type: 'string', + meta: { + interface: 'system-interface-options', + width: 'full', + options: { + interfaceField: 'inter', + }, + }, + }, + ]; + }, + minWidth: 12, + minHeight: 6, +}); diff --git a/app/src/panels/variable/panel-variable.vue b/app/src/panels/variable/panel-variable.vue new file mode 100644 index 0000000000..d41be2f9c6 --- /dev/null +++ b/app/src/panels/variable/panel-variable.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/app/src/stores/insights.ts b/app/src/stores/insights.ts index 988826df60..d07fd08e04 100644 --- a/app/src/stores/insights.ts +++ b/app/src/stores/insights.ts @@ -1,34 +1,471 @@ -import { Dashboard } from '../types'; import api from '@/api'; -import { defineStore } from 'pinia'; -import { useUserStore, usePermissionsStore } from '@/stores'; +import { getPanels } from '@/panels'; +import { usePermissionsStore } from '@/stores'; +import { queryToGqlString } from '@/utils/query-to-gql-string'; +import { unexpectedError } from '@/utils/unexpected-error'; +import { Item, Panel } from '@directus/shared/types'; +import { getSimpleHash, toArray, applyOptionsData } from '@directus/shared/utils'; +import { AxiosResponse } from 'axios'; +import { assign, clone, get, isUndefined, mapKeys, omit, omitBy, pull, uniq } from 'lodash'; +import { nanoid } from 'nanoid'; +import { acceptHMRUpdate, defineStore } from 'pinia'; +import { computed, reactive, ref, unref } from 'vue'; +import { Dashboard } from '../types'; +import escapeStringRegexp from 'escape-string-regexp'; -export const useInsightsStore = defineStore({ - id: 'insightsStore', - state: () => ({ - dashboards: [] as Dashboard[], - }), - actions: { - async hydrate() { - const userStore = useUserStore(); - const permissionsStore = usePermissionsStore(); +export type CreatePanel = Partial & + Pick; - if (userStore.isAdmin !== true && !permissionsStore.hasPermission('directus_dashboards', 'read')) { - this.dashboards = []; - } else { - try { - const response = await api.get('/dashboards', { - params: { limit: -1, fields: ['*', 'panels.*'], sort: ['name'] }, - }); +const MAX_CACHE_SIZE = 3; // Max number of dashboards to keep in cache at a time - this.dashboards = response.data.data; - } catch { - this.dashboards = []; +export const useInsightsStore = defineStore('insightsStore', () => { + /** All available dashboards in the platform */ + const dashboards = ref([]); + + /** All available panels */ + const panels = ref([]); + + /** Panels that are currently loading */ + const loading = ref([]); + + /** Panels that errored while fetching data */ + const errors = ref<{ [id: string]: Error }>({}); + + /** Cache/store for the panel data */ + const data = ref<{ [panel: string]: Item | Item[] }>({}); + + /** Runtime filter values */ + const variables = ref<{ [field: string]: any }>({}); + + /** Staged edits */ + const edits = reactive<{ create: CreatePanel[]; update: Partial[]; delete: string[] }>({ + create: [], + update: [], + delete: [], + }); + + const refreshIntervals = {} as { [dashboard: string]: number }; + + const saving = ref(false); + + /** Last MAX_CACHE_SIZE dashboards that we've loaded into data. Used to purge caches once too much data is loaded */ + const lastLoaded: string[] = []; + + /** If there's any unsaved staged changes */ + const hasEdits = computed(() => edits.create.length > 0 || edits.update.length > 0 || edits.delete.length > 0); + + /** Raw panels modified to assign the edits */ + const panelsWithEdits = computed(() => { + return [ + ...unref(panels) + .filter((panel) => edits.delete.includes(panel.id) === false) + .map((panel) => { + const updates = edits.update.find((updated) => updated.id === panel.id); + + if (updates) { + return assign({}, panel, updates); + } + + return panel; + }), + ...edits.create, + ]; + }); + + const { panels: panelTypes } = getPanels(); + + return { + dashboards, + panels: panelsWithEdits, + loading, + errors, + data, + hasEdits, + saving, + edits, + variables, + hydrate, + dehydrate, + clearCache, + clearEdits, + getDashboard, + getPanelsForDashboard, + refresh, + stagePanelCreate, + stagePanelUpdate, + stagePanelDuplicate, + stagePanelDelete, + saveChanges, + refreshIntervals, + getVariable, + setVariable, + }; + + async function hydrate() { + const permissionsStore = usePermissionsStore(); + + if ( + permissionsStore.hasPermission('directus_dashboards', 'read') && + permissionsStore.hasPermission('directus_panels', 'read') + ) { + try { + const [dashboardsResponse, panelsResponse] = await Promise.all([ + api.get('/dashboards', { + params: { limit: -1, fields: ['*'], sort: ['name'] }, + }), + api.get('/panels', { params: { limit: -1, fields: ['*'], sort: ['dashboard'] } }), + ]); + dashboards.value = dashboardsResponse.data.data; + panels.value = panelsResponse.data.data; + } catch { + dashboards.value = []; + panels.value = []; + } + } + + const variableDefaults: Record = {}; + + panels.value.forEach((panel) => { + const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type); + + if (panelType?.variable === true && panel.options?.field) { + variableDefaults[panel.options.field] = panel.options?.defaultValue; + } + }); + + variables.value = variableDefaults; + } + + function dehydrate() { + dashboards.value = []; + panels.value = []; + + clearCache(); + clearEdits(); + } + + function clearCache() { + loading.value = []; + errors.value = {}; + data.value = {}; + } + + function clearEdits() { + edits.create = []; + edits.update = []; + edits.delete = []; + } + + function getDashboard(id: string) { + return unref(dashboards).find((dashboard) => dashboard.id === id); + } + + function getPanelsForDashboard(dashboard: string) { + return unref(panelsWithEdits).filter((panel) => panel.dashboard === dashboard); + } + + async function refresh(dashboard: string) { + const panelsForDashboard = unref(panels).filter((panel) => panel.dashboard === dashboard); + + await loadPanelData(panelsForDashboard); + + if (lastLoaded.includes(dashboard) === false) { + lastLoaded.push(dashboard); + + if (lastLoaded.length > MAX_CACHE_SIZE) { + const removed = lastLoaded.shift(); + const removedPanels = unref(panels) + .filter((panel) => panel.dashboard === removed) + .map(({ id }) => id); + data.value = omit(data.value, ...removedPanels); + } + } + } + + async function loadPanelData( + panels: Pick | Pick[] + ) { + panels = toArray(panels); + + const queries = new Map(); + + for (const panel of panels) { + const req = prepareQuery(panel); + + if (!req) continue; + + toArray(req).forEach(({ collection, query }, index) => { + const key = getSimpleHash(panel.id + collection + JSON.stringify(query)); + queries.set(key, { panel: panel.id, collection, query, key, index, length: toArray(req).length }); + }); + } + + loading.value = uniq([...loading.value, ...Array.from(queries.values()).map(({ panel }) => panel)]); + + const gqlString = queryToGqlString( + Array.from(queries.values()) + .filter(({ collection }) => { + return collection.startsWith('directus_') === false; + }) + .map(({ key, ...rest }) => ({ key: `query_${key}`, ...rest })) + ); + + const systemGqlString = queryToGqlString( + Array.from(queries.values()) + .filter(({ collection }) => { + return collection.startsWith('directus_') === true; + }) + .map(({ key, collection, ...rest }) => ({ + key: `query_${key}`, + collection: collection.substring(9), + ...rest, + })) + ); + + try { + const requests: Promise>[] = []; + + if (gqlString) requests.push(api.post(`/graphql`, { query: gqlString })); + if (systemGqlString) requests.push(api.post(`/graphql/system`, { query: systemGqlString })); + + const responses = await Promise.all(requests); + + const results: { [panel: string]: Item | Item[] } = {}; + + for (const { data } of responses) { + const result = mapKeys(data.data, (_, key) => key.substring('query_'.length)); + + for (const [key, data] of Object.entries(result)) { + const { panel, length } = queries.get(key); + if (length === 1) results[panel] = data; + else if (!results[panel]) results[panel] = [data]; + else results[panel].push(data); + } + + if (Array.isArray(data.errors)) { + setErrorsFromResponseData(data.errors); } } - }, - async dehydrate() { - this.$reset(); - }, - }, + + data.value = assign({}, data.value, results); + + const succeededPanels = Object.keys(results); + errors.value = omit(errors.value, ...succeededPanels); + } catch (err: any) { + /** + * A thrown error means the request failed completely. This can happen for a wide variety + * of reasons, but there's one common one we need to account for: misconfigured panels. A + * GraphQL validation error will throw a 400 rather than a 200+partial data, so we need to + * retry the request without the failed panels */ + + if (err.response.status === 400 && Array.isArray(err.response.data?.errors)) { + const failedIDs = setErrorsFromResponseData(err.response.data.errors); + + const panelsToTryAgain = panels.filter(({ id }) => failedIDs.includes(id) === false); + + // Make sure we don't end in an infinite loop of retries + if (panels.length !== panelsToTryAgain.length) { + await loadPanelData(panelsToTryAgain); + } else { + unexpectedError(err); + } + } else { + unexpectedError(err); + } + } finally { + loading.value = pull(unref(loading), ...Array.from(queries.values()).map(({ panel }) => panel)); + } + + /** + * Set the error objects based on the gqlError paths, returns the panel IDs of the panels that failed + */ + function setErrorsFromResponseData(responseErrors: any[]): string[] { + const failedIDs: string[] = []; + + for (const gqlError of responseErrors) { + const queryKey = gqlError?.extensions?.graphqlErrors?.[0]?.path?.[0]; + if (!queryKey) continue; + const panelID = queries.get(queryKey.substring('query_'.length))?.panel; + if (!panelID) continue; + failedIDs.push(panelID); + errors.value = assign({}, errors.value, { [panelID]: gqlError }); + } + + return failedIDs; + } + } + + function prepareQuery(panel: Pick) { + const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type); + return ( + panelType?.query?.(applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys)) ?? null + ); + } + + function stagePanelCreate(panel: CreatePanel) { + edits.create.push(panel); + loadPanelData(panel); + } + + function stagePanelUpdate({ id, edits: panelEdits }: { id: string; edits: Partial }) { + panelEdits = omitBy(panelEdits, isUndefined); + + const isNew = id.startsWith('_'); + const arr = isNew ? edits.create : edits.update; + + /** + * Check what the currently used data query is, so we can compare it to the new query later to + * decide whether or not to reload the data + */ + let oldQuery; + if ('options' in panelEdits) { + // Edits not yet applied + const panel = unref(panelsWithEdits).find((panel) => panel.id === id); + + if (panel) { + const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type)!; + oldQuery = panelType.query?.( + applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys) + ); + } + } + + if (arr.map(({ id }) => id).includes(id)) { + const updatedArr = arr.map((currentEdit) => { + if (currentEdit.id === id) { + return assign({}, currentEdit, panelEdits); + } + + return currentEdit; + }); + + if (isNew) edits.create = updatedArr as CreatePanel[]; + else edits.update = updatedArr; + } else { + arr.push({ id, ...panelEdits }); + } + + // Reload data for panel if the query has changed + if ('options' in panelEdits) { + // This panel has the edits applied + const panel = unref(panelsWithEdits).find((panel) => panel.id === id)!; + const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type)!; + const newQuery = panelType.query?.( + applyOptionsData(panelEdits.options ?? {}, unref(variables), panelType.skipUndefinedKeys) + ); + if (JSON.stringify(oldQuery) !== JSON.stringify(newQuery)) loadPanelData(panel); + } + } + + function stagePanelDuplicate(panelKey: string, overrides?: Partial) { + const panel = unref(panelsWithEdits).find((panel) => panel.id === panelKey); + if (!panel) return; + + const newPanel = clone(panel); + + newPanel.id = `_${nanoid()}`; + newPanel.position_x = (newPanel.position_x ?? 0) + 2; + newPanel.position_y = (newPanel.position_y ?? 0) + 2; + + // In case width/height is totally unknown (which it shouldn't be) fallback to 4x4 as a last-resort + newPanel.width ??= 4; + newPanel.height ??= 4; + + if (overrides) { + assign(newPanel, overrides); + } + + stagePanelCreate(newPanel as CreatePanel); + } + + function stagePanelDelete(panelKey: string) { + if (edits.create.some((created) => created.id === panelKey)) { + edits.create = edits.create.filter((created) => created.id !== panelKey); + return; + } + + edits.update = edits.update.filter((updated) => updated.id !== panelKey); + edits.delete.push(panelKey); + data.value = omit(data.value, panelKey); + } + + async function saveChanges() { + saving.value = true; + + try { + const requests: Promise>[] = []; + + if (edits.create) { + // Created edits might come with a temporary ID for editing. Make sure to submit to API without temp ID + requests.push( + api.post( + `/panels`, + edits.create.map((create) => omit(create, 'id')) + ) + ); + } + + if (edits.update) { + requests.push(api.patch(`/panels`, edits.update)); + } + + if (edits.delete) { + requests.push(api.delete(`/panels`, { data: edits.delete })); + } + + await Promise.all(requests); + await hydrate(); + + // Remove cached data for the newly created panels + data.value = omit(data.value, ...edits.create.map(({ id }) => id)); + + // Fetch data for panels that now exist in the dashboard (from create) but haven't been fetched yet + const panelsToLoad = unref(panelsWithEdits).filter(({ id }) => id in unref(data) === false); + loadPanelData(panelsToLoad); + + clearEdits(); + } catch (err: any) { + unexpectedError(err); + } finally { + saving.value = false; + } + } + + function getVariable(field: string) { + return get(unref(variables), field); + } + + function setVariable(field: string, value: unknown) { + const newVariables = assign({}, variables.value, { [field]: value }); + + // Find all panels that are using this variable in their options + const regex = new RegExp(`{{\\s*?${escapeStringRegexp(field)}\\s*?}}`); + const needReload = unref(panelsWithEdits).filter((panel) => { + if (panel.id in unref(data) === false) return false; + + const optionsString = JSON.stringify(panel.options ?? {}); + const containsVariable = regex.test(optionsString); + if (!containsVariable) return false; + + const panelType = unref(panelTypes).find((panelType) => panelType.id === panel.type); + if (!panelType) return false; + const oldQuery = panelType.query?.( + applyOptionsData(panel.options ?? {}, unref(variables), panelType.skipUndefinedKeys) + ); + const newQuery = panelType.query?.( + applyOptionsData(panel.options ?? {}, unref(newVariables), panelType.skipUndefinedKeys) + ); + return JSON.stringify(oldQuery) !== JSON.stringify(newQuery); + }); + + variables.value = newVariables; + + if (needReload.length > 0) { + loadPanelData(needReload); + } + } }); + +if (import.meta.hot) { + import.meta.hot.accept(acceptHMRUpdate(useInsightsStore, import.meta.hot)); +} diff --git a/app/src/types/insights.ts b/app/src/types/insights.ts index e0a2cc09fa..586396facd 100644 --- a/app/src/types/insights.ts +++ b/app/src/types/insights.ts @@ -1,12 +1,9 @@ -import { Panel } from '@directus/shared/types'; - export type Dashboard = { id: string; name: string; note: string; icon: string; color: string; - panels: Panel[]; date_created: string; user_created: string; }; diff --git a/app/src/utils/get-groups.ts b/app/src/utils/get-groups.ts new file mode 100644 index 0000000000..a2beb42de3 --- /dev/null +++ b/app/src/utils/get-groups.ts @@ -0,0 +1,32 @@ +export function getGroups(precision: string, dateField: string) { + let groups: string[] = []; + + switch (precision || 'hour') { + case 'year': + groups = ['year']; + break; + case 'month': + groups = ['year', 'month']; + break; + case 'week': + groups = ['year', 'month', 'week']; + break; + case 'day': + groups = ['year', 'month', 'day']; + break; + case 'hour': + groups = ['year', 'month', 'day', 'hour']; + break; + case 'minute': + groups = ['year', 'month', 'day', 'hour', 'minute']; + break; + case 'second': + groups = ['year', 'month', 'day', 'hour', 'minute', 'second']; + break; + default: + groups = ['year', 'month', 'day', 'hour']; + break; + } + + return groups.map((datePart) => `${datePart}(${dateField})`); +} diff --git a/app/src/utils/query-to-gql-string.ts b/app/src/utils/query-to-gql-string.ts new file mode 100644 index 0000000000..b9535449d4 --- /dev/null +++ b/app/src/utils/query-to-gql-string.ts @@ -0,0 +1,61 @@ +import { useFieldsStore } from '@/stores'; +import { Query } from '@directus/shared/types'; +import { toArray } from '@directus/shared/utils'; +import { jsonToGraphQLQuery } from 'json-to-graphql-query'; +import { isEmpty, pick, set, omitBy, isUndefined } from 'lodash'; + +type QueryInfo = { collection: string; key: string; query: Query }; + +export function queryToGqlString(queries: QueryInfo | QueryInfo[]): string | null { + if (!queries || isEmpty(queries)) return null; + + const queryJSON: Record = { + query: {}, + }; + + for (const query of toArray(queries)) { + queryJSON.query[query.key] = formatQuery(query); + } + + return jsonToGraphQLQuery(queryJSON); +} + +export function formatQuery({ collection, query }: QueryInfo): Record { + const queryKeysInArguments: (keyof Query)[] = ['limit', 'sort', 'filter', 'offset', 'page', 'search']; + + const formattedQuery: Record = { + __args: omitBy(pick(query, ...queryKeysInArguments), isUndefined), + __aliasFor: collection, + }; + + const fields = query.fields ?? [useFieldsStore().getPrimaryKeyFieldForCollection(collection)!.field]; + + if (query?.aggregate && !isEmpty(query.aggregate)) { + formattedQuery.__aliasFor = collection + '_aggregated'; + + for (const [aggregateFunc, fields] of Object.entries(query.aggregate)) { + if (!formattedQuery[aggregateFunc]) { + formattedQuery[aggregateFunc] = {}; + } + + fields.forEach((field) => { + formattedQuery[aggregateFunc][field] = true; + }); + } + + if (query.group) { + formattedQuery.group = true; + formattedQuery.__args.groupBy = query.group; + } + } else { + for (const field of fields) { + set(formattedQuery, field, true); + } + } + + if (query.deep) { + // TBD @TODO + } + + return formattedQuery; +} diff --git a/package-lock.json b/package-lock.json index 2b9b4d27f7..32f66a597c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -346,6 +346,7 @@ "front-matter": "4.0.2", "html-entities": "2.3.2", "jest": "27.4.7", + "json-to-graphql-query": "^2.2.4", "json2csv": "^5.0.7", "jsonlint-mod": "1.7.6", "maplibre-gl": "1.15.2", @@ -31036,6 +31037,12 @@ "dev": true, "license": "ISC" }, + "node_modules/json-to-graphql-query": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/json-to-graphql-query/-/json-to-graphql-query-2.2.4.tgz", + "integrity": "sha512-vNvsOKDSlEqYCzejI1xHS9Hm738dSnG4Upy09LUGqyybZXSIIb7NydDphB/6WxW2EEVpPU4JeU/Yo63Nw9dEJg==", + "dev": true + }, "node_modules/json2csv": { "version": "5.0.7", "license": "MIT", @@ -33927,7 +33934,8 @@ }, "node_modules/micromustache": { "version": "8.0.3", - "license": "MIT", + "resolved": "https://registry.npmjs.org/micromustache/-/micromustache-8.0.3.tgz", + "integrity": "sha512-SXjrEPuYNtWq0reR9LR2nHdzdQx/3re9HPcDGjm00L7hi2RsH5KMRBhYEBvPdyQC51RW/2TznjwX/sQLPPyHNw==", "engines": { "node": ">=8" }, @@ -49764,7 +49772,8 @@ "date-fns": "2.24.0", "fs-extra": "10.0.0", "joi": "17.4.2", - "lodash": "4.17.21" + "lodash": "4.17.21", + "micromustache": "^8.0.3" }, "devDependencies": { "npm-run-all": "4.1.5", @@ -52003,6 +52012,7 @@ "front-matter": "4.0.2", "html-entities": "2.3.2", "jest": "27.4.7", + "json-to-graphql-query": "^2.2.4", "json2csv": "^5.0.7", "jsonlint-mod": "1.7.6", "maplibre-gl": "1.15.2", @@ -52718,6 +52728,7 @@ "fs-extra": "10.0.0", "joi": "17.4.2", "lodash": "4.17.21", + "micromustache": "^8.0.3", "npm-run-all": "4.1.5", "rimraf": "3.0.2", "tmp": "0.2.1", @@ -71971,6 +71982,12 @@ "version": "5.0.1", "dev": true }, + "json-to-graphql-query": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/json-to-graphql-query/-/json-to-graphql-query-2.2.4.tgz", + "integrity": "sha512-vNvsOKDSlEqYCzejI1xHS9Hm738dSnG4Upy09LUGqyybZXSIIb7NydDphB/6WxW2EEVpPU4JeU/Yo63Nw9dEJg==", + "dev": true + }, "json2csv": { "version": "5.0.7", "requires": { @@ -73834,7 +73851,9 @@ } }, "micromustache": { - "version": "8.0.3" + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/micromustache/-/micromustache-8.0.3.tgz", + "integrity": "sha512-SXjrEPuYNtWq0reR9LR2nHdzdQx/3re9HPcDGjm00L7hi2RsH5KMRBhYEBvPdyQC51RW/2TznjwX/sQLPPyHNw==" }, "miller-rabin": { "version": "4.0.1", diff --git a/packages/shared/package.json b/packages/shared/package.json index fddae99592..21bfbd7633 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -53,7 +53,8 @@ "date-fns": "2.24.0", "fs-extra": "10.0.0", "joi": "17.4.2", - "lodash": "4.17.21" + "lodash": "4.17.21", + "micromustache": "^8.0.3" }, "peerDependencies": { "@types/express": "*", diff --git a/packages/shared/src/types/panels.ts b/packages/shared/src/types/panels.ts index 9788a5ba46..a15fcfec40 100644 --- a/packages/shared/src/types/panels.ts +++ b/packages/shared/src/types/panels.ts @@ -1,13 +1,17 @@ import { Component, ComponentOptions } from 'vue'; import { DeepPartial } from './misc'; import { Field } from './fields'; +import { Query } from './query'; + +export type PanelQuery = { collection: string; query: Query; key?: string }; export interface PanelConfig { id: string; name: string; icon: string; description?: string; - + query?: (options: Record) => PanelQuery | PanelQuery[] | undefined; + variable?: true; // Mark the panel as a global variable component: Component; options: | DeepPartial[] @@ -19,6 +23,7 @@ export interface PanelConfig { | null; minWidth: number; minHeight: number; + skipUndefinedKeys?: string[]; } export type Panel = { diff --git a/packages/shared/src/utils/apply-options-data.ts b/packages/shared/src/utils/apply-options-data.ts new file mode 100644 index 0000000000..0da1dba153 --- /dev/null +++ b/packages/shared/src/utils/apply-options-data.ts @@ -0,0 +1,67 @@ +import { renderFn, get, Scope, ResolveFn } from 'micromustache'; +import { parseJSON } from './parse-json'; + +type Mustacheable = string | number | boolean | null | Mustacheable[] | { [key: string]: Mustacheable }; +type GenericString = T extends string ? string : T; + +export function applyOptionsData( + options: Record, + data: Record, + skipUndefinedKeys: string[] = [] +): Record { + return Object.fromEntries( + Object.entries(options).map(([key, value]) => { + if (typeof value === 'string') { + const single = value.match(/^\{\{\s*([^}\s]+)\s*\}\}$/); + + if (single !== null && single.length > 0) { + const foundValue = get(data, single[1]!); + + if (foundValue !== undefined || !skipUndefinedKeys.includes(key)) { + return [key, foundValue]; + } else { + return [key, value]; + } + } + } + + return [key, renderMustache(value, data, skipUndefinedKeys.includes(key))]; + }) + ); +} + +function resolveFn(skipUndefined: boolean): ResolveFn { + return (path: string, scope?: Scope) => { + if (!scope) return skipUndefined ? `{{${path}}}` : undefined; + + const value = get(scope, path); + + if (value !== undefined || !skipUndefined) { + return typeof value === 'object' ? JSON.stringify(value) : value; + } else { + return `{{${path}}}`; + } + }; +} + +function renderMustache(item: T, scope: Scope, skipUndefined: boolean): GenericString { + if (typeof item === 'string') { + return renderFn(item, resolveFn(skipUndefined), scope, { explicit: true }) as GenericString; + } else if (Array.isArray(item)) { + return item.map((element) => renderMustache(element, scope, skipUndefined)) as GenericString; + } else if (typeof item === 'object' && item !== null) { + return Object.fromEntries( + Object.entries(item).map(([key, value]) => [key, renderMustache(value, scope, skipUndefined)]) + ) as GenericString; + } else { + return item as GenericString; + } +} + +export function optionToObject(option: T): Exclude { + return typeof option === 'string' ? parseJSON(option) : option; +} + +export function optionToString(option: unknown): string { + return typeof option === 'object' ? JSON.stringify(option) : String(option); +} diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts index d5c1751f63..da16938449 100644 --- a/packages/shared/src/utils/index.ts +++ b/packages/shared/src/utils/index.ts @@ -1,8 +1,12 @@ +export * from './abbreviate-number'; +export * from './add-field-flag'; export * from './adjust-date'; +export * from './apply-options-data'; export * from './deep-map'; export * from './define-extension'; export * from './generate-joi'; export * from './get-collection-type'; +export * from './get-endpoint'; export * from './get-fields-from-template'; export * from './get-filter-operators-for-type'; export * from './get-functions-for-type'; @@ -14,10 +18,8 @@ export * from './is-extension'; export * from './merge-filters'; export * from './move-in-array'; export * from './parse-filter'; +export * from './parse-json'; export * from './pluralize'; export * from './to-array'; export * from './validate-extension-manifest'; export * from './validate-payload'; -export * from './add-field-flag'; -export * from './abbreviate-number'; -export * from './get-endpoint'; diff --git a/api/src/utils/parse-json.ts b/packages/shared/src/utils/parse-json.ts similarity index 100% rename from api/src/utils/parse-json.ts rename to packages/shared/src/utils/parse-json.ts diff --git a/api/tests/utils/parse-json.test.ts b/packages/shared/tests/utils/parse-json.test.ts similarity index 100% rename from api/tests/utils/parse-json.test.ts rename to packages/shared/tests/utils/parse-json.test.ts