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:
Rijk van Zanten
2021-07-27 00:02:24 +02:00
committed by GitHub
parent 47e9d2f1fe
commit 92e1ee77bd
121 changed files with 792 additions and 261 deletions

View File

@@ -5,5 +5,5 @@ export {
defineModule,
defineHook,
defineEndpoint,
} from '@directus/shared/utils/browser';
} from '@directus/shared/utils';
export { useLayoutState } from '@directus/shared/composables';

View File

@@ -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"
},

View 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;

View File

@@ -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>;
};

View 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;
};

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {};

View File

@@ -5,7 +5,7 @@ import {
ModuleConfig,
HookRegisterFunction,
EndpointRegisterFunction,
} from '../../types';
} from '../types';
export function defineInterface(config: InterfaceConfig): InterfaceConfig {
return config;

View 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();
}

View 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',
];
}
}

View File

@@ -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';

View File

@@ -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);

View File

@@ -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> {

View File

@@ -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> };

View File

@@ -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`;

View File

@@ -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(

View 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
View File

@@ -0,0 +1 @@
export * from '../dist/esm/utils/node';