mirror of
https://github.com/directus/directus.git
synced 2026-04-25 03:00:53 -04:00
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
This commit is contained in:
@@ -5,5 +5,5 @@ export {
|
||||
defineModule,
|
||||
defineHook,
|
||||
defineEndpoint,
|
||||
} from '@directus/shared/utils/browser';
|
||||
} from '@directus/shared/utils';
|
||||
export { useLayoutState } from '@directus/shared/composables';
|
||||
|
||||
@@ -36,11 +36,16 @@
|
||||
"dev": "npm run build -- -w --preserveWatchOutput --incremental"
|
||||
},
|
||||
"author": "Nicola Krumschmidt",
|
||||
"maintainers": [
|
||||
"Rijk van Zanten <rijkvanzanten@me.com>"
|
||||
],
|
||||
"gitHead": "24621f3934dc77eb23441331040ed13c676ceffd",
|
||||
"dependencies": {
|
||||
"express": "4.17.1",
|
||||
"fs-extra": "10.0.0",
|
||||
"joi": "^17.4.1",
|
||||
"knex-schema-inspector": "1.5.12",
|
||||
"lodash": "^4.17.21",
|
||||
"vue": "3.1.5",
|
||||
"vue-router": "4.0.10"
|
||||
},
|
||||
|
||||
33
packages/shared/src/constants/field-types.ts
Normal file
33
packages/shared/src/constants/field-types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const types = [
|
||||
'alias',
|
||||
'bigInteger',
|
||||
'boolean',
|
||||
'date',
|
||||
'dateTime',
|
||||
'decimal',
|
||||
'float',
|
||||
'integer',
|
||||
'json',
|
||||
'string',
|
||||
'text',
|
||||
'time',
|
||||
'timestamp',
|
||||
'binary',
|
||||
'uuid',
|
||||
'hash',
|
||||
'csv',
|
||||
'unknown',
|
||||
] as const;
|
||||
|
||||
export const localTypes = [
|
||||
'standard',
|
||||
'file',
|
||||
'files',
|
||||
'm2o',
|
||||
'o2m',
|
||||
'm2m',
|
||||
'm2a',
|
||||
'presentation',
|
||||
'translations',
|
||||
'group',
|
||||
] as const;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FilterOperator } from './filter';
|
||||
import { Column } from 'knex-schema-inspector/dist/types/column';
|
||||
import { LOCAL_TYPES, TYPES } from '../constants';
|
||||
|
||||
@@ -28,6 +29,7 @@ export type FieldMeta = {
|
||||
translations: Translations[] | null;
|
||||
width: Width | null;
|
||||
note: string | null;
|
||||
conditions: Condition[] | null;
|
||||
system?: true;
|
||||
};
|
||||
|
||||
@@ -43,3 +45,21 @@ export interface Field extends FieldRaw {
|
||||
name: string;
|
||||
children?: Field[] | null;
|
||||
}
|
||||
|
||||
export type ValidationError = {
|
||||
code: string;
|
||||
field: string;
|
||||
type: FilterOperator;
|
||||
valid?: number | string | (number | string)[];
|
||||
invalid?: number | string | (number | string)[];
|
||||
substring?: string;
|
||||
};
|
||||
|
||||
export type Condition = {
|
||||
name: string;
|
||||
rule: Record<string, any>;
|
||||
|
||||
readonly?: boolean;
|
||||
hidden?: boolean;
|
||||
options?: Record<string, any>;
|
||||
};
|
||||
|
||||
47
packages/shared/src/types/filter.ts
Normal file
47
packages/shared/src/types/filter.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'in'
|
||||
| 'nin'
|
||||
| 'null'
|
||||
| 'nnull'
|
||||
| 'contains'
|
||||
| 'ncontains'
|
||||
| 'between'
|
||||
| 'nbetween'
|
||||
| 'empty'
|
||||
| 'nempty';
|
||||
|
||||
export type ClientFilterOperator = FilterOperator | 'starts_with' | 'nstarts_with' | 'ends_with' | 'nends_with';
|
||||
|
||||
export type Filter = FieldFilter & {
|
||||
_and?: FieldFilter[];
|
||||
_or?: FieldFilter[];
|
||||
};
|
||||
|
||||
export type FieldFilter = {
|
||||
[field: string]: FieldFilterOperator | FieldFilter;
|
||||
};
|
||||
|
||||
export type FieldFilterOperator = {
|
||||
_eq?: string | number | boolean;
|
||||
_neq?: string | number | boolean;
|
||||
_lt?: string | number;
|
||||
_lte?: string | number;
|
||||
_gt?: string | number;
|
||||
_gte?: string | number;
|
||||
_in?: (string | number)[];
|
||||
_nin?: (string | number)[];
|
||||
_null?: boolean;
|
||||
_nnull?: boolean;
|
||||
_contains?: string;
|
||||
_ncontains?: string;
|
||||
_between?: (string | number)[];
|
||||
_nbetween?: (string | number)[];
|
||||
_empty?: boolean;
|
||||
_nempty?: boolean;
|
||||
};
|
||||
@@ -2,6 +2,7 @@ export * from './displays';
|
||||
export * from './endpoints';
|
||||
export * from './extensions';
|
||||
export * from './fields';
|
||||
export * from './filter';
|
||||
export * from './hooks';
|
||||
export * from './interfaces';
|
||||
export * from './items';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component } from 'vue';
|
||||
import { Item } from './items';
|
||||
import { Filter } from './presets';
|
||||
import { AppFilter } from './presets';
|
||||
|
||||
export interface LayoutConfig<Options = any, Query = any> {
|
||||
id: string;
|
||||
@@ -20,7 +20,7 @@ export interface LayoutProps<Options = any, Query = any> {
|
||||
selection: Item[];
|
||||
layoutOptions: Options;
|
||||
layoutQuery: Query;
|
||||
filters: Filter[];
|
||||
filters: AppFilter[];
|
||||
searchQuery: string | null;
|
||||
selectMode: boolean;
|
||||
readonly: boolean;
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
export type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'in'
|
||||
| 'nin'
|
||||
| 'null'
|
||||
| 'nnull'
|
||||
| 'contains'
|
||||
| 'ncontains'
|
||||
| 'between'
|
||||
| 'nbetween'
|
||||
| 'empty'
|
||||
| 'nempty';
|
||||
import { FilterOperator } from './filter';
|
||||
|
||||
export type Filter = {
|
||||
export type AppFilter = {
|
||||
key: string;
|
||||
field: string;
|
||||
operator: FilterOperator;
|
||||
@@ -31,7 +15,7 @@ export type Preset = {
|
||||
role: string | null;
|
||||
collection: string;
|
||||
search: string | null;
|
||||
filters: readonly Filter[] | null;
|
||||
filters: readonly AppFilter[] | null;
|
||||
layout: string | null;
|
||||
layout_query: { [layout: string]: any } | null;
|
||||
layout_options: { [layout: string]: any } | null;
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from './define-extension';
|
||||
export * from './is-extension';
|
||||
export * from './pluralize';
|
||||
export * from './validate-extension-manifest';
|
||||
// nothing to see here
|
||||
export {};
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
ModuleConfig,
|
||||
HookRegisterFunction,
|
||||
EndpointRegisterFunction,
|
||||
} from '../../types';
|
||||
} from '../types';
|
||||
|
||||
export function defineInterface(config: InterfaceConfig): InterfaceConfig {
|
||||
return config;
|
||||
199
packages/shared/src/utils/generate-joi.ts
Normal file
199
packages/shared/src/utils/generate-joi.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import BaseJoi, { AnySchema } from 'joi';
|
||||
import { escapeRegExp, merge } from 'lodash';
|
||||
import { FieldFilter } from '../types/filter';
|
||||
|
||||
const Joi = BaseJoi.extend({
|
||||
type: 'string',
|
||||
base: BaseJoi.string(),
|
||||
messages: {
|
||||
'string.contains': '{{#label}} must contain [{{#substring}}]',
|
||||
'string.ncontains': "{{#label}} can't contain [{{#substring}}]",
|
||||
},
|
||||
rules: {
|
||||
contains: {
|
||||
args: [
|
||||
{
|
||||
name: 'substring',
|
||||
ref: true,
|
||||
assert: (val) => typeof val === 'string',
|
||||
message: 'must be a string',
|
||||
},
|
||||
],
|
||||
method(substring) {
|
||||
return this.$_addRule({ name: 'contains', args: { substring } });
|
||||
},
|
||||
validate(value, helpers, { substring }) {
|
||||
if (value.includes(substring) === false) {
|
||||
return helpers.error('string.contains', { substring });
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
},
|
||||
ncontains: {
|
||||
args: [
|
||||
{
|
||||
name: 'substring',
|
||||
ref: true,
|
||||
assert: (val) => typeof val === 'string',
|
||||
message: 'must be a string',
|
||||
},
|
||||
],
|
||||
method(substring) {
|
||||
return this.$_addRule({ name: 'ncontains', args: { substring } });
|
||||
},
|
||||
validate(value, helpers, { substring }) {
|
||||
if (value.includes(substring) === true) {
|
||||
return helpers.error('string.ncontains', { substring });
|
||||
}
|
||||
|
||||
return value;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export type JoiOptions = {
|
||||
requireAll?: boolean;
|
||||
};
|
||||
|
||||
const defaults: JoiOptions = {
|
||||
requireAll: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a Joi schema from a filter object.
|
||||
*
|
||||
* @param {FieldFilter} filter - Field filter object. Note: does not support _and/_or filters.
|
||||
* @param {JoiOptions} [options] - Options for the schema generation.
|
||||
* @returns {AnySchema} Joi schema.
|
||||
*/
|
||||
export function generateJoi(filter: FieldFilter, options?: JoiOptions): AnySchema {
|
||||
filter = filter || {};
|
||||
|
||||
options = merge({}, defaults, options);
|
||||
|
||||
const schema: Record<string, AnySchema> = {};
|
||||
|
||||
const key = Object.keys(filter)[0];
|
||||
|
||||
if (!key) {
|
||||
throw new Error(`[generateJoi] Filter doesn't contain field key. Passed filter: ${JSON.stringify(filter)}`);
|
||||
}
|
||||
|
||||
const value = Object.values(filter)[0];
|
||||
|
||||
if (!value) {
|
||||
throw new Error(`[generateJoi] Filter doesn't contain filter rule. Passed filter: ${JSON.stringify(filter)}`);
|
||||
}
|
||||
|
||||
const isField = key.startsWith('_') === false;
|
||||
|
||||
if (isField) {
|
||||
const operator = Object.keys(value)[0];
|
||||
|
||||
if (operator === '_eq') {
|
||||
schema[key] = Joi.any().equal(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_neq') {
|
||||
schema[key] = Joi.any().not(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_contains') {
|
||||
schema[key] = Joi.string().contains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_ncontains') {
|
||||
schema[key] = Joi.string().ncontains(Object.values(value)[0]);
|
||||
}
|
||||
|
||||
if (operator === '_starts_with') {
|
||||
schema[key] = Joi.string().pattern(new RegExp(`^${escapeRegExp(Object.values(value)[0] as string)}.*`), {
|
||||
name: 'starts_with',
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_nstarts_with') {
|
||||
schema[key] = Joi.string().pattern(new RegExp(`^${escapeRegExp(Object.values(value)[0] as string)}.*`), {
|
||||
name: 'starts_with',
|
||||
invert: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_ends_with') {
|
||||
schema[key] = Joi.string().pattern(new RegExp(`.*${escapeRegExp(Object.values(value)[0] as string)}$`), {
|
||||
name: 'ends_with',
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_nends_with') {
|
||||
schema[key] = Joi.string().pattern(new RegExp(`.*${escapeRegExp(Object.values(value)[0] as string)}$`), {
|
||||
name: 'ends_with',
|
||||
invert: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (operator === '_in') {
|
||||
schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_nin') {
|
||||
schema[key] = Joi.any().not(...(Object.values(value)[0] as (string | number)[]));
|
||||
}
|
||||
|
||||
if (operator === '_gt') {
|
||||
schema[key] = Joi.number().greater(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_gte') {
|
||||
schema[key] = Joi.number().min(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lt') {
|
||||
schema[key] = Joi.number().less(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_lte') {
|
||||
schema[key] = Joi.number().max(Number(Object.values(value)[0]));
|
||||
}
|
||||
|
||||
if (operator === '_null') {
|
||||
schema[key] = Joi.any().valid(null);
|
||||
}
|
||||
|
||||
if (operator === '_nnull') {
|
||||
schema[key] = Joi.any().invalid(null);
|
||||
}
|
||||
|
||||
if (operator === '_empty') {
|
||||
schema[key] = Joi.any().valid('');
|
||||
}
|
||||
|
||||
if (operator === '_nempty') {
|
||||
schema[key] = Joi.any().invalid('');
|
||||
}
|
||||
|
||||
if (operator === '_between') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().greater(values[0]).less(values[1]);
|
||||
}
|
||||
|
||||
if (operator === '_nbetween') {
|
||||
const values = Object.values(value)[0] as number[];
|
||||
schema[key] = Joi.number().less(values[0]).greater(values[1]);
|
||||
}
|
||||
} else {
|
||||
schema[key] = Joi.object({
|
||||
[key]: generateJoi(value as FieldFilter, options),
|
||||
});
|
||||
}
|
||||
|
||||
schema[key] = schema[key] ?? Joi.any();
|
||||
|
||||
if (options.requireAll) {
|
||||
schema[key] = schema[key]!.required();
|
||||
}
|
||||
|
||||
return Joi.object(schema).unknown();
|
||||
}
|
||||
60
packages/shared/src/utils/get-filter-operators-for-type.ts
Normal file
60
packages/shared/src/utils/get-filter-operators-for-type.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ClientFilterOperator, Type } from '../types';
|
||||
|
||||
export function getFilterOperatorsForType(type: Type): ClientFilterOperator[] {
|
||||
switch (type) {
|
||||
// Text
|
||||
case 'binary':
|
||||
case 'json':
|
||||
case 'hash':
|
||||
case 'string':
|
||||
return [
|
||||
'contains',
|
||||
'ncontains',
|
||||
'starts_with',
|
||||
'nstarts_with',
|
||||
'ends_with',
|
||||
'nends_with',
|
||||
'eq',
|
||||
'neq',
|
||||
'empty',
|
||||
'nempty',
|
||||
'in',
|
||||
'nin',
|
||||
];
|
||||
case 'uuid':
|
||||
return ['eq', 'neq', 'empty', 'nempty', 'in', 'nin'];
|
||||
|
||||
// Boolean
|
||||
case 'boolean':
|
||||
return ['eq', 'neq', 'empty', 'nempty'];
|
||||
|
||||
// Numbers
|
||||
case 'integer':
|
||||
case 'decimal':
|
||||
return ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'between', 'nbetween', 'empty', 'nempty', 'in', 'nin'];
|
||||
|
||||
// Datetime
|
||||
case 'dateTime':
|
||||
case 'date':
|
||||
case 'time':
|
||||
return ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'between', 'nbetween', 'empty', 'nempty', 'in', 'nin'];
|
||||
|
||||
default:
|
||||
return [
|
||||
'eq',
|
||||
'neq',
|
||||
'lt',
|
||||
'lte',
|
||||
'gt',
|
||||
'gte',
|
||||
'contains',
|
||||
'ncontains',
|
||||
'between',
|
||||
'nbetween',
|
||||
'empty',
|
||||
'nempty',
|
||||
'in',
|
||||
'nin',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,7 @@
|
||||
export * from './browser';
|
||||
export * from './node';
|
||||
export * from './define-extension';
|
||||
export * from './generate-joi';
|
||||
export * from './get-filter-operators-for-type';
|
||||
export * from './is-extension';
|
||||
export * from './pluralize';
|
||||
export * from './validate-extension-manifest';
|
||||
export * from './validate-payload';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { API_EXTENSION_TYPES, APP_EXTENSION_TYPES, EXTENSION_PACKAGE_TYPES, EXTENSION_TYPES } from '../../constants';
|
||||
import { ApiExtensionType, AppExtensionType, ExtensionPackageType, ExtensionType } from '../../types';
|
||||
import { API_EXTENSION_TYPES, APP_EXTENSION_TYPES, EXTENSION_PACKAGE_TYPES, EXTENSION_TYPES } from '../constants';
|
||||
import { ApiExtensionType, AppExtensionType, ExtensionPackageType, ExtensionType } from '../types';
|
||||
|
||||
export function isExtension(type: string): type is ExtensionType {
|
||||
return (EXTENSION_TYPES as readonly string[]).includes(type);
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'path';
|
||||
import fse from 'fs-extra';
|
||||
import { pluralize } from '../browser';
|
||||
import { pluralize } from '../pluralize';
|
||||
import { EXTENSION_TYPES } from '../../constants';
|
||||
|
||||
export async function ensureExtensionDirs(extensionsPath: string): Promise<void> {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { Extension, ExtensionManifestRaw } from '../../types';
|
||||
import { resolvePackage } from './resolve-package';
|
||||
import { listFolders } from './list-folders';
|
||||
import { EXTENSION_NAME_REGEX, EXTENSION_PKG_KEY, EXTENSION_TYPES } from '../../constants';
|
||||
import { pluralize, validateExtensionManifest } from '../browser';
|
||||
import { pluralize } from '../pluralize';
|
||||
import { validateExtensionManifest } from '../validate-extension-manifest';
|
||||
|
||||
export async function getPackageExtensions(root: string): Promise<Extension[]> {
|
||||
let pkg: { dependencies?: Record<string, string> };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Plural } from '../../types';
|
||||
import { Plural } from '../types';
|
||||
|
||||
export function pluralize<T extends string>(str: T): Plural<T> {
|
||||
return `${str}s`;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { EXTENSION_PKG_KEY } from '../../constants';
|
||||
import { ExtensionManifest, ExtensionManifestRaw } from '../../types';
|
||||
import { EXTENSION_PKG_KEY } from '../constants';
|
||||
import { ExtensionManifest, ExtensionManifestRaw } from '../types';
|
||||
import { isExtensionPackage } from './is-extension';
|
||||
|
||||
export function validateExtensionManifest(
|
||||
58
packages/shared/src/utils/validate-payload.ts
Normal file
58
packages/shared/src/utils/validate-payload.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FieldFilter, Filter } from '../types/filter';
|
||||
import { flatten } from 'lodash';
|
||||
import { generateJoi, JoiOptions } from './generate-joi';
|
||||
import Joi from 'joi';
|
||||
|
||||
/**
|
||||
* Validate the payload against the given filter rules
|
||||
*
|
||||
* @param {Filter} filter - The filter rules to check against
|
||||
* @param {Record<string, any>} payload - The payload to validate
|
||||
* @param {JoiOptions} [options] - Optional options to pass to Joi
|
||||
* @returns Array of errors
|
||||
*/
|
||||
export function validatePayload(
|
||||
filter: Filter,
|
||||
payload: Record<string, any>,
|
||||
options?: JoiOptions
|
||||
): Joi.ValidationError[] {
|
||||
const errors: Joi.ValidationError[] = [];
|
||||
|
||||
/**
|
||||
* Note there can only be a single _and / _or per level
|
||||
*/
|
||||
|
||||
if (Object.keys(filter)[0] === '_and') {
|
||||
const subValidation = Object.values(filter)[0] as FieldFilter[];
|
||||
|
||||
const nestedErrors = flatten<Joi.ValidationError>(
|
||||
subValidation.map((subObj: Record<string, any>) => {
|
||||
return validatePayload(subObj, payload);
|
||||
})
|
||||
).filter((err?: Joi.ValidationError) => err);
|
||||
|
||||
errors.push(...nestedErrors);
|
||||
} else if (Object.keys(filter)[0] === '_or') {
|
||||
const subValidation = Object.values(filter)[0] as FieldFilter[];
|
||||
|
||||
const nestedErrors = flatten<Joi.ValidationError>(
|
||||
subValidation.map((subObj: Record<string, any>) => validatePayload(subObj, payload))
|
||||
);
|
||||
|
||||
const allErrored = subValidation.length === nestedErrors.length;
|
||||
|
||||
if (allErrored) {
|
||||
errors.push(...nestedErrors);
|
||||
}
|
||||
} else {
|
||||
const schema = generateJoi(filter, options);
|
||||
|
||||
const { error } = schema.validate(payload, { abortEarly: false });
|
||||
|
||||
if (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
1
packages/shared/utils/node.d.ts
vendored
Normal file
1
packages/shared/utils/node.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export * from '../dist/esm/utils/node';
|
||||
Reference in New Issue
Block a user