diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fcecf5436b..e8ea08c548 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,11 @@ * @rijkvanzanten + /docs/*.md @benhaynes /packages/cli @WoLfulus /packages/sdk @WoLfulus /packages/gatsby-source-directus @WoLfulus + +/packages/shared @nickrum +/packages/extension-sdk @nickrum +/app/vite.config.js @nickrum diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..c12b5bf986 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,61 @@ +name: Bug Report +description: Create a report to help us improve +labels: 'Bug (Potential)' +body: + - type: markdown + attributes: + value: Hi, thank you for taking the time to create an issue! + - type: markdown + attributes: + value: 'Before continuing, you must first have completed all [Troubleshooting Steps](https://docs.directus.io/getting-started/support/#troubleshooting-steps)' + - type: markdown + attributes: + value: Please double check if an issue describing this problem doesn't exist already. + - type: input + attributes: + label: What version of Directus are you using? + description: 'For example: v9.1.4' + validations: + required: true + - type: input + attributes: + label: What version of Node.js are you using? + description: 'For example: 12.0.0' + validations: + required: true + - type: input + attributes: + label: What database are you using? + description: 'For example: Postgres 13, SQLite 3.31.0' + validations: + required: true + - type: input + attributes: + label: What browser are you using? + description: 'For example: Chrome, Safari' + validations: + required: true + - type: input + attributes: + label: What operating system are you using? + description: 'For example: macOS, Windows' + validations: + required: true + - type: input + attributes: + label: How are you deploying Directus? + description: 'For example: running locally, Docker, PaaS' + validations: + required: true + - type: textarea + attributes: + label: Describe the Bug + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + attributes: + label: To Reproduce + description: Steps to reproduce the behavior. Contributors should be able to follow the steps provided in order to reproduce the bug. + validations: + required: true diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index e41373db4b..f4db1a211b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: db: ['mssql', 'mysql', 'postgres', 'maria', 'sqlite3'] - node-version: ['12-alpine', '14-alpine', '15-alpine'] + node-version: ['12-alpine', '14-alpine', '16-alpine'] env: CACHED_IMAGE: ghcr.io/directus/directus-e2e-test-cache:${{ matrix.node-version }} steps: @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: - node-version: '15' + node-version: '16' - name: restore node_modules cache uses: actions/cache@v2 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index dc6e399902..a09b6fa102 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,7 +22,7 @@ jobs: - uses: actions/setup-node@v2 with: - node-version: '15' + node-version: '16' - name: Cache node modules uses: actions/cache@v2 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..b6f27f1359 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/Dockerfile b/Dockerfile index 0239d12980..1c12ca1d99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # NOTE: Testing Only. DO NOT use this in production -ARG NODE_VERSION=15-alpine +ARG NODE_VERSION=16-alpine FROM node:${NODE_VERSION} diff --git a/api/package.json b/api/package.json index 2d78b2354c..3d972a631e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "directus", - "version": "9.0.0-rc.76", + "version": "9.0.0-rc.80", "license": "GPL-3.0-only", "homepage": "https://github.com/directus/directus#readme", "description": "Directus is a real-time API and App dashboard for managing SQL database content.", @@ -59,6 +59,9 @@ "cli": "cross-env DIRECTUS_DEV=true NODE_ENV=development ts-node --script-mode --transpile-only src/cli/index.ts", "prepublishOnly": "npm run build" }, + "engines": { + "node": ">=12.0.0" + }, "files": [ "dist", "LICENSE", @@ -66,15 +69,18 @@ "example.env" ], "dependencies": { - "@directus/app": "9.0.0-rc.76", - "@directus/drive": "9.0.0-rc.76", - "@directus/drive-azure": "9.0.0-rc.76", - "@directus/drive-gcs": "9.0.0-rc.76", - "@directus/drive-s3": "9.0.0-rc.76", - "@directus/format-title": "9.0.0-rc.76", - "@directus/schema": "9.0.0-rc.76", - "@directus/specs": "9.0.0-rc.76", + "@directus/app": "9.0.0-rc.80", + "@directus/drive": "9.0.0-rc.80", + "@directus/drive-azure": "9.0.0-rc.80", + "@directus/drive-gcs": "9.0.0-rc.80", + "@directus/drive-s3": "9.0.0-rc.80", + "@directus/format-title": "9.0.0-rc.80", + "@directus/schema": "9.0.0-rc.80", + "@directus/shared": "9.0.0-rc.80", + "@directus/specs": "9.0.0-rc.80", "@godaddy/terminus": "^4.9.0", + "@rollup/plugin-alias": "^3.1.2", + "@rollup/plugin-virtual": "^2.0.3", "argon2": "^0.28.1", "async": "^3.2.0", "async-mutex": "^0.3.1", @@ -129,6 +135,7 @@ "qs": "^6.9.4", "rate-limiter-flexible": "^2.2.2", "resolve-cwd": "^3.0.0", + "rollup": "^2.52.1", "sharp": "^0.28.3", "stream-json": "^1.7.1", "uuid": "^8.3.2", @@ -162,7 +169,7 @@ "@types/express-pino-logger": "4.0.2", "@types/express-session": "1.17.3", "@types/fs-extra": "9.0.11", - "@types/inquirer": "7.3.1", + "@types/inquirer": "7.3.2", "@types/js-yaml": "4.0.1", "@types/json2csv": "5.0.2", "@types/jsonwebtoken": "8.5.2", diff --git a/api/src/app.ts b/api/src/app.ts index 58c4273a72..5a470f813a 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -56,7 +56,7 @@ export default async function createApp(): Promise { await initializeExtensions(); - await registerExtensionHooks(); + registerExtensionHooks(); const app = express(); @@ -170,7 +170,7 @@ export default async function createApp(): Promise { // Register custom hooks / endpoints await emitAsyncSafe('routes.custom.init.before', { app }); - await registerExtensionEndpoints(customRouter); + registerExtensionEndpoints(customRouter); await emitAsyncSafe('routes.custom.init.after', { app }); app.use(notFoundHandler); diff --git a/api/src/cache.ts b/api/src/cache.ts index 32e546e9ae..423a28b323 100644 --- a/api/src/cache.ts +++ b/api/src/cache.ts @@ -6,31 +6,39 @@ import { getConfigFromEnv } from './utils/get-config-from-env'; import { validateEnv } from './utils/validate-env'; let cache: Keyv | null = null; +let schemaCache: Keyv | null = null; -if (env.CACHE_ENABLED === true) { - validateEnv(['CACHE_NAMESPACE', 'CACHE_TTL', 'CACHE_STORE']); - cache = getKeyvInstance(); - cache.on('error', (err) => logger.error(err)); +export function getCache(): { cache: Keyv | null; schemaCache: Keyv | null } { + if (env.CACHE_ENABLED === true && cache === null) { + validateEnv(['CACHE_NAMESPACE', 'CACHE_TTL', 'CACHE_STORE']); + cache = getKeyvInstance(ms(env.CACHE_TTL as string)); + cache.on('error', (err) => logger.error(err)); + } + + if (env.CACHE_SCHEMA !== false && schemaCache === null) { + schemaCache = getKeyvInstance(typeof env.CACHE_SCHEMA === 'string' ? ms(env.CACHE_SCHEMA) : undefined); + schemaCache.on('error', (err) => logger.error(err)); + } + + return { cache, schemaCache }; } -export default cache; - -function getKeyvInstance() { +function getKeyvInstance(ttl: number | undefined): Keyv { switch (env.CACHE_STORE) { case 'redis': - return new Keyv(getConfig('redis')); + return new Keyv(getConfig('redis', ttl)); case 'memcache': - return new Keyv(getConfig('memcache')); + return new Keyv(getConfig('memcache', ttl)); case 'memory': default: - return new Keyv(getConfig()); + return new Keyv(getConfig('memory', ttl)); } } -function getConfig(store: 'memory' | 'redis' | 'memcache' = 'memory'): Options { +function getConfig(store: 'memory' | 'redis' | 'memcache' = 'memory', ttl: number | undefined): Options { const config: Options = { namespace: env.CACHE_NAMESPACE, - ttl: ms(env.CACHE_TTL as string), + ttl, }; if (store === 'redis') { diff --git a/api/src/constants.ts b/api/src/constants.ts index c6eb6bde6c..2fdbd23fb0 100644 --- a/api/src/constants.ts +++ b/api/src/constants.ts @@ -1,4 +1,4 @@ -import { Transformation } from './types/assets'; +import { Transformation } from './types'; export const SYSTEM_ASSET_ALLOW_LIST: Transformation[] = [ { diff --git a/api/src/controllers/extensions.ts b/api/src/controllers/extensions.ts index 99672eb754..7bfa287604 100644 --- a/api/src/controllers/extensions.ts +++ b/api/src/controllers/extensions.ts @@ -1,28 +1,24 @@ -import express, { Router } from 'express'; -import env from '../env'; -import { RouteNotFoundException } from '../exceptions'; -import { listExtensions } from '../extensions'; -import { respond } from '../middleware/respond'; +import { Router } from 'express'; import asyncHandler from '../utils/async-handler'; +import { RouteNotFoundException } from '../exceptions'; +import { listExtensions, getAppExtensionSource } from '../extensions'; +import { respond } from '../middleware/respond'; +import { depluralize } from '@directus/shared/utils'; +import { AppExtensionType, Plural } from '@directus/shared/types'; +import { APP_EXTENSION_TYPES } from '@directus/shared/constants'; const router = Router(); -const extensionsPath = env.EXTENSIONS_PATH as string; - -const appExtensions = ['interfaces', 'layouts', 'displays', 'modules']; - router.get( - ['/:type', '/:type/*'], + '/:type', asyncHandler(async (req, res, next) => { - if (appExtensions.includes(req.params.type) === false) { + const type = depluralize(req.params.type as Plural); + + if (APP_EXTENSION_TYPES.includes(type) === false) { throw new RouteNotFoundException(req.path); } - return next(); - }), - express.static(extensionsPath), - asyncHandler(async (req, res, next) => { - const extensions = await listExtensions(req.params.type); + const extensions = listExtensions(type); res.locals.payload = { data: extensions, @@ -33,4 +29,23 @@ router.get( respond ); +router.get( + '/:type/index.js', + asyncHandler(async (req, res) => { + const type = depluralize(req.params.type as Plural); + + if (APP_EXTENSION_TYPES.includes(type) === false) { + throw new RouteNotFoundException(req.path); + } + + const extensionSource = getAppExtensionSource(type); + if (extensionSource === undefined) { + throw new RouteNotFoundException(req.path); + } + + res.setHeader('Content-Type', 'application/javascript; charset=UTF-8'); + res.end(extensionSource); + }) +); + export default router; diff --git a/api/src/env.ts b/api/src/env.ts index 96277e67ee..f2258da059 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -51,6 +51,7 @@ const defaults: Record = { CACHE_NAMESPACE: 'system-cache', CACHE_AUTO_PURGE: false, CACHE_CONTROL_S_MAXAGE: '0', + CACHE_SCHEMA: true, OAUTH_PROVIDERS: '', diff --git a/api/src/extensions.ts b/api/src/extensions.ts index c3c8819189..8784d9c47a 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -1,91 +1,139 @@ import express, { Router } from 'express'; -import { ensureDir } from 'fs-extra'; import path from 'path'; +import { AppExtensionType, Extension, ExtensionType } from '@directus/shared/types'; +import { + generateExtensionsEntry, + getLocalExtensions, + getPackageExtensions, + pluralize, + resolvePackage, +} from '@directus/shared/utils'; +import { APP_EXTENSION_TYPES, EXTENSION_TYPES, SHARED_DEPS } from '@directus/shared/constants'; import getDatabase from './database'; import emitter from './emitter'; import env from './env'; import * as exceptions from './exceptions'; -import { ServiceUnavailableException } from './exceptions'; import logger from './logger'; -import * as services from './services'; -import { EndpointRegisterFunction, HookRegisterFunction } from './types'; +import { HookRegisterFunction, EndpointRegisterFunction } from './types'; +import fse from 'fs-extra'; import { getSchema } from './utils/get-schema'; -import listFolders from './utils/list-folders'; + +import * as services from './services'; import { schedule, validate } from 'node-cron'; import { REGEX_BETWEEN_PARENS } from './constants'; +import { rollup } from 'rollup'; +// @TODO Remove this once a new version of @rollup/plugin-virtual has been released +// @ts-expect-error +import virtual from '@rollup/plugin-virtual'; +import alias from '@rollup/plugin-alias'; -export async function ensureFoldersExist(): Promise { - const folders = ['endpoints', 'hooks', 'interfaces', 'modules', 'layouts', 'displays']; +let extensions: Extension[] = []; +let extensionBundles: Partial> = {}; - for (const folder of folders) { - const folderPath = path.resolve(env.EXTENSIONS_PATH, folder); +export async function initializeExtensions(): Promise { + await ensureDirsExist(); + extensions = await getExtensions(); + extensionBundles = await generateExtensionBundles(); + + logger.info(`Loaded extensions: ${listExtensions().join(', ')}`); +} + +export function listExtensions(type?: ExtensionType): string[] { + if (type === undefined) { + return extensions.map((extension) => extension.name); + } else { + return extensions.filter((extension) => extension.type === type).map((extension) => extension.name); + } +} + +export function getAppExtensionSource(type: AppExtensionType): string | undefined { + return extensionBundles[type]; +} + +export function registerExtensionEndpoints(router: Router): void { + const endpoints = extensions.filter((extension) => extension.type === 'endpoint'); + registerEndpoints(endpoints, router); +} + +export function registerExtensionHooks(): void { + const hooks = extensions.filter((extension) => extension.type === 'hook'); + registerHooks(hooks); +} + +async function getExtensions(): Promise { + const packageExtensions = await getPackageExtensions('.'); + const localExtensions = await getLocalExtensions(env.EXTENSIONS_PATH); + + return [...packageExtensions, ...localExtensions]; +} + +async function generateExtensionBundles() { + const sharedDepsMapping = await getSharedDepsMapping(SHARED_DEPS); + const internalImports = Object.entries(sharedDepsMapping).map(([name, path]) => ({ + find: name, + replacement: path, + })); + + const bundles: Partial> = {}; + + for (const extensionType of APP_EXTENSION_TYPES) { + const entry = generateExtensionsEntry(extensionType, extensions); + + const bundle = await rollup({ + input: 'entry', + external: SHARED_DEPS, + plugins: [virtual({ entry }), alias({ entries: internalImports })], + }); + const { output } = await bundle.generate({ format: 'es' }); + + bundles[extensionType] = output[0].code; + + await bundle.close(); + } + + return bundles; +} + +async function ensureDirsExist() { + for (const extensionType of EXTENSION_TYPES) { + const dirPath = path.resolve(env.EXTENSIONS_PATH, pluralize(extensionType)); try { - await ensureDir(folderPath); + await fse.ensureDir(dirPath); } catch (err) { logger.warn(err); } } } -export async function initializeExtensions(): Promise { - await ensureFoldersExist(); -} +async function getSharedDepsMapping(deps: string[]) { + const appDir = await fse.readdir(path.join(resolvePackage('@directus/app'), 'dist')); -export async function listExtensions(type: string): Promise { - const extensionsPath = env.EXTENSIONS_PATH as string; - const location = path.join(extensionsPath, type); + const depsMapping: Record = {}; + for (const dep of deps) { + const depName = appDir.find((file) => dep.replace(/\//g, '_') === file.substring(0, file.indexOf('.'))); - try { - return await listFolders(location); - } catch (err) { - if (err.code === 'ENOENT') { - throw new ServiceUnavailableException(`Extension folder "extensions/${type}" couldn't be opened`, { - service: 'extensions', - }); + if (depName) { + depsMapping[dep] = `${env.PUBLIC_URL}/admin/${depName}`; + } else { + logger.warn(`Couldn't find extension internal dependency "${dep}"`); } - throw err; } + + return depsMapping; } -export async function registerExtensions(router: Router): Promise { - await registerExtensionHooks(); - await registerExtensionEndpoints(router); -} - -export async function registerExtensionEndpoints(router: Router): Promise { - let endpoints: string[] = []; - try { - endpoints = await listExtensions('endpoints'); - registerEndpoints(endpoints, router); - } catch (err) { - logger.warn(err); - } -} - -export async function registerExtensionHooks(): Promise { - let hooks: string[] = []; - try { - hooks = await listExtensions('hooks'); - registerHooks(hooks); - } catch (err) { - logger.warn(err); - } -} - -function registerHooks(hooks: string[]) { - const extensionsPath = env.EXTENSIONS_PATH as string; - +function registerHooks(hooks: Extension[]) { for (const hook of hooks) { try { registerHook(hook); } catch (error) { - logger.warn(`Couldn't register hook "${hook}"`); + logger.warn(`Couldn't register hook "${hook.name}"`); logger.warn(error); } } - function registerHook(hook: string) { - const hookPath = path.resolve(extensionsPath, 'hooks', hook, 'index.js'); + function registerHook(hook: Extension) { + const hookPath = path.resolve(hook.path, hook.entrypoint || ''); const hookInstance: HookRegisterFunction | { default?: HookRegisterFunction } = require(hookPath); let register: HookRegisterFunction = hookInstance as HookRegisterFunction; @@ -113,20 +161,18 @@ function registerHooks(hooks: string[]) { } } -function registerEndpoints(endpoints: string[], router: Router) { - const extensionsPath = env.EXTENSIONS_PATH as string; - +function registerEndpoints(endpoints: Extension[], router: Router) { for (const endpoint of endpoints) { try { registerEndpoint(endpoint); } catch (error) { - logger.warn(`Couldn't register endpoint "${endpoint}"`); + logger.warn(`Couldn't register endpoint "${endpoint.name}"`); logger.warn(error); } } - function registerEndpoint(endpoint: string) { - const endpointPath = path.resolve(extensionsPath, 'endpoints', endpoint, 'index.js'); + function registerEndpoint(endpoint: Extension) { + const endpointPath = path.resolve(endpoint.path, endpoint.entrypoint || ''); const endpointInstance: EndpointRegisterFunction | { default?: EndpointRegisterFunction } = require(endpointPath); let register: EndpointRegisterFunction = endpointInstance as EndpointRegisterFunction; @@ -137,7 +183,7 @@ function registerEndpoints(endpoints: string[], router: Router) { } const scopedRouter = express.Router(); - router.use(`/${endpoint}/`, scopedRouter); + router.use(`/${endpoint.name}/`, scopedRouter); register(scopedRouter, { services, exceptions, env, database: getDatabase(), getSchema }); } diff --git a/api/src/middleware/authenticate.ts b/api/src/middleware/authenticate.ts index 257e1ff008..8e6abe3b52 100644 --- a/api/src/middleware/authenticate.ts +++ b/api/src/middleware/authenticate.ts @@ -78,10 +78,6 @@ const authenticate: RequestHandler = asyncHandler(async (req, res, next) => { req.accountability.app = user.app_access === true || user.app_access == 1; } - if (req.accountability?.user) { - await database('directus_users').update({ last_access: new Date() }).where({ id: req.accountability.user }); - } - return next(); }); diff --git a/api/src/middleware/cache.ts b/api/src/middleware/cache.ts index 134d6fc2fc..613cc81572 100644 --- a/api/src/middleware/cache.ts +++ b/api/src/middleware/cache.ts @@ -1,11 +1,13 @@ import { RequestHandler } from 'express'; -import cache from '../cache'; +import { getCache } from '../cache'; import env from '../env'; import asyncHandler from '../utils/async-handler'; import { getCacheControlHeader } from '../utils/get-cache-headers'; import { getCacheKey } from '../utils/get-cache-key'; const checkCacheMiddleware: RequestHandler = asyncHandler(async (req, res, next) => { + const { cache } = getCache(); + if (req.method.toLowerCase() !== 'get') return next(); if (env.CACHE_ENABLED !== true) return next(); if (!cache) return next(); diff --git a/api/src/middleware/respond.ts b/api/src/middleware/respond.ts index 748d5e48ff..ed4a4032a2 100644 --- a/api/src/middleware/respond.ts +++ b/api/src/middleware/respond.ts @@ -2,7 +2,7 @@ import { RequestHandler } from 'express'; import { Transform, transforms } from 'json2csv'; import ms from 'ms'; import { PassThrough } from 'stream'; -import cache from '../cache'; +import { getCache } from '../cache'; import env from '../env'; import asyncHandler from '../utils/async-handler'; import { getCacheKey } from '../utils/get-cache-key'; @@ -10,6 +10,8 @@ import { parse as toXML } from 'js2xmlparser'; import { getCacheControlHeader } from '../utils/get-cache-headers'; export const respond: RequestHandler = asyncHandler(async (req, res) => { + const { cache } = getCache(); + if ( req.method.toLowerCase() === 'get' && env.CACHE_ENABLED === true && diff --git a/api/src/services/authentication.ts b/api/src/services/authentication.ts index d30d58d590..ae944df822 100644 --- a/api/src/services/authentication.ts +++ b/api/src/services/authentication.ts @@ -185,6 +185,8 @@ export class AuthenticationService { }); } + await this.knex('directus_users').update({ last_access: new Date() }).where({ id: user.id }); + emitStatus('success'); if (allowedAttempts !== null) { @@ -230,6 +232,8 @@ export class AuthenticationService { .update({ token: newRefreshToken, expires: refreshTokenExpiration }) .where({ token: refreshToken }); + await this.knex('directus_users').update({ last_access: new Date() }).where({ id: record.id }); + return { accessToken, refreshToken: newRefreshToken, diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 6ed9c097df..3b87db7fa2 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -1,6 +1,6 @@ import SchemaInspector from '@directus/schema'; import { Knex } from 'knex'; -import cache from '../cache'; +import { getCache } from '../cache'; import { ALIAS_TYPES } from '../constants'; import getDatabase, { getSchemaInspector } from '../database'; import { systemCollectionRows } from '../database/system-data/collections'; @@ -9,6 +9,7 @@ import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import logger from '../logger'; import { FieldsService, RawField } from '../services/fields'; import { ItemsService, MutationOptions } from '../services/items'; +import Keyv from 'keyv'; import { AbstractServiceOptions, Accountability, @@ -29,12 +30,18 @@ export class CollectionsService { accountability: Accountability | null; schemaInspector: ReturnType; schema: SchemaOverview; + cache: Keyv | null; + schemaCache: Keyv | null; constructor(options: AbstractServiceOptions) { this.knex = options.knex || getDatabase(); this.accountability = options.accountability || null; this.schemaInspector = options.knex ? SchemaInspector(options.knex) : getSchemaInspector(); this.schema = options.schema; + + const { cache, schemaCache } = getCache(); + this.cache = cache; + this.schemaCache = schemaCache; } /** @@ -128,8 +135,12 @@ export class CollectionsService { return payload.collection; }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); + } + + if (this.schemaCache) { + await this.schemaCache.clear(); } return payload.collection; @@ -156,8 +167,12 @@ export class CollectionsService { return collectionNames; }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); + } + + if (this.schemaCache) { + await this.schemaCache.clear(); } return collections; @@ -416,8 +431,12 @@ export class CollectionsService { await trx.schema.dropTable(collectionKey); }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); + } + + if (this.schemaCache) { + await this.schemaCache.clear(); } return collectionKey; @@ -443,8 +462,12 @@ export class CollectionsService { } }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); + } + + if (this.schemaCache) { + await this.schemaCache.clear(); } return collectionKeys; diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index ec0d9c89ce..b821508a6e 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -1,7 +1,7 @@ import SchemaInspector from '@directus/schema'; import { Knex } from 'knex'; import { Column } from 'knex-schema-inspector/dist/types/column'; -import cache from '../cache'; +import { getCache } from '../cache'; import { ALIAS_TYPES } from '../constants'; import getDatabase, { getSchemaInspector } from '../database'; import { systemFieldRows } from '../database/system-data/fields/'; @@ -18,6 +18,7 @@ import getLocalType from '../utils/get-local-type'; import { toArray } from '../utils/to-array'; import { isEqual } from 'lodash'; import { RelationsService } from './relations'; +import Keyv from 'keyv'; export type RawField = DeepPartial & { field: string; type: typeof types[number] }; @@ -28,6 +29,8 @@ export class FieldsService { payloadService: PayloadService; schemaInspector: ReturnType; schema: SchemaOverview; + cache: Keyv | null; + schemaCache: Keyv | null; constructor(options: AbstractServiceOptions) { this.knex = options.knex || getDatabase(); @@ -36,6 +39,10 @@ export class FieldsService { this.itemsService = new ItemsService('directus_fields', options); this.payloadService = new PayloadService('directus_fields', options); this.schema = options.schema; + + const { cache, schemaCache } = getCache(); + this.cache = cache; + this.schemaCache = schemaCache; } private get hasReadAccess() { @@ -244,8 +251,12 @@ export class FieldsService { } }); - if (cache && env.CACHE_AUTO_PURGE) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE) { + await this.cache.clear(); + } + + if (this.schemaCache) { + await this.schemaCache.clear(); } } @@ -291,8 +302,12 @@ export class FieldsService { } } - if (cache && env.CACHE_AUTO_PURGE) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE) { + await this.cache.clear(); + } + + if (this.schemaCache) { + await this.schemaCache.clear(); } return field.field; @@ -396,8 +411,12 @@ export class FieldsService { } }); - if (cache && env.CACHE_AUTO_PURGE) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE) { + await this.cache.clear(); + } + + if (this.schemaCache) { + await this.schemaCache.clear(); } emitAsyncSafe(`fields.delete`, { diff --git a/api/src/services/files.ts b/api/src/services/files.ts index 7eab80fd31..25f7664389 100644 --- a/api/src/services/files.ts +++ b/api/src/services/files.ts @@ -7,7 +7,6 @@ import { extension } from 'mime-types'; import path from 'path'; import sharp from 'sharp'; import url from 'url'; -import cache from '../cache'; import { emitAsyncSafe } from '../emitter'; import env from '../env'; import { ForbiddenException, ServiceUnavailableException } from '../exceptions'; @@ -121,8 +120,8 @@ export class FilesService extends ItemsService { await sudoService.updateOne(primaryKey, payload, { emitEvents: false }); - if (cache && env.CACHE_AUTO_PURGE) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE) { + await this.cache.clear(); } emitAsyncSafe(`files.upload`, { @@ -208,8 +207,8 @@ export class FilesService extends ItemsService { } } - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); } return keys; diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index c22f36ac3b..ca297efffd 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -416,6 +416,18 @@ export class GraphQLService { _ncontains: { type: GraphQLString, }, + _starts_with: { + type: GraphQLString, + }, + _nstarts_with: { + type: GraphQLString, + }, + _ends_with: { + type: GraphQLString, + }, + _nends_with: { + type: GraphQLString, + }, _in: { type: new GraphQLList(GraphQLString), }, @@ -1265,10 +1277,10 @@ export class GraphQLService { }, }), resolve: async () => ({ - interfaces: await listExtensions('interfaces'), - displays: await listExtensions('displays'), - layouts: await listExtensions('layouts'), - modules: await listExtensions('modules'), + interfaces: listExtensions('interface'), + displays: listExtensions('display'), + layouts: listExtensions('layout'), + modules: listExtensions('module'), }), }, server_specs_oas: { diff --git a/api/src/services/items.ts b/api/src/services/items.ts index 4aa0258ee2..7971593a34 100644 --- a/api/src/services/items.ts +++ b/api/src/services/items.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; import { clone, cloneDeep, merge, pick, without } from 'lodash'; -import cache from '../cache'; +import { getCache } from '../cache'; +import Keyv from 'keyv'; import getDatabase from '../database'; import runAST from '../database/run-ast'; import emitter, { emitAsyncSafe } from '../emitter'; @@ -52,6 +53,7 @@ export class ItemsService implements AbstractSer accountability: Accountability | null; eventScope: string; schema: SchemaOverview; + cache: Keyv | null; constructor(collection: string, options: AbstractServiceOptions) { this.collection = collection; @@ -59,6 +61,7 @@ export class ItemsService implements AbstractSer this.accountability = options.accountability || null; this.eventScope = this.collection.startsWith('directus_') ? this.collection.substring(9) : 'items'; this.schema = options.schema; + this.cache = getCache().cache; return this; } @@ -208,8 +211,8 @@ export class ItemsService implements AbstractSer }); } - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); } return primaryKey; @@ -236,8 +239,8 @@ export class ItemsService implements AbstractSer return primaryKeys; }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); } return primaryKeys; @@ -524,8 +527,8 @@ export class ItemsService implements AbstractSer } }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); } if (opts?.emitEvents !== false) { @@ -589,8 +592,8 @@ export class ItemsService implements AbstractSer return primaryKeys; }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); } return primaryKeys; @@ -673,8 +676,8 @@ export class ItemsService implements AbstractSer } }); - if (cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE && opts?.autoPurgeCache !== false) { + await this.cache.clear(); } if (opts?.emitEvents !== false) { @@ -717,6 +720,11 @@ export class ItemsService implements AbstractSer } for (const [name, field] of fields) { + if (this.schema.collections[this.collection].primary === name) { + defaults[name] = null; + continue; + } + defaults[name] = field.defaultValue; } diff --git a/api/src/services/relations.ts b/api/src/services/relations.ts index c9a38c8463..1aa0523e33 100644 --- a/api/src/services/relations.ts +++ b/api/src/services/relations.ts @@ -9,6 +9,8 @@ import SchemaInspector from '@directus/schema'; import { ForeignKey } from 'knex-schema-inspector/dist/types/foreign-key'; import getDatabase, { getSchemaInspector } from '../database'; import { getDefaultIndexName } from '../utils/get-default-index-name'; +import { getCache } from '../cache'; +import Keyv from 'keyv'; export class RelationsService { knex: Knex; @@ -17,6 +19,7 @@ export class RelationsService { accountability: Accountability | null; schema: SchemaOverview; relationsItemService: ItemsService; + schemaCache: Keyv | null; constructor(options: AbstractServiceOptions) { this.knex = options.knex || getDatabase(); @@ -31,6 +34,8 @@ export class RelationsService { // allowed to extract the relations regardless of permissions to directus_relations. This // happens in `filterForbidden` down below }); + + this.schemaCache = getCache().schemaCache; } async readAll(collection?: string, opts?: QueryOptions): Promise { @@ -183,6 +188,10 @@ export class RelationsService { await relationsItemService.createOne(metaRow); }); + + if (this.schemaCache) { + await this.schemaCache.clear(); + } } /** @@ -259,6 +268,10 @@ export class RelationsService { } } }); + + if (this.schemaCache) { + await this.schemaCache.clear(); + } } /** @@ -296,6 +309,10 @@ export class RelationsService { await trx('directus_relations').delete().where({ many_collection: collection, many_field: field }); } }); + + if (this.schemaCache) { + await this.schemaCache.clear(); + } } /** diff --git a/api/src/services/server.ts b/api/src/services/server.ts index 2bf4ef5913..ef81579ce5 100644 --- a/api/src/services/server.ts +++ b/api/src/services/server.ts @@ -6,7 +6,7 @@ import os from 'os'; import { performance } from 'perf_hooks'; // @ts-ignore import { version } from '../../package.json'; -import cache from '../cache'; +import { getCache } from '../cache'; import getDatabase, { hasDatabaseConnection } from '../database'; import env from '../env'; import logger from '../logger'; @@ -189,6 +189,8 @@ export class ServerService { return {}; } + const { cache } = getCache(); + const checks: Record = { 'cache:responseTime': [ { diff --git a/api/src/services/users.ts b/api/src/services/users.ts index 7088dcc505..cc80d95992 100644 --- a/api/src/services/users.ts +++ b/api/src/services/users.ts @@ -2,7 +2,6 @@ import argon2 from 'argon2'; import jwt from 'jsonwebtoken'; import { Knex } from 'knex'; import { clone } from 'lodash'; -import cache from '../cache'; import getDatabase from '../database'; import env from '../env'; import { @@ -287,8 +286,8 @@ export class UsersService extends ItemsService { await this.knex('directus_users').update({ password: passwordHashed, status: 'active' }).where({ id: user.id }); - if (cache && env.CACHE_AUTO_PURGE) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE) { + await this.cache.clear(); } } @@ -343,8 +342,8 @@ export class UsersService extends ItemsService { await this.knex('directus_users').update({ password: passwordHashed, status: 'active' }).where({ id: user.id }); - if (cache && env.CACHE_AUTO_PURGE) { - await cache.clear(); + if (this.cache && env.CACHE_AUTO_PURGE) { + await this.cache.clear(); } } diff --git a/api/src/utils/apply-query.ts b/api/src/utils/apply-query.ts index 4922dba183..e5d52defa0 100644 --- a/api/src/utils/apply-query.ts +++ b/api/src/utils/apply-query.ts @@ -361,6 +361,22 @@ export function applyFilter( dbQuery[logical].whereNot(selectionRaw, 'like', `%${compareValue}%`); } + if (operator === '_starts_with') { + dbQuery[logical].where(key, 'like', `${compareValue}%`); + } + + if (operator === '_nstarts_with') { + dbQuery[logical].whereNot(key, 'like', `${compareValue}%`); + } + + if (operator === '_ends_with') { + dbQuery[logical].where(key, 'like', `%${compareValue}`); + } + + if (operator === '_nends_with') { + dbQuery[logical].whereNot(key, 'like', `%${compareValue}`); + } + if (operator === '_gt') { dbQuery[logical].where(selectionRaw, '>', compareValue); } diff --git a/api/src/utils/generate-joi.ts b/api/src/utils/generate-joi.ts index 4f6bc9b5b7..44ab075b2c 100644 --- a/api/src/utils/generate-joi.ts +++ b/api/src/utils/generate-joi.ts @@ -1,5 +1,6 @@ import BaseJoi, { AnySchema } from 'joi'; import { Filter } from '../types'; +import { escapeRegExp } from 'lodash'; const Joi: typeof BaseJoi = BaseJoi.extend({ type: 'string', @@ -92,6 +93,22 @@ function getJoi(operator: string, value: any) { return Joi.string().ncontains(value); } + if (operator === '_starts_with') { + return Joi.string().pattern(new RegExp(`^${escapeRegExp(value)}.*`), { name: 'starts_with' }); + } + + if (operator === '_nstarts_with') { + return Joi.string().pattern(new RegExp(`^${escapeRegExp(value)}.*`), { name: 'starts_with', invert: true }); + } + + if (operator === '_ends_with') { + return Joi.string().pattern(new RegExp(`.*${escapeRegExp(value)}$`), { name: 'ends_with' }); + } + + if (operator === '_nends_with') { + return Joi.string().pattern(new RegExp(`.*${escapeRegExp(value)}$`), { name: 'ends_with', invert: true }); + } + if (operator === '_in') { return Joi.any().equal(...(value as (string | number)[])); } diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index 2e9a104717..f4102510f0 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -12,20 +12,32 @@ import getDefaultValue from './get-default-value'; import getLocalType from './get-local-type'; import { mergePermissions } from './merge-permissions'; import getDatabase from '../database'; +import { getCache } from '../cache'; +import env from '../env'; +import ms from 'ms'; export async function getSchema(options?: { accountability?: Accountability; database?: Knex; }): Promise { - // Allows for use in the CLI const database = options?.database || getDatabase(); const schemaInspector = SchemaInspector(database); + const { schemaCache } = getCache(); - const result: SchemaOverview = { - collections: {}, - relations: [], - permissions: [], - }; + let result: SchemaOverview; + + if (env.CACHE_SCHEMA !== false && schemaCache) { + const cachedSchema = (await schemaCache.get('schema')) as SchemaOverview; + + if (cachedSchema) { + result = cachedSchema; + } else { + result = await getDatabaseSchema(database, schemaInspector); + await schemaCache.set('schema', result, typeof env.CACHE_SCHEMA === 'string' ? ms(env.CACHE_SCHEMA) : undefined); + } + } else { + result = await getDatabaseSchema(database, schemaInspector); + } let permissions: Permission[] = []; @@ -65,6 +77,19 @@ export async function getSchema(options?: { result.permissions = permissions; + return result; +} + +async function getDatabaseSchema( + database: Knex, + schemaInspector: ReturnType +): Promise { + const result: SchemaOverview = { + collections: {}, + relations: [], + permissions: [], + }; + const schemaOverview = await schemaInspector.overview(); const collections = [ diff --git a/api/src/utils/list-folders.ts b/api/src/utils/list-folders.ts deleted file mode 100644 index 6b06d241b6..0000000000 --- a/api/src/utils/list-folders.ts +++ /dev/null @@ -1,24 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import { promisify } from 'util'; - -const readdir = promisify(fs.readdir); -const stat = promisify(fs.stat); - -export default async function listFolders(location: string): Promise { - const fullPath = path.resolve(location); - const files = await readdir(fullPath); - - const directories: string[] = []; - - for (const file of files) { - const filePath = path.join(fullPath, file); - const stats = await stat(filePath); - - if (stats.isDirectory()) { - directories.push(file); - } - } - - return directories; -} diff --git a/api/src/utils/validate-query.ts b/api/src/utils/validate-query.ts index 43af7d924c..74b68e800b 100644 --- a/api/src/utils/validate-query.ts +++ b/api/src/utils/validate-query.ts @@ -53,6 +53,10 @@ function validateFilter(filter: Query['filter']) { case '_neq': case '_contains': case '_ncontains': + case '_starts_with': + case '_nstarts_with': + case '_ends_with': + case '_nends_with': case '_gt': case '_gte': case '_lt': diff --git a/app/package.json b/app/package.json index 1e7035999c..ed85ceca04 100644 --- a/app/package.json +++ b/app/package.json @@ -1,9 +1,9 @@ { "name": "@directus/app", - "version": "9.0.0-rc.76", + "version": "9.0.0-rc.80", "private": false, "description": "Directus is an Open-Source Headless CMS & API for Managing Custom Databases", - "author": "Rijk van Zanten ", + "author": "Rijk van Zanten ", "main": "dist/index.html", "files": [ "dist", @@ -18,7 +18,7 @@ "access": "public" }, "scripts": { - "dev": "cross-env NODE_ENV=development vite", + "dev": "vite", "build": "vite build", "serve": "vite preview", "copy-docs-images": "rimraf public/img/docs && copyfiles -u 3 \"../docs/assets/**/*\" \"public/img/docs\" --verbose", @@ -28,8 +28,10 @@ }, "gitHead": "24621f3934dc77eb23441331040ed13c676ceffd", "devDependencies": { - "@directus/docs": "9.0.0-rc.76", - "@directus/format-title": "9.0.0-rc.76", + "@directus/docs": "9.0.0-rc.80", + "@directus/extension-sdk": "9.0.0-rc.80", + "@directus/format-title": "9.0.0-rc.80", + "@directus/shared": "9.0.0-rc.80", "@fullcalendar/core": "5.8.0", "@fullcalendar/daygrid": "5.8.0", "@fullcalendar/interaction": "5.8.0", @@ -41,7 +43,7 @@ "@tinymce/tinymce-vue": "4.0.3", "@types/base-64": "1.0.0", "@types/bytes": "3.1.0", - "@types/codemirror": "5.60.0", + "@types/codemirror": "5.60.1", "@types/color": "3.0.1", "@types/diff": "5.0.0", "@types/dompurify": "2.2.2", @@ -60,7 +62,7 @@ "@vue/compiler-sfc": "3.1.1", "axios": "0.21.1", "base-64": "1.0.0", - "codemirror": "5.61.1", + "codemirror": "5.62.0", "copyfiles": "2.4.1", "cropperjs": "1.5.12", "date-fns": "2.22.1", @@ -69,7 +71,7 @@ "front-matter": "4.0.2", "html-entities": "2.3.2", "jsonlint-mod": "1.7.6", - "marked": "2.1.1", + "marked": "2.1.2", "micromustache": "8.0.3", "mime": "2.5.2", "mitt": "2.1.0", @@ -80,12 +82,12 @@ "qrcode": "1.4.4", "rimraf": "3.0.2", "sass": "1.35.1", - "tinymce": "5.8.1", + "tinymce": "5.8.2", "typescript": "4.3.4", - "vite": "2.3.7", + "vite": "2.3.8", "vue": "3.1.1", "vue-i18n": "9.1.6", - "vue-router": "4.0.9", + "vue-router": "4.0.10", "vuedraggable": "4.0.3" } } diff --git a/app/src/api.ts b/app/src/api.ts index 5c2cd5cbfb..e26e325abe 100644 --- a/app/src/api.ts +++ b/app/src/api.ts @@ -8,7 +8,7 @@ const api = axios.create({ baseURL: getRootPath(), withCredentials: true, headers: { - 'Cache-Control': 'no-cache', + 'Cache-Control': 'no-store', }, }); diff --git a/app/src/components/v-button/v-button.vue b/app/src/components/v-button/v-button.vue index 976dfdf3d3..6886faebac 100644 --- a/app/src/components/v-button/v-button.vue +++ b/app/src/components/v-button/v-button.vue @@ -86,7 +86,7 @@ export default defineComponent({ }, to: { type: [String, Object] as PropType, - default: null, + default: '', }, href: { type: String, @@ -140,7 +140,7 @@ export default defineComponent({ const component = computed<'a' | 'router-link' | 'button'>(() => { if (props.disabled) return 'button'; if (notEmpty(props.href)) return 'a'; - if (notEmpty(props.to)) return 'router-link'; + if (props.to) return 'router-link'; return 'button'; }); diff --git a/app/src/components/v-checkbox-tree/v-checkbox-tree-checkbox.vue b/app/src/components/v-checkbox-tree/v-checkbox-tree-checkbox.vue index d16b8fb8d6..3cca1b3a8d 100644 --- a/app/src/components/v-checkbox-tree/v-checkbox-tree-checkbox.vue +++ b/app/src/components/v-checkbox-tree/v-checkbox-tree-checkbox.vue @@ -6,6 +6,7 @@ :checked="groupCheckedStateOverride" :label="text" :value="value" + :disabled="disabled" v-model="treeValue" /> @@ -23,12 +24,13 @@ :text="choice[itemText]" :value="choice[itemValue]" :children="choice[itemChildren]" + :disabled="disabled" v-model="treeValue" /> - + @@ -88,13 +90,31 @@ export default defineComponent({ type: String, default: 'children', }, + disabled: { + type: Boolean, + default: false, + }, }, setup(props, { emit }) { const visibleChildrenValues = computed(() => { if (!props.search) return props.children?.map((child) => child[props.itemValue]); + return props.children - ?.filter((child) => child[props.itemText].toLowerCase().includes(props.search.toLowerCase())) + ?.filter( + (child) => + child[props.itemText].toLowerCase().includes(props.search.toLowerCase()) || + childrenHaveMatch(child.children) + ) ?.map((child) => child[props.itemValue]); + + function childrenHaveMatch(children: Record[] | undefined): boolean { + if (!children) return false; + return children.some( + (child) => + child[props.itemText].toLowerCase().includes(props.search.toLowerCase()) || + childrenHaveMatch(child[props.itemChildren]) + ); + } }); const childrenValues = computed(() => props.children?.map((child) => child[props.itemValue]) || []); @@ -294,7 +314,7 @@ export default defineComponent({ return emitValue(rawValue); } - function emitLeaf(rawValue: (string | number)[], { added, removed }: Delta) { + function emitLeaf(rawValue: (string | number)[], { added }: Delta) { const allChildrenRecursive = getRecursiveChildrenValues('all'); const leafChildrenRecursive = getRecursiveChildrenValues('leaf'); @@ -355,7 +375,7 @@ export default defineComponent({ return emitValue(rawValue); } - function emitExclusive(rawValue: (string | number)[], { added, removed }: Delta) { + function emitExclusive(rawValue: (string | number)[], { added }: Delta) { const childrenValuesRecursive = getRecursiveChildrenValues('all'); // When enabling the group level diff --git a/app/src/components/v-checkbox-tree/v-checkbox-tree.vue b/app/src/components/v-checkbox-tree/v-checkbox-tree.vue index 46d02fe46a..e7829687f8 100644 --- a/app/src/components/v-checkbox-tree/v-checkbox-tree.vue +++ b/app/src/components/v-checkbox-tree/v-checkbox-tree.vue @@ -11,6 +11,7 @@ :text="choice[itemText]" :value="choice[itemValue]" :children="choice[itemChildren]" + :disabled="disabled" v-model="value" /> @@ -52,6 +53,10 @@ export default defineComponent({ type: String, default: 'children', }, + disabled: { + type: Boolean, + default: false, + }, }, setup(props, { emit }) { const value = computed({ diff --git a/app/src/components/v-field-template/v-field-template.vue b/app/src/components/v-field-template/v-field-template.vue index ae6f4cd38d..477f78d24f 100644 --- a/app/src/components/v-field-template/v-field-template.vue +++ b/app/src/components/v-field-template/v-field-template.vue @@ -3,7 +3,14 @@