From 92e1ee77bd34118dce69e91c44d9cfcfbd208f37 Mon Sep 17 00:00:00 2001 From: Rijk van Zanten Date: Tue, 27 Jul 2021 00:02:24 +0200 Subject: [PATCH] Add support for Conditional Fields (#6864) * Add conditions field to directus_fields * Add conditions configuration * Apply conditional overrides * Handle conditions in nested groups * Fix reverse mutating conditions * Start on filter setup interface * Move field types/constants to shared * [WIP] Updated client side filter validation * Support logical operators in client validation step * Use new validation util in conditions check * Add nesting in filter seutp * Add filter rule setup configurator * Fixes that should've been done in the merge * Strip out filter-settings interface TBD in a new PR * Move browser to index --- api/src/cli/commands/bootstrap/index.ts | 2 +- api/src/controllers/fields.ts | 11 +- .../20210716A-add-conditions-to-fields.ts | 13 ++ api/src/database/seeds/run.ts | 6 +- .../database/system-data/fields/fields.yaml | 5 + api/src/database/system-data/fields/index.ts | 2 +- api/src/extensions.ts | 2 +- api/src/services/collections.ts | 10 +- api/src/services/fields.ts | 11 +- api/src/services/specifications.ts | 44 ++-- api/src/types/collection.ts | 2 +- api/src/types/field.ts | 44 ---- api/src/types/index.ts | 1 - api/src/types/schema.ts | 4 +- api/src/utils/get-graphql-type.ts | 6 +- api/src/utils/get-local-type.ts | 6 +- app/src/components/v-form/form-field.vue | 6 +- app/src/components/v-form/v-form.vue | 58 +++-- app/src/components/v-input/v-input.vue | 5 + .../use-form-fields/use-form-fields.ts | 4 +- app/src/displays/boolean/index.ts | 2 +- app/src/displays/collection/index.ts | 2 +- app/src/displays/color/index.ts | 2 +- app/src/displays/datetime/index.ts | 2 +- app/src/displays/file/index.ts | 2 +- app/src/displays/filesize/index.ts | 2 +- .../displays/formatted-json-value/index.ts | 2 +- app/src/displays/formatted-value/index.ts | 2 +- app/src/displays/icon/index.ts | 2 +- app/src/displays/image/index.ts | 2 +- app/src/displays/labels/index.ts | 2 +- app/src/displays/mime-type/index.ts | 2 +- app/src/displays/rating/index.ts | 2 +- app/src/displays/raw/index.ts | 2 +- app/src/displays/related-values/index.ts | 2 +- app/src/displays/user/index.ts | 2 +- .../_system/system-collection/index.ts | 2 +- .../_system/system-collections/index.ts | 2 +- .../_system/system-display-template/index.ts | 2 +- .../_system/system-field-tree/index.ts | 2 +- .../interfaces/_system/system-field/index.ts | 2 +- .../interfaces/_system/system-folder/index.ts | 2 +- .../_system/system-interface-options/index.ts | 2 +- .../system-interface-options.vue | 20 +- .../_system/system-interface/index.ts | 2 +- .../_system/system-language/index.ts | 2 +- .../_system/system-mfa-setup/index.ts | 2 +- .../interfaces/_system/system-scope/index.ts | 2 +- app/src/interfaces/boolean/index.ts | 2 +- app/src/interfaces/datetime/index.ts | 2 +- app/src/interfaces/file-image/index.ts | 2 +- app/src/interfaces/file/index.ts | 2 +- app/src/interfaces/group-divider/index.ts | 2 +- app/src/interfaces/group-raw/index.ts | 2 +- .../input-autocomplete-api/index.ts | 2 +- app/src/interfaces/input-code/index.ts | 2 +- app/src/interfaces/input-hash/index.ts | 2 +- app/src/interfaces/input-multiline/index.ts | 2 +- .../interfaces/input-rich-text-html/index.ts | 2 +- .../interfaces/input-rich-text-md/index.ts | 2 +- app/src/interfaces/input/index.ts | 2 +- app/src/interfaces/list-m2a/index.ts | 2 +- app/src/interfaces/list-m2m/index.ts | 2 +- .../interfaces/list-o2m-tree-view/index.ts | 2 +- app/src/interfaces/list-o2m/index.ts | 2 +- app/src/interfaces/list/index.ts | 2 +- .../interfaces/presentation-divider/index.ts | 2 +- .../interfaces/presentation-links/index.ts | 2 +- .../interfaces/presentation-notice/index.ts | 2 +- app/src/interfaces/select-color/index.ts | 2 +- .../interfaces/select-dropdown-m2o/index.ts | 2 +- app/src/interfaces/select-dropdown/index.ts | 2 +- app/src/interfaces/select-icon/index.ts | 2 +- .../select-multiple-checkbox-tree/index.ts | 2 +- .../select-multiple-checkbox/index.ts | 2 +- .../select-multiple-dropdown/index.ts | 2 +- app/src/interfaces/select-radio/index.ts | 2 +- app/src/interfaces/slider/index.ts | 2 +- app/src/interfaces/tags/index.ts | 2 +- app/src/interfaces/translations/index.ts | 2 +- app/src/lang/translations/en-US.yaml | 5 + app/src/layouts/calendar/index.ts | 2 +- app/src/layouts/cards/index.ts | 2 +- app/src/layouts/tabular/index.ts | 2 +- app/src/modules/activity/index.ts | 2 +- app/src/modules/collections/index.ts | 2 +- app/src/modules/docs/index.ts | 2 +- app/src/modules/files/index.ts | 2 +- app/src/modules/settings/index.ts | 2 +- .../field-detail/components/conditions.vue | 101 +++++++++ .../data-model/field-detail/field-detail.vue | 10 + app/src/modules/users/index.ts | 2 +- app/src/types/fields.ts | 10 - app/src/types/index.ts | 1 - app/src/utils/generate-joi/index.ts | 23 +- .../filter-sidebar-detail/field-filter.vue | 4 +- .../filter-sidebar-detail.vue | 4 +- .../components/filter-sidebar-detail/types.ts | 5 - app/vite.config.js | 2 +- docs/reference/environment-variables.md | 2 +- package-lock.json | 4 + packages/extension-sdk/src/index.ts | 2 +- packages/shared/package.json | 5 + packages/shared/src/constants/field-types.ts | 33 +++ packages/shared/src/types/fields.ts | 20 ++ packages/shared/src/types/filter.ts | 47 +++++ packages/shared/src/types/index.ts | 1 + packages/shared/src/types/layouts.ts | 4 +- packages/shared/src/types/presets.ts | 22 +- packages/shared/src/utils/browser/index.ts | 6 +- .../utils/{browser => }/define-extension.ts | 2 +- packages/shared/src/utils/generate-joi.ts | 199 ++++++++++++++++++ .../utils/get-filter-operators-for-type.ts | 60 ++++++ packages/shared/src/utils/index.ts | 9 +- .../src/utils/{browser => }/is-extension.ts | 4 +- .../src/utils/node/ensure-extension-dirs.ts | 2 +- .../shared/src/utils/node/get-extensions.ts | 3 +- .../src/utils/{browser => }/pluralize.ts | 2 +- .../validate-extension-manifest.ts | 4 +- packages/shared/src/utils/validate-payload.ts | 58 +++++ packages/shared/utils/node.d.ts | 1 + 121 files changed, 792 insertions(+), 261 deletions(-) create mode 100644 api/src/database/migrations/20210716A-add-conditions-to-fields.ts delete mode 100644 api/src/types/field.ts create mode 100644 app/src/modules/settings/routes/data-model/field-detail/components/conditions.vue delete mode 100644 app/src/types/fields.ts create mode 100644 packages/shared/src/constants/field-types.ts create mode 100644 packages/shared/src/types/filter.ts rename packages/shared/src/utils/{browser => }/define-extension.ts (97%) create mode 100644 packages/shared/src/utils/generate-joi.ts create mode 100644 packages/shared/src/utils/get-filter-operators-for-type.ts rename packages/shared/src/utils/{browser => }/is-extension.ts (88%) rename packages/shared/src/utils/{browser => }/pluralize.ts (83%) rename packages/shared/src/utils/{browser => }/validate-extension-manifest.ts (85%) create mode 100644 packages/shared/src/utils/validate-payload.ts create mode 100644 packages/shared/utils/node.d.ts diff --git a/api/src/cli/commands/bootstrap/index.ts b/api/src/cli/commands/bootstrap/index.ts index 0a04145d4c..fbcd4f7726 100644 --- a/api/src/cli/commands/bootstrap/index.ts +++ b/api/src/cli/commands/bootstrap/index.ts @@ -5,7 +5,7 @@ import env from '../../../env'; import logger from '../../../logger'; import { getSchema } from '../../../utils/get-schema'; import { RolesService, UsersService, SettingsService } from '../../../services'; -import getDatabase, { isInstalled, hasDatabaseConnection, validateDBConnection } from '../../../database'; +import getDatabase, { isInstalled, validateDBConnection } from '../../../database'; import { SchemaOverview } from '../../../types'; export default async function bootstrap({ skipAdminInit }: { skipAdminInit?: boolean }): Promise { diff --git a/api/src/controllers/fields.ts b/api/src/controllers/fields.ts index 2d45235b2b..732e6a589c 100644 --- a/api/src/controllers/fields.ts +++ b/api/src/controllers/fields.ts @@ -6,7 +6,8 @@ import validateCollection from '../middleware/collection-exists'; import { respond } from '../middleware/respond'; import useCollection from '../middleware/use-collection'; import { FieldsService } from '../services/fields'; -import { Field, types } from '../types'; +import { Field, Type } from '@directus/shared/types'; +import { TYPES } from '@directus/shared/constants'; import asyncHandler from '../utils/async-handler'; const router = Router(); @@ -65,7 +66,7 @@ const newFieldSchema = Joi.object({ collection: Joi.string().optional(), field: Joi.string().required(), type: Joi.string() - .valid(...types, ...ALIAS_TYPES) + .valid(...TYPES, ...ALIAS_TYPES) .allow(null) .optional(), schema: Joi.object({ @@ -93,7 +94,7 @@ router.post( throw new InvalidPayloadException(error.message); } - const field: Partial & { field: string; type: typeof types[number] | null } = req.body; + const field: Partial & { field: string; type: Type | null } = req.body; await service.createField(req.params.collection, field); @@ -152,7 +153,7 @@ router.patch( const updateSchema = Joi.object({ type: Joi.string() - .valid(...types, ...ALIAS_TYPES) + .valid(...TYPES, ...ALIAS_TYPES) .allow(null), schema: Joi.object({ default_value: Joi.any(), @@ -183,7 +184,7 @@ router.patch( throw new InvalidPayloadException(`You need to provide "type" when providing "schema".`); } - const fieldData: Partial & { field: string; type: typeof types[number] } = req.body; + const fieldData: Partial & { field: string; type: Type } = req.body; if (!fieldData.field) fieldData.field = req.params.field; diff --git a/api/src/database/migrations/20210716A-add-conditions-to-fields.ts b/api/src/database/migrations/20210716A-add-conditions-to-fields.ts new file mode 100644 index 0000000000..efead1ddf5 --- /dev/null +++ b/api/src/database/migrations/20210716A-add-conditions-to-fields.ts @@ -0,0 +1,13 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + await knex.schema.alterTable('directus_fields', (table) => { + table.json('conditions'); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_files', (table) => { + table.dropColumn('conditions'); + }); +} diff --git a/api/src/database/seeds/run.ts b/api/src/database/seeds/run.ts index 2ad2eb0916..cf86c8eddf 100644 --- a/api/src/database/seeds/run.ts +++ b/api/src/database/seeds/run.ts @@ -3,13 +3,13 @@ import yaml from 'js-yaml'; import { Knex } from 'knex'; import { isObject } from 'lodash'; import path from 'path'; -import { types } from '../../types'; +import { Type } from '@directus/shared/types'; type TableSeed = { table: string; columns: { [column: string]: { - type?: typeof types[number]; + type?: Type; primary?: boolean; nullable?: boolean; default?: any; @@ -45,6 +45,8 @@ export default async function runSeed(database: Knex): Promise { for (const [columnName, columnInfo] of Object.entries(seedData.columns)) { let column: Knex.ColumnBuilder; + if (columnInfo.type === 'alias' || columnInfo.type === 'unknown') return; + if (columnInfo.type === 'string') { column = tableBuilder.string(columnName, columnInfo.length); } else if (columnInfo.increments) { diff --git a/api/src/database/system-data/fields/fields.yaml b/api/src/database/system-data/fields/fields.yaml index 5dfc5fcb25..82dd60a3ca 100644 --- a/api/src/database/system-data/fields/fields.yaml +++ b/api/src/database/system-data/fields/fields.yaml @@ -73,3 +73,8 @@ fields: - collection: directus_fields field: note width: half + + - collection: directus_fields + field: conditions + hidden: true + special: json diff --git a/api/src/database/system-data/fields/index.ts b/api/src/database/system-data/fields/index.ts index b085e359e2..0f27da3436 100644 --- a/api/src/database/system-data/fields/index.ts +++ b/api/src/database/system-data/fields/index.ts @@ -1,7 +1,7 @@ import fse from 'fs-extra'; import { merge } from 'lodash'; import path from 'path'; -import { FieldMeta } from '../../../types'; +import { FieldMeta } from '@directus/shared/types'; import { requireYAML } from '../../../utils/require-yaml'; const defaults = requireYAML(require.resolve('./_defaults.yaml')); diff --git a/api/src/extensions.ts b/api/src/extensions.ts index ece92a880c..d510411d4d 100644 --- a/api/src/extensions.ts +++ b/api/src/extensions.ts @@ -7,7 +7,7 @@ import { getLocalExtensions, getPackageExtensions, resolvePackage, -} from '@directus/shared/utils'; +} from '@directus/shared/utils/node'; import { APP_EXTENSION_TYPES, APP_SHARED_DEPS } from '@directus/shared/constants'; import getDatabase from './database'; import emitter from './emitter'; diff --git a/api/src/services/collections.ts b/api/src/services/collections.ts index 3b87db7fa2..242d40a204 100644 --- a/api/src/services/collections.ts +++ b/api/src/services/collections.ts @@ -10,14 +10,8 @@ import logger from '../logger'; import { FieldsService, RawField } from '../services/fields'; import { ItemsService, MutationOptions } from '../services/items'; import Keyv from 'keyv'; -import { - AbstractServiceOptions, - Accountability, - Collection, - CollectionMeta, - FieldMeta, - SchemaOverview, -} from '../types'; +import { AbstractServiceOptions, Accountability, Collection, CollectionMeta, SchemaOverview } from '../types'; +import { FieldMeta } from '@directus/shared/types'; export type RawCollection = { collection: string; diff --git a/api/src/services/fields.ts b/api/src/services/fields.ts index b39ee0e07a..74ccd0cf07 100644 --- a/api/src/services/fields.ts +++ b/api/src/services/fields.ts @@ -11,8 +11,8 @@ import { ForbiddenException, InvalidPayloadException } from '../exceptions'; import { translateDatabaseError } from '../exceptions/database/translate'; import { ItemsService } from '../services/items'; import { PayloadService } from '../services/payload'; -import { AbstractServiceOptions, Accountability, FieldMeta, SchemaOverview, types } from '../types'; -import { Field } from '../types/field'; +import { AbstractServiceOptions, Accountability, SchemaOverview } from '../types'; +import { Field, FieldMeta, Type } from '@directus/shared/types'; import getDefaultValue from '../utils/get-default-value'; import getLocalType from '../utils/get-local-type'; import { toArray } from '../utils/to-array'; @@ -21,7 +21,7 @@ import { RelationsService } from './relations'; import Keyv from 'keyv'; import { DeepPartial } from '@directus/shared/types'; -export type RawField = DeepPartial & { field: string; type: typeof types[number] }; +export type RawField = DeepPartial & { field: string; type: Type }; export class FieldsService { knex: Knex; @@ -214,7 +214,7 @@ export class FieldsService { async createField( collection: string, - field: Partial & { field: string; type: typeof types[number] | null }, + field: Partial & { field: string; type: Type | null }, table?: Knex.CreateTableBuilder // allows collection creation to ): Promise { if (this.accountability && this.accountability.admin !== true) { @@ -435,6 +435,9 @@ export class FieldsService { public addColumnToTable(table: Knex.CreateTableBuilder, field: RawField | Field, alter: Column | null = null): void { let column: Knex.ColumnBuilder; + // Don't attempt to add a DB column for alias / corrupt fields + if (field.type === 'alias' || field.type === 'unknown') return; + if (field.schema?.has_auto_increment) { column = table.increments(field.field); } else if (field.type === 'string') { diff --git a/api/src/services/specifications.ts b/api/src/services/specifications.ts index c4d21e60ed..24479f7dab 100644 --- a/api/src/services/specifications.ts +++ b/api/src/services/specifications.ts @@ -7,16 +7,8 @@ import { OpenAPIObject, OperationObject, PathItemObject, SchemaObject, TagObject import { version } from '../../package.json'; import getDatabase from '../database'; import env from '../env'; -import { - AbstractServiceOptions, - Accountability, - Collection, - Field, - Permission, - Relation, - SchemaOverview, - types, -} from '../types'; +import { AbstractServiceOptions, Accountability, Collection, Permission, Relation, SchemaOverview } from '../types'; +import { Field, Type } from '@directus/shared/types'; import { getRelationType } from '../utils/get-relation-type'; import { CollectionsService } from './collections'; import { FieldsService } from './fields'; @@ -459,20 +451,33 @@ class OASSpecsService implements SpecificationSubService { } private fieldTypes: Record< - typeof types[number], + Type, { type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'integer' | 'null' | undefined; format?: string; items?: any; } > = { + alias: { + type: 'string', + }, bigInteger: { type: 'integer', format: 'int64', }, + binary: { + type: 'string', + format: 'binary', + }, boolean: { type: 'boolean', }, + csv: { + type: 'array', + items: { + type: 'string', + }, + }, date: { type: 'string', format: 'date', @@ -488,6 +493,9 @@ class OASSpecsService implements SpecificationSubService { type: 'number', format: 'float', }, + hash: { + type: 'string', + }, integer: { type: 'integer', }, @@ -511,23 +519,13 @@ class OASSpecsService implements SpecificationSubService { type: 'string', format: 'timestamp', }, - binary: { - type: 'string', - format: 'binary', + unknown: { + type: undefined, }, uuid: { type: 'string', format: 'uuid', }, - csv: { - type: 'array', - items: { - type: 'string', - }, - }, - hash: { - type: 'string', - }, }; } diff --git a/api/src/types/collection.ts b/api/src/types/collection.ts index e589efb85e..37362d1789 100644 --- a/api/src/types/collection.ts +++ b/api/src/types/collection.ts @@ -1,5 +1,5 @@ import { Table } from 'knex-schema-inspector/dist/types/table'; -import { Field } from './field'; +import { Field } from '@directus/shared/types'; export type CollectionMeta = { collection: string; diff --git a/api/src/types/field.ts b/api/src/types/field.ts deleted file mode 100644 index d0b518a89e..0000000000 --- a/api/src/types/field.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Column } from 'knex-schema-inspector/dist/types/column'; - -export const types = [ - 'bigInteger', - 'boolean', - 'date', - 'dateTime', - 'decimal', - 'float', - 'integer', - 'json', - 'string', - 'text', - 'time', - 'timestamp', - 'binary', - 'uuid', - 'hash', - 'csv', -] as const; - -export type FieldMeta = { - id: number; - collection: string; - field: string; - special: string[] | null; - interface: string | null; - options: Record | null; - readonly: boolean; - hidden: boolean; - sort: number | null; - width: string | null; - group: number | null; - note: string | null; - translations: null; -}; - -export type Field = { - collection: string; - field: string; - type: typeof types[number]; - schema: Column | null; - meta: FieldMeta | null; -}; diff --git a/api/src/types/index.ts b/api/src/types/index.ts index 4fbf2d9510..d8c3228f00 100644 --- a/api/src/types/index.ts +++ b/api/src/types/index.ts @@ -4,7 +4,6 @@ export * from './assets'; export * from './ast'; export * from './collection'; export * from './extensions'; -export * from './field'; export * from './files'; export * from './graphql'; export * from './items'; diff --git a/api/src/types/schema.ts b/api/src/types/schema.ts index b5f4b33a60..0bc23e94a2 100644 --- a/api/src/types/schema.ts +++ b/api/src/types/schema.ts @@ -1,4 +1,4 @@ -import { types } from './field'; +import { Type } from '@directus/shared/types'; import { Permission } from './permissions'; import { Relation } from './relation'; @@ -15,7 +15,7 @@ type CollectionsOverview = { field: string; defaultValue: any; nullable: boolean; - type: typeof types[number] | 'unknown' | 'alias'; + type: Type | 'unknown' | 'alias'; dbType: string | null; precision: number | null; scale: number | null; diff --git a/api/src/utils/get-graphql-type.ts b/api/src/utils/get-graphql-type.ts index d2b3821e5d..4edce04d06 100644 --- a/api/src/utils/get-graphql-type.ts +++ b/api/src/utils/get-graphql-type.ts @@ -9,11 +9,9 @@ import { } from 'graphql'; import { GraphQLJSON } from 'graphql-compose'; import { GraphQLDate } from '../services/graphql'; -import { types } from '../types'; +import { Type } from '@directus/shared/types'; -export function getGraphQLType( - localType: typeof types[number] | 'alias' | 'unknown' -): GraphQLScalarType | GraphQLList { +export function getGraphQLType(localType: Type | 'alias' | 'unknown'): GraphQLScalarType | GraphQLList { switch (localType) { case 'boolean': return GraphQLBoolean; diff --git a/api/src/utils/get-local-type.ts b/api/src/utils/get-local-type.ts index f5759267ff..6f5885473b 100644 --- a/api/src/utils/get-local-type.ts +++ b/api/src/utils/get-local-type.ts @@ -1,11 +1,11 @@ import { SchemaOverview } from '@directus/schema/dist/types/overview'; import { Column } from 'knex-schema-inspector/dist/types/column'; -import { FieldMeta, types } from '../types'; +import { FieldMeta, Type } from '@directus/shared/types'; /** * Typemap graciously provided by @gpetrov */ -const localTypeMap: Record = { +const localTypeMap: Record = { // Shared boolean: { type: 'boolean' }, tinyint: { type: 'boolean' }, @@ -90,7 +90,7 @@ const localTypeMap: Record -
+