diff --git a/api/src/database/migrations/20210803A-add-required-to-fields.ts b/api/src/database/migrations/20210803A-add-required-to-fields.ts new file mode 100644 index 0000000000..352496d07a --- /dev/null +++ b/api/src/database/migrations/20210803A-add-required-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.boolean('required').defaultTo(false); + }); +} + +export async function down(knex: Knex): Promise { + await knex.schema.alterTable('directus_fields', (table) => { + table.dropColumn('required'); + }); +} diff --git a/api/src/exceptions/database/contains-null-values.ts b/api/src/exceptions/database/contains-null-values.ts index 67fa622e29..a1456e291e 100644 --- a/api/src/exceptions/database/contains-null-values.ts +++ b/api/src/exceptions/database/contains-null-values.ts @@ -1,4 +1,4 @@ -import { BaseException } from '../base'; +import { BaseException } from '@directus/shared/exceptions'; type Exceptions = { collection: string; diff --git a/api/src/exceptions/database/invalid-foreign-key.ts b/api/src/exceptions/database/invalid-foreign-key.ts index 7102538757..f95750b3d3 100644 --- a/api/src/exceptions/database/invalid-foreign-key.ts +++ b/api/src/exceptions/database/invalid-foreign-key.ts @@ -1,4 +1,4 @@ -import { BaseException } from '../base'; +import { BaseException } from '@directus/shared/exceptions'; type Extensions = { collection: string; diff --git a/api/src/exceptions/database/not-null-violation.ts b/api/src/exceptions/database/not-null-violation.ts index e857c76d3a..154eda3d57 100644 --- a/api/src/exceptions/database/not-null-violation.ts +++ b/api/src/exceptions/database/not-null-violation.ts @@ -1,4 +1,4 @@ -import { BaseException } from '../base'; +import { BaseException } from '@directus/shared/exceptions'; type Exceptions = { collection: string; diff --git a/api/src/exceptions/database/record-not-unique.ts b/api/src/exceptions/database/record-not-unique.ts index 2bbb68cfff..47464b9fa8 100644 --- a/api/src/exceptions/database/record-not-unique.ts +++ b/api/src/exceptions/database/record-not-unique.ts @@ -1,4 +1,4 @@ -import { BaseException } from '../base'; +import { BaseException } from '@directus/shared/exceptions'; type Extensions = { collection: string; diff --git a/api/src/exceptions/database/value-out-of-range.ts b/api/src/exceptions/database/value-out-of-range.ts index e238274377..48f42de9f5 100644 --- a/api/src/exceptions/database/value-out-of-range.ts +++ b/api/src/exceptions/database/value-out-of-range.ts @@ -1,4 +1,4 @@ -import { BaseException } from '../base'; +import { BaseException } from '@directus/shared/exceptions'; type Exceptions = { collection: string; diff --git a/api/src/exceptions/database/value-too-long.ts b/api/src/exceptions/database/value-too-long.ts index 4d27b67099..0c0bfabf49 100644 --- a/api/src/exceptions/database/value-too-long.ts +++ b/api/src/exceptions/database/value-too-long.ts @@ -1,4 +1,4 @@ -import { BaseException } from '../base'; +import { BaseException } from '@directus/shared/exceptions'; type Extensions = { collection: string; diff --git a/api/src/exceptions/forbidden.ts b/api/src/exceptions/forbidden.ts index 4b464d7e00..fd969abb05 100644 --- a/api/src/exceptions/forbidden.ts +++ b/api/src/exceptions/forbidden.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class ForbiddenException extends BaseException { constructor() { diff --git a/api/src/exceptions/graphql-validation.ts b/api/src/exceptions/graphql-validation.ts index 09ddb81a29..dc193b9db9 100644 --- a/api/src/exceptions/graphql-validation.ts +++ b/api/src/exceptions/graphql-validation.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class GraphQLValidationException extends BaseException { constructor(extensions: Record) { diff --git a/api/src/exceptions/hit-rate-limit.ts b/api/src/exceptions/hit-rate-limit.ts index 25f2f61663..077f5c0193 100644 --- a/api/src/exceptions/hit-rate-limit.ts +++ b/api/src/exceptions/hit-rate-limit.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; type Extensions = { limit: number; diff --git a/api/src/exceptions/illegal-asset-transformation.ts b/api/src/exceptions/illegal-asset-transformation.ts index 8b964cc433..0dbc174354 100644 --- a/api/src/exceptions/illegal-asset-transformation.ts +++ b/api/src/exceptions/illegal-asset-transformation.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class IllegalAssetTransformation extends BaseException { constructor(message: string) { diff --git a/api/src/exceptions/index.ts b/api/src/exceptions/index.ts index c55b89d4eb..d00eaf78ba 100644 --- a/api/src/exceptions/index.ts +++ b/api/src/exceptions/index.ts @@ -1,5 +1,3 @@ -export * from './base'; -export * from './failed-validation'; export * from './forbidden'; export * from './graphql-validation'; export * from './hit-rate-limit'; diff --git a/api/src/exceptions/invalid-credentials.ts b/api/src/exceptions/invalid-credentials.ts index 015cd3b012..cfdfc258b0 100644 --- a/api/src/exceptions/invalid-credentials.ts +++ b/api/src/exceptions/invalid-credentials.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class InvalidCredentialsException extends BaseException { constructor(message = 'Invalid user credentials.') { diff --git a/api/src/exceptions/invalid-ip.ts b/api/src/exceptions/invalid-ip.ts index 4709418d5f..73ca7d068d 100644 --- a/api/src/exceptions/invalid-ip.ts +++ b/api/src/exceptions/invalid-ip.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class InvalidIPException extends BaseException { constructor(message = 'Invalid IP address.') { diff --git a/api/src/exceptions/invalid-otp.ts b/api/src/exceptions/invalid-otp.ts index da7d736cc0..13e2db07f8 100644 --- a/api/src/exceptions/invalid-otp.ts +++ b/api/src/exceptions/invalid-otp.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class InvalidOTPException extends BaseException { constructor(message = 'Invalid user OTP.') { diff --git a/api/src/exceptions/invalid-payload.ts b/api/src/exceptions/invalid-payload.ts index b041444e8b..d40382f87b 100644 --- a/api/src/exceptions/invalid-payload.ts +++ b/api/src/exceptions/invalid-payload.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class InvalidPayloadException extends BaseException { constructor(message: string, extensions?: Record) { diff --git a/api/src/exceptions/invalid-query.ts b/api/src/exceptions/invalid-query.ts index 0419ee0536..fd7ed5f134 100644 --- a/api/src/exceptions/invalid-query.ts +++ b/api/src/exceptions/invalid-query.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class InvalidQueryException extends BaseException { constructor(message: string) { diff --git a/api/src/exceptions/method-not-allowed.ts b/api/src/exceptions/method-not-allowed.ts index 6d43769bc2..656d92d43b 100644 --- a/api/src/exceptions/method-not-allowed.ts +++ b/api/src/exceptions/method-not-allowed.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; type Extensions = { allow: string[]; diff --git a/api/src/exceptions/range-not-satisfiable.ts b/api/src/exceptions/range-not-satisfiable.ts index 6b169dde0e..437e0fac7e 100644 --- a/api/src/exceptions/range-not-satisfiable.ts +++ b/api/src/exceptions/range-not-satisfiable.ts @@ -1,5 +1,5 @@ import { Range } from '@directus/drive'; -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class RangeNotSatisfiableException extends BaseException { constructor(range: Range) { diff --git a/api/src/exceptions/route-not-found.ts b/api/src/exceptions/route-not-found.ts index 6f054af4b1..d225dba10b 100644 --- a/api/src/exceptions/route-not-found.ts +++ b/api/src/exceptions/route-not-found.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class RouteNotFoundException extends BaseException { constructor(path: string) { diff --git a/api/src/exceptions/service-unavailable.ts b/api/src/exceptions/service-unavailable.ts index f425f100d2..cd8a39860e 100644 --- a/api/src/exceptions/service-unavailable.ts +++ b/api/src/exceptions/service-unavailable.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; type Extensions = { service: string; diff --git a/api/src/exceptions/unprocessable-entity.ts b/api/src/exceptions/unprocessable-entity.ts index 6fe580d5ea..5a6dc25f30 100644 --- a/api/src/exceptions/unprocessable-entity.ts +++ b/api/src/exceptions/unprocessable-entity.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class UnprocessableEntityException extends BaseException { constructor(message: string) { diff --git a/api/src/exceptions/user-suspended.ts b/api/src/exceptions/user-suspended.ts index 4267fc6ae9..9bd5ae6d89 100644 --- a/api/src/exceptions/user-suspended.ts +++ b/api/src/exceptions/user-suspended.ts @@ -1,4 +1,4 @@ -import { BaseException } from './base'; +import { BaseException } from '@directus/shared/exceptions'; export class UserSuspendedException extends BaseException { constructor(message = 'User suspended.') { diff --git a/api/src/middleware/error-handler.ts b/api/src/middleware/error-handler.ts index 24a61390ad..af985938d7 100644 --- a/api/src/middleware/error-handler.ts +++ b/api/src/middleware/error-handler.ts @@ -1,7 +1,8 @@ import { ErrorRequestHandler } from 'express'; import { emitAsyncSafe } from '../emitter'; import env from '../env'; -import { BaseException, MethodNotAllowedException } from '../exceptions'; +import { MethodNotAllowedException } from '../exceptions'; +import { BaseException } from '@directus/shared/exceptions'; import logger from '../logger'; import { toArray } from '../utils/to-array'; diff --git a/api/src/services/authorization.ts b/api/src/services/authorization.ts index 2b0d1cbd8b..f69aae38e8 100644 --- a/api/src/services/authorization.ts +++ b/api/src/services/authorization.ts @@ -1,13 +1,14 @@ import { Knex } from 'knex'; -import { cloneDeep, flatten, merge, uniq, uniqWith } from 'lodash'; +import { cloneDeep, merge, uniq, uniqWith, flatten } from 'lodash'; import getDatabase from '../database'; -import { FailedValidationException, ForbiddenException } from '../exceptions'; +import { ForbiddenException } from '../exceptions'; +import { FailedValidationException } from '@directus/shared/exceptions'; +import { validatePayload } from '@directus/shared/utils'; import { AbstractServiceOptions, Accountability, AST, FieldNode, - Filter, Item, NestedCollectionNode, Permission, @@ -16,7 +17,6 @@ import { Query, SchemaOverview, } from '../types'; -import generateJoi from '../utils/generate-joi'; import { parseFilter } from '../utils/parse-filter'; import { ItemsService } from './items'; import { PayloadService } from './payload'; @@ -226,44 +226,53 @@ export class AuthorizationService { const payloadWithPresets = merge({}, preset, payload); - const requiredColumns: string[] = []; + const requiredColumns: SchemaOverview['collections'][string]['fields'][string][] = []; - for (const [name, field] of Object.entries(this.schema.collections[collection].fields)) { + for (const field of Object.values(this.schema.collections[collection].fields)) { const specials = field?.special ?? []; const hasGenerateSpecial = ['uuid', 'date-created', 'role-created', 'user-created'].some((name) => specials.includes(name) ); - const isRequired = field.nullable === false && field.defaultValue === null && hasGenerateSpecial === false; + const notNullable = field.nullable === false && hasGenerateSpecial === false; - if (isRequired) { - requiredColumns.push(name); + if (notNullable) { + requiredColumns.push(field); } } if (requiredColumns.length > 0) { - permission.validation = { - _and: [permission.validation, {}], - }; + permission.validation = + permission.validation && Object.keys(permission.validation).length > 0 + ? { + _and: [permission.validation], + } + : { _and: [] }; - if (action === 'create') { - for (const name of requiredColumns) { - permission.validation._and[1][name] = { - _submitted: true, - }; + for (const field of requiredColumns) { + if (action === 'create' && field.defaultValue === null) { + permission.validation._and.push({ + [field.field]: { + _submitted: true, + }, + }); } - } else { - for (const name of requiredColumns) { - permission.validation._and[1][name] = { + + permission.validation._and.push({ + [field.field]: { _nnull: true, - }; - } + }, + }); } } validationErrors.push( - ...this.validateJoi(parseFilter(permission.validation || {}, this.accountability), payloadWithPresets) + ...flatten( + validatePayload(parseFilter(permission.validation || {}, this.accountability), payloadWithPresets).map( + (error) => error.details.map((details) => new FailedValidationException(details)) + ) + ) ); if (validationErrors.length > 0) throw validationErrors; @@ -271,48 +280,6 @@ export class AuthorizationService { return payloadWithPresets; } - validateJoi(validation: Filter, payload: Partial): FailedValidationException[] { - if (!validation) return []; - - const errors: FailedValidationException[] = []; - - /** - * Note there can only be a single _and / _or per level - */ - - if (Object.keys(validation)[0] === '_and') { - const subValidation = Object.values(validation)[0]; - - const nestedErrors = flatten( - subValidation.map((subObj: Record) => { - return this.validateJoi(subObj, payload); - }) - ).filter((err?: FailedValidationException) => err); - errors.push(...nestedErrors); - } else if (Object.keys(validation)[0] === '_or') { - const subValidation = Object.values(validation)[0]; - const nestedErrors = flatten( - subValidation.map((subObj: Record) => this.validateJoi(subObj, payload)) - ); - - const allErrored = subValidation.length === nestedErrors.length; - - if (allErrored) { - errors.push(...nestedErrors); - } - } else { - const schema = generateJoi(validation); - - const { error } = schema.validate(payload, { abortEarly: false }); - - if (error) { - errors.push(...error.details.map((details) => new FailedValidationException(details))); - } - } - - return errors; - } - async checkAccess(action: PermissionsAction, collection: string, pk: PrimaryKey | PrimaryKey[]): Promise { if (this.accountability?.admin === true) return; diff --git a/api/src/services/graphql.ts b/api/src/services/graphql.ts index d37d4febb8..fe40b14fc0 100644 --- a/api/src/services/graphql.ts +++ b/api/src/services/graphql.ts @@ -47,7 +47,8 @@ import ms from 'ms'; import { getCache } from '../cache'; import getDatabase from '../database'; import env from '../env'; -import { BaseException, ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions'; +import { ForbiddenException, GraphQLValidationException, InvalidPayloadException } from '../exceptions'; +import { BaseException } from '@directus/shared/exceptions'; import { listExtensions } from '../extensions'; import { AbstractServiceOptions, Accountability, Action, GraphQLParams, Item, Query, SchemaOverview } from '../types'; import { getGraphQLType } from '../utils/get-graphql-type'; diff --git a/api/src/types/query.ts b/api/src/types/query.ts index b93d113bf2..70af5e1fcc 100644 --- a/api/src/types/query.ts +++ b/api/src/types/query.ts @@ -37,5 +37,3 @@ export type FilterOperator = | 'nnull' | 'empty' | 'nempty'; - -export type ValidationOperator = 'required' | 'regex'; diff --git a/api/src/utils/get-schema.ts b/api/src/utils/get-schema.ts index f4102510f0..06b775dbf7 100644 --- a/api/src/utils/get-schema.ts +++ b/api/src/utils/get-schema.ts @@ -132,13 +132,9 @@ async function getDatabaseSchema( const fields = [ ...(await database - .select<{ id: number; collection: string; field: string; special: string; note: string | null }[]>( - 'id', - 'collection', - 'field', - 'special', - 'note' - ) + .select< + { id: number; collection: string; field: string; required: boolean; special: string; note: string | null }[] + >('id', 'collection', 'field', 'required', 'special', 'note') .from('directus_fields')), ...systemFieldRows, ].filter((field) => (field.special ? toArray(field.special) : []).includes('no-data') === false); diff --git a/app/src/components/v-form/form-field-label.vue b/app/src/components/v-form/form-field-label.vue index 223de4ad5d..423520d7cc 100644 --- a/app/src/components/v-form/form-field-label.vue +++ b/app/src/components/v-form/form-field-label.vue @@ -8,7 +8,7 @@ /> {{ field.name }} - + diff --git a/app/src/components/v-form/v-form.vue b/app/src/components/v-form/v-form.vue index c5f71eee35..1b93c7f1fc 100644 --- a/app/src/components/v-form/v-form.vue +++ b/app/src/components/v-form/v-form.vue @@ -154,7 +154,7 @@ export default defineComponent({ const firstEditableFieldIndex = computed(() => { for (let i = 0; i < formFields.value.length; i++) { - if (formFields.value[i].meta && !formFields.value[i].meta.readonly) { + if (formFields.value[i].meta && !formFields.value[i].meta?.readonly) { return i; } } @@ -167,8 +167,18 @@ export default defineComponent({ * admin can be made aware */ const unknownValidationErrors = computed(() => { + const fieldsInGroup = getFieldsForGroup(props.group); + const fieldsInGroupKeys = fieldsInGroup.map((field) => field.field); const fieldKeys = formFields.value.map((field: FieldRaw) => field.field); - return props.validationErrors.filter((error) => fieldKeys.includes(error.field) === false); + return props.validationErrors.filter((error) => { + let included = fieldKeys.includes(error.field) === false && fieldsInGroupKeys.includes(error.field); + + if (props.group === null) { + included = included && fieldsInGroup.find((field) => field.field === error.field)?.meta?.group === null; + } + + return included; + }); }); provide('values', values); @@ -237,6 +247,7 @@ export default defineComponent({ readonly: matchingCondition.readonly, options: matchingCondition.options, hidden: matchingCondition.hidden, + required: matchingCondition.required, }), }; } diff --git a/app/src/composables/use-item/use-item.ts b/app/src/composables/use-item/use-item.ts index 8239d1eb12..cbfca15db3 100644 --- a/app/src/composables/use-item/use-item.ts +++ b/app/src/composables/use-item/use-item.ts @@ -7,6 +7,10 @@ import { notify } from '@/utils/notify'; import { unexpectedError } from '@/utils/unexpected-error'; import { AxiosResponse } from 'axios'; import { computed, ComputedRef, Ref, ref, watch } from 'vue'; +import { validatePayload } from '@directus/shared/utils'; +import { Filter, Item, Field } from '@directus/shared/types'; +import { isNil, flatten, merge } from 'lodash'; +import { FailedValidationException } from '@directus/shared/exceptions'; type UsableItem = { edits: Ref>; @@ -29,8 +33,7 @@ type UsableItem = { }; export function useItem(collection: Ref, primaryKey: Ref): UsableItem { - const { info: collectionInfo, primaryKeyField } = useCollection(collection); - + const { info: collectionInfo, primaryKeyField, fields } = useCollection(collection); const item = ref | null>(null); const error = ref(null); const validationErrors = ref([]); @@ -107,6 +110,14 @@ export function useItem(collection: Ref, primaryKey: Ref 0) { + validationErrors.value = errors; + saving.value = false; + throw errors; + } + try { let response; @@ -172,6 +183,14 @@ export function useItem(collection: Ref, primaryKey: Ref 0) { + validationErrors.value = errors; + saving.value = false; + throw errors; + } + try { const response = await api.post(endpoint.value, newItem); @@ -292,4 +311,63 @@ export function useItem(collection: Ref, primaryKey: Ref { + if (field.meta && Array.isArray(field.meta?.conditions)) { + const conditions = [...field.meta.conditions].reverse(); + + const matchingCondition = conditions.find((condition) => { + const errors = validatePayload(condition.rule, item, { requireAll: true }); + return errors.length === 0; + }); + + if (matchingCondition) { + return { + ...field, + meta: merge({}, field.meta || {}, { + readonly: matchingCondition.readonly, + options: matchingCondition.options, + hidden: matchingCondition.hidden, + required: matchingCondition.required, + }), + }; + } + + return field; + } else { + return field; + } + }; + + const fieldsWithConditions = fields.value.map((field) => applyConditions(field)); + + const requiredFields = fieldsWithConditions.filter((field) => field.meta?.required === true); + + for (const field of requiredFields) { + if (isNew.value === true && isNil(field.schema?.default_value)) { + validationRules._and!.push({ + [field.field]: { + _submitted: true, + }, + }); + } + + validationRules._and!.push({ + [field.field]: { + _nnull: true, + }, + }); + } + + return flatten( + validatePayload(validationRules, item).map((error) => + error.details.map((details) => new FailedValidationException(details).extensions) + ) + ); + } } diff --git a/app/src/lang/translations/en-US.yaml b/app/src/lang/translations/en-US.yaml index 665be96354..4560c36de5 100644 --- a/app/src/lang/translations/en-US.yaml +++ b/app/src/lang/translations/en-US.yaml @@ -449,6 +449,7 @@ errors: UNKNOWN: Unexpected Error UNPROCESSABLE_ENTITY: Unprocessable entity INTERNAL_SERVER_ERROR: Unexpected Error + NOT_NULL_VIOLATION: Value can't be null value_hashed: Value Securely Hashed bookmark_name: Bookmark name... create_bookmark: Create Bookmark @@ -905,6 +906,7 @@ sort_direction: Sort Direction sort_asc: Sort Ascending sort_desc: Sort Descending template: Template +require_value_to_be_set: Require value to be set translation: Translation value: Value view_project: View Project diff --git a/app/src/modules/settings/routes/data-model/field-detail/components/conditions.vue b/app/src/modules/settings/routes/data-model/field-detail/components/conditions.vue index b188f303be..6fcbb04f9e 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/components/conditions.vue +++ b/app/src/modules/settings/routes/data-model/field-detail/components/conditions.vue @@ -82,6 +82,18 @@ export default defineComponent({ width: 'half', }, }, + { + field: 'required', + name: t('required'), + type: 'boolean', + meta: { + interface: 'boolean', + options: { + label: t('require_value_to_be_set'), + }, + width: 'half', + }, + }, { field: 'options', name: t('interface_options'), diff --git a/app/src/modules/settings/routes/data-model/field-detail/components/field.vue b/app/src/modules/settings/routes/data-model/field-detail/components/field.vue index 7b8d13db2f..b3e33a7f74 100644 --- a/app/src/modules/settings/routes/data-model/field-detail/components/field.vue +++ b/app/src/modules/settings/routes/data-model/field-detail/components/field.vue @@ -11,6 +11,11 @@ +
+
{{ t('required') }}
+ +
+
{{ t('note') }}
diff --git a/app/src/modules/settings/routes/data-model/fields/components/field-select.vue b/app/src/modules/settings/routes/data-model/fields/components/field-select.vue index fe89695917..970cc9683e 100644 --- a/app/src/modules/settings/routes/data-model/fields/components/field-select.vue +++ b/app/src/modules/settings/routes/data-model/fields/components/field-select.vue @@ -57,7 +57,7 @@
{{ field.field }} - + {{ interfaceName }} {{ t('db_only_click_to_configure') }} diff --git a/packages/shared/exceptions.d.ts b/packages/shared/exceptions.d.ts new file mode 100644 index 0000000000..35e231f56e --- /dev/null +++ b/packages/shared/exceptions.d.ts @@ -0,0 +1 @@ +export * from './dist/esm/exceptions'; diff --git a/packages/shared/package.json b/packages/shared/package.json index aedf60a394..c4e056ef60 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -26,6 +26,10 @@ "./utils/node": { "import": "./dist/esm/utils/node/index.js", "require": "./dist/cjs/utils/node/index.js" + }, + "./exceptions": { + "import": "./dist/esm/exceptions/index.js", + "require": "./dist/cjs/exceptions/index.js" } }, "scripts": { diff --git a/api/src/exceptions/base.ts b/packages/shared/src/exceptions/base.ts similarity index 100% rename from api/src/exceptions/base.ts rename to packages/shared/src/exceptions/base.ts diff --git a/api/src/exceptions/failed-validation.ts b/packages/shared/src/exceptions/failed-validation.ts similarity index 95% rename from api/src/exceptions/failed-validation.ts rename to packages/shared/src/exceptions/failed-validation.ts index e13011bebc..895c582b3c 100644 --- a/api/src/exceptions/failed-validation.ts +++ b/packages/shared/src/exceptions/failed-validation.ts @@ -1,10 +1,10 @@ import { ValidationErrorItem } from 'joi'; -import { FilterOperator, ValidationOperator } from '../types'; +import { FilterOperator } from '../types'; import { BaseException } from './base'; type FailedValidationExtensions = { field: string; - type: FilterOperator | ValidationOperator; + type: FilterOperator | 'required' | 'regex'; valid?: number | string | (number | string)[]; invalid?: number | string | (number | string)[]; substring?: string; diff --git a/packages/shared/src/exceptions/index.ts b/packages/shared/src/exceptions/index.ts new file mode 100644 index 0000000000..ee12c5afa1 --- /dev/null +++ b/packages/shared/src/exceptions/index.ts @@ -0,0 +1,2 @@ +export * from './base'; +export * from './failed-validation'; diff --git a/packages/shared/src/types/fields.ts b/packages/shared/src/types/fields.ts index 265075de13..73967cebd7 100644 --- a/packages/shared/src/types/fields.ts +++ b/packages/shared/src/types/fields.ts @@ -24,6 +24,7 @@ export type FieldMeta = { options: Record | null; display_options: Record | null; readonly: boolean; + required: boolean; sort: number | null; special: string[] | null; translations: Translations[] | null; @@ -62,4 +63,5 @@ export type Condition = { readonly?: boolean; hidden?: boolean; options?: Record; + required?: boolean; }; diff --git a/packages/shared/src/types/filter.ts b/packages/shared/src/types/filter.ts index 67202c06bf..a99c080e2b 100644 --- a/packages/shared/src/types/filter.ts +++ b/packages/shared/src/types/filter.ts @@ -24,7 +24,7 @@ export type Filter = FieldFilter & { }; export type FieldFilter = { - [field: string]: FieldFilterOperator | FieldFilter; + [field: string]: FieldFilterOperator | FieldValidationOperator | FieldFilter; }; export type FieldFilterOperator = { @@ -45,3 +45,8 @@ export type FieldFilterOperator = { _empty?: boolean; _nempty?: boolean; }; + +export type FieldValidationOperator = { + _submitted?: boolean; + _regex?: string; +}; diff --git a/packages/shared/src/utils/generate-joi.ts b/packages/shared/src/utils/generate-joi.ts index 49c682cdc1..c312afb82f 100644 --- a/packages/shared/src/utils/generate-joi.ts +++ b/packages/shared/src/utils/generate-joi.ts @@ -93,97 +93,107 @@ export function generateJoi(filter: FieldFilter, options?: JoiOptions): AnySchem }); } else { const operator = Object.keys(value)[0]; + const compareValue = Object.values(value)[0]; if (operator === '_eq') { - schema[key] = Joi.any().equal(Object.values(value)[0]); + schema[key] = (schema[key] ?? Joi.any()).equal(compareValue); } if (operator === '_neq') { - schema[key] = Joi.any().not(Object.values(value)[0]); + schema[key] = (schema[key] ?? Joi.any()).not(compareValue); } if (operator === '_contains') { - schema[key] = Joi.string().contains(Object.values(value)[0]); + schema[key] = (schema[key] ?? Joi.string()).contains(compareValue); } if (operator === '_ncontains') { - schema[key] = Joi.string().ncontains(Object.values(value)[0]); + schema[key] = (schema[key] ?? Joi.string()).ncontains(compareValue); } if (operator === '_starts_with') { - schema[key] = Joi.string().pattern(new RegExp(`^${escapeRegExp(Object.values(value)[0] as string)}.*`), { + schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`^${escapeRegExp(compareValue as string)}.*`), { name: 'starts_with', }); } if (operator === '_nstarts_with') { - schema[key] = Joi.string().pattern(new RegExp(`^${escapeRegExp(Object.values(value)[0] as string)}.*`), { + schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`^${escapeRegExp(compareValue 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)}$`), { + schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`.*${escapeRegExp(compareValue as string)}$`), { name: 'ends_with', }); } if (operator === '_nends_with') { - schema[key] = Joi.string().pattern(new RegExp(`.*${escapeRegExp(Object.values(value)[0] as string)}$`), { + schema[key] = (schema[key] ?? Joi.string()).pattern(new RegExp(`.*${escapeRegExp(compareValue as string)}$`), { name: 'ends_with', invert: true, }); } if (operator === '_in') { - schema[key] = Joi.any().equal(...(Object.values(value)[0] as (string | number)[])); + schema[key] = (schema[key] ?? Joi.any()).equal(...(compareValue as (string | number)[])); } if (operator === '_nin') { - schema[key] = Joi.any().not(...(Object.values(value)[0] as (string | number)[])); + schema[key] = (schema[key] ?? Joi.any()).not(...(compareValue as (string | number)[])); } if (operator === '_gt') { - schema[key] = Joi.number().greater(Number(Object.values(value)[0])); + schema[key] = (schema[key] ?? Joi.number()).greater(Number(compareValue)); } if (operator === '_gte') { - schema[key] = Joi.number().min(Number(Object.values(value)[0])); + schema[key] = (schema[key] ?? Joi.number()).min(Number(compareValue)); } if (operator === '_lt') { - schema[key] = Joi.number().less(Number(Object.values(value)[0])); + schema[key] = (schema[key] ?? Joi.number()).less(Number(compareValue)); } if (operator === '_lte') { - schema[key] = Joi.number().max(Number(Object.values(value)[0])); + schema[key] = (schema[key] ?? Joi.number()).max(Number(compareValue)); } if (operator === '_null') { - schema[key] = Joi.any().valid(null); + schema[key] = (schema[key] ?? Joi.any()).valid(null); } if (operator === '_nnull') { - schema[key] = Joi.any().invalid(null); + schema[key] = (schema[key] ?? Joi.any()).invalid(null); } if (operator === '_empty') { - schema[key] = Joi.any().valid(''); + schema[key] = (schema[key] ?? Joi.any()).valid(''); } if (operator === '_nempty') { - schema[key] = Joi.any().invalid(''); + schema[key] = (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]); + const values = compareValue as number[]; + schema[key] = (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]); + const values = compareValue as number[]; + schema[key] = (schema[key] ?? Joi.number()).less(values[0]).greater(values[1]); + } + + if (operator === '_submitted') { + schema[key] = (schema[key] ?? Joi.any()).required(); + } + + if (operator === '_regex') { + const wrapped = compareValue.startsWith('/') && compareValue.endsWith('/'); + schema[key] = (schema[key] ?? Joi.string()).regex(new RegExp(wrapped ? compareValue.slice(1, -1) : compareValue)); } }