From d05beef30c2de40bd45ca35863b4fd0ed7a072c1 Mon Sep 17 00:00:00 2001 From: Katrin Khilko Date: Thu, 19 Sep 2024 16:55:57 +0300 Subject: [PATCH 1/2] FIO-8954: added Allow only available values validation for Data Source Type = URL --- .../__tests__/validateAvailableItems.test.ts | 103 ++++++++++++++ .../validation/rules/asynchronousRules.ts | 2 + .../rules/validateAvailableItems.ts | 4 +- .../rules/validateAvailableItemsUrl.ts | 134 ++++++++++++++++++ 4 files changed, 241 insertions(+), 2 deletions(-) create mode 100644 src/process/validation/rules/validateAvailableItemsUrl.ts diff --git a/src/process/validation/rules/__tests__/validateAvailableItems.test.ts b/src/process/validation/rules/__tests__/validateAvailableItems.test.ts index 9f0251c6..ba14d559 100644 --- a/src/process/validation/rules/__tests__/validateAvailableItems.test.ts +++ b/src/process/validation/rules/__tests__/validateAvailableItems.test.ts @@ -10,6 +10,7 @@ import { } from './fixtures/components'; import { generateProcessorContext } from './fixtures/util'; import { validateAvailableItems, validateAvailableItemsSync } from '../validateAvailableItems'; +import { validateAvailableItemsUrl } from '../validateAvailableItemsUrl'; it('Validating a component without the available items validation parameter will return null', async () => { const component = simpleTextField; @@ -457,3 +458,105 @@ it('Validating a simple JSON select component (nested actual JSON with valueProp const result = await validateAvailableItems(context); expect(result).to.equal(null); }); + +it('Validating a simple radio component with url data source with the available items validation parameter will return null if the item is valid', async () => { + const component: RadioComponent = { + ...simpleRadioField, + dataSrc: 'url', + data: { + url: 'http://localhost:8080/numbers', + headers: [], + }, + validate: { onlyAvailableItems: true }, + }; + const data = { + component: '2', + }; + + const context = generateProcessorContext(component, data); + context.fetch = (url: string, options?: RequestInit | undefined) => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(['1', '2', '3']) + }); + }; + const result = await validateAvailableItemsUrl(context); + expect(result).to.equal(null); +}); + +it('Validating a simple radio component with url data source with the available items validation parameter will return FieldError if the item is invalid', async () => { + const component: RadioComponent = { + ...simpleRadioField, + dataSrc: 'url', + data: { + url: 'http://localhost:8080/numbers', + headers: [], + }, + validate: { onlyAvailableItems: true }, + }; + const data = { + component: '4', + }; + + const context = generateProcessorContext(component, data); + context.fetch = (url: string, options?: RequestInit | undefined) => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(['1', '2', '3']) + }); + }; + const result = await validateAvailableItemsUrl(context); + expect(result).to.be.instanceOf(FieldError); + expect(result?.errorKeyOrMessage).to.equal('invalidOption'); +}); + +it('Validating a simple select component with url data source with the available items validation parameter will return null if the item is valid', async () => { + const component: SelectComponent = { + ...simpleSelectOptions, + dataSrc: 'url', + data: { + url: 'http://localhost:8080/numbers', + headers: [], + }, + validate: { onlyAvailableItems: true }, + }; + const data = { + component: {'id': 'opt_1', 'value': 1}, + }; + + const context = generateProcessorContext(component, data); + context.fetch = (url: string, options?: RequestInit | undefined) => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{'id': 'opt_1', 'value': 1}, {'id': 'opt_2', 'value': 2}]) + }); + }; + const result = await validateAvailableItemsUrl(context); + expect(result).to.equal(null); +}); + +it('Validating a simple select component with url data source with the available items validation parameter will return FieldError if the item is invalid', async () => { + const component: SelectComponent = { + ...simpleSelectOptions, + dataSrc: 'url', + data: { + url: 'http://localhost:8080/numbers', + headers: [], + }, + validate: { onlyAvailableItems: true }, + }; + const data = { + component: {'id': 'opt_3', 'value': 3}, + }; + + const context = generateProcessorContext(component, data); + context.fetch = (url: string, options?: RequestInit | undefined) => { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve([{'id': 'opt_1', 'value': 1}, {'id': 'opt_2', 'value': 2}]) + }); + }; + const result = await validateAvailableItemsUrl(context); + expect(result).to.be.instanceOf(FieldError); + expect(result?.errorKeyOrMessage).to.equal('invalidOption'); +}); \ No newline at end of file diff --git a/src/process/validation/rules/asynchronousRules.ts b/src/process/validation/rules/asynchronousRules.ts index 4c0703d4..9028060a 100644 --- a/src/process/validation/rules/asynchronousRules.ts +++ b/src/process/validation/rules/asynchronousRules.ts @@ -1,7 +1,9 @@ import { ValidationRuleInfo } from "types"; import { validateUrlSelectValueInfo } from "./validateUrlSelectValue"; +import { validateAvailableItemsUrlInfo } from "./validateAvailableItemsUrl"; // These are the validations that are asynchronouse (e.g. require fetch export const asynchronousRules: ValidationRuleInfo[] = [ validateUrlSelectValueInfo, + validateAvailableItemsUrlInfo ]; diff --git a/src/process/validation/rules/validateAvailableItems.ts b/src/process/validation/rules/validateAvailableItems.ts index 5cad15c3..f5d00557 100644 --- a/src/process/validation/rules/validateAvailableItems.ts +++ b/src/process/validation/rules/validateAvailableItems.ts @@ -23,7 +23,7 @@ function isValidateableSelectComponent(component: any): component is SelectCompo ); } -function mapDynamicValues>(component: SelectComponent, values: T[]) { +export function mapDynamicValues>(component: SelectComponent, values: T[]) { return values.map((value) => { if (component.valueProperty) { return value[component.valueProperty]; @@ -173,7 +173,7 @@ function getAvailableSelectValuesSync(component: SelectComponent, context: Valid } } -function compareComplexValues(valueA: unknown, valueB: unknown, context: ValidationContext) { +export function compareComplexValues(valueA: unknown, valueB: unknown, context: ValidationContext) { if (!isObject(valueA) || !isObject(valueB)) { return false; } diff --git a/src/process/validation/rules/validateAvailableItemsUrl.ts b/src/process/validation/rules/validateAvailableItemsUrl.ts new file mode 100644 index 00000000..2c26fa60 --- /dev/null +++ b/src/process/validation/rules/validateAvailableItemsUrl.ts @@ -0,0 +1,134 @@ +import { isEmpty } from 'lodash'; +import { FieldError, ProcessorError } from 'error'; +import { RadioComponent, SelectComponent, RuleFn, RuleFnSync, ValidationContext, FetchFn, SelectBoxesComponent } from 'types'; +import { isObject } from '../util'; +import { ProcessorInfo } from 'types/process/ProcessorInfo'; +import { compareComplexValues, mapDynamicValues } from './validateAvailableItems'; +import { getErrorMessage } from 'utils/error'; + +function isValidatableRadioComponent(component: any): component is RadioComponent { + return ( + component && + component.type === 'radio' && + !!component.validate?.onlyAvailableItems && + component.dataSrc === 'url' + ); +} + +function isValidateableSelectComponent(component: any): component is SelectComponent { + return ( + component && + !!component.validate?.onlyAvailableItems && + component.type === 'select' && + component.dataSrc === 'url' + ); +} + +function isValidateableSelectBoxesComponent(component: any): component is SelectBoxesComponent { + return ( + component && + !!component.validate?.onlyAvailableItems && + component.type === 'selectboxes' && + component.dataSrc === 'url' + ); +} + +export const shouldValidate = (context: any) => { + const { component, value } = context; + if (value == null || isEmpty(value)) { + return false; + } + if (isValidatableRadioComponent(component)) { + return true; + } + if (isValidateableSelectComponent(component)) { + return true; + } + if (isValidateableSelectBoxesComponent(component)) { + return true; + } + return false; +} + +const getAvailableValues = async (component: any, context: ValidationContext) => { + let _fetch: FetchFn | null = null; + try { + _fetch = context.fetch ? context.fetch : fetch; + } + catch (err) { + _fetch = null; + } + try { + if (!_fetch) { + console.log('You must provide a fetch interface to the fetch processor.'); + return null; + } + const response = await _fetch((component as any).data.url, { method: 'GET' }); + const data = await response.json(); + return data ? mapDynamicValues(component, data) : null; + } + catch (err) { + console.error(getErrorMessage(err)); + return null; + } +} + +export const validateAvailableItemsUrl: RuleFn = async (context: ValidationContext) => { + const { component, value } = context; + const error = new FieldError('invalidOption', context, 'onlyAvailableItemsURL'); + try { + if (isValidatableRadioComponent(component)) { + if (value == null || isEmpty(value)) { + return null; + } + + const values = await getAvailableValues(component, context); + if (values) { + return values.findIndex((optionValue) => optionValue === value) !== -1 + ? null + : error; + } + + return null; + } else if (isValidateableSelectComponent(component)) { + if (value == null || isEmpty(value)) { + return null; + } + const values = await getAvailableValues(component, context); + if (values) { + if (isObject(value)) { + return values.find((optionValue) => compareComplexValues(optionValue, value, context)) !== + undefined + ? null + : error; + } + + return values.find((optionValue) => optionValue === value) !== undefined ? null : error; + } + } else if (isValidateableSelectBoxesComponent(component)) { + if (value == null || isEmpty(value)) { + return null; + } + const values = await getAvailableValues(component, context); + if (values) { + if (isObject(value)) { + return values.find((optionValue) => compareComplexValues(optionValue, value, context)) !== + undefined + ? null + : error; + } + + return values.find((optionValue) => optionValue === value) !== undefined ? null : error; + } + } + } catch (err: any) { + throw new ProcessorError(err.message || err, context, 'validate:validateAvailableItemsURL'); + } + return null; +}; + +export const validateAvailableItemsUrlInfo: ProcessorInfo = { + name: 'validateAvailableItemsUrl', + process: validateAvailableItemsUrl, + shouldProcess: shouldValidate, +} \ No newline at end of file From dd8879af5ae2f8352045c3ea6e70651e8f3da2c1 Mon Sep 17 00:00:00 2001 From: Katrin Khilko Date: Wed, 25 Sep 2024 16:46:31 +0300 Subject: [PATCH 2/2] FIO-8954: merged rule for dataSrc = URL into validateAvailableItems --- .../__tests__/validateAvailableItems.test.ts | 9 +- .../validation/rules/asynchronousRules.ts | 4 +- .../rules/validateAvailableItems.ts | 76 ++++++++-- .../rules/validateAvailableItemsUrl.ts | 134 ------------------ 4 files changed, 72 insertions(+), 151 deletions(-) delete mode 100644 src/process/validation/rules/validateAvailableItemsUrl.ts diff --git a/src/process/validation/rules/__tests__/validateAvailableItems.test.ts b/src/process/validation/rules/__tests__/validateAvailableItems.test.ts index ba14d559..cc5beaba 100644 --- a/src/process/validation/rules/__tests__/validateAvailableItems.test.ts +++ b/src/process/validation/rules/__tests__/validateAvailableItems.test.ts @@ -10,7 +10,6 @@ import { } from './fixtures/components'; import { generateProcessorContext } from './fixtures/util'; import { validateAvailableItems, validateAvailableItemsSync } from '../validateAvailableItems'; -import { validateAvailableItemsUrl } from '../validateAvailableItemsUrl'; it('Validating a component without the available items validation parameter will return null', async () => { const component = simpleTextField; @@ -480,7 +479,7 @@ it('Validating a simple radio component with url data source with the available json: () => Promise.resolve(['1', '2', '3']) }); }; - const result = await validateAvailableItemsUrl(context); + const result = await validateAvailableItems(context); expect(result).to.equal(null); }); @@ -505,7 +504,7 @@ it('Validating a simple radio component with url data source with the available json: () => Promise.resolve(['1', '2', '3']) }); }; - const result = await validateAvailableItemsUrl(context); + const result = await validateAvailableItems(context); expect(result).to.be.instanceOf(FieldError); expect(result?.errorKeyOrMessage).to.equal('invalidOption'); }); @@ -531,7 +530,7 @@ it('Validating a simple select component with url data source with the available json: () => Promise.resolve([{'id': 'opt_1', 'value': 1}, {'id': 'opt_2', 'value': 2}]) }); }; - const result = await validateAvailableItemsUrl(context); + const result = await validateAvailableItems(context); expect(result).to.equal(null); }); @@ -556,7 +555,7 @@ it('Validating a simple select component with url data source with the available json: () => Promise.resolve([{'id': 'opt_1', 'value': 1}, {'id': 'opt_2', 'value': 2}]) }); }; - const result = await validateAvailableItemsUrl(context); + const result = await validateAvailableItems(context); expect(result).to.be.instanceOf(FieldError); expect(result?.errorKeyOrMessage).to.equal('invalidOption'); }); \ No newline at end of file diff --git a/src/process/validation/rules/asynchronousRules.ts b/src/process/validation/rules/asynchronousRules.ts index 9028060a..4b2fc2c8 100644 --- a/src/process/validation/rules/asynchronousRules.ts +++ b/src/process/validation/rules/asynchronousRules.ts @@ -1,9 +1,9 @@ import { ValidationRuleInfo } from "types"; import { validateUrlSelectValueInfo } from "./validateUrlSelectValue"; -import { validateAvailableItemsUrlInfo } from "./validateAvailableItemsUrl"; +import { validateAvailableItemsInfo } from "./validateAvailableItems"; // These are the validations that are asynchronouse (e.g. require fetch export const asynchronousRules: ValidationRuleInfo[] = [ validateUrlSelectValueInfo, - validateAvailableItemsUrlInfo + validateAvailableItemsInfo ]; diff --git a/src/process/validation/rules/validateAvailableItems.ts b/src/process/validation/rules/validateAvailableItems.ts index f5d00557..64c361b9 100644 --- a/src/process/validation/rules/validateAvailableItems.ts +++ b/src/process/validation/rules/validateAvailableItems.ts @@ -1,16 +1,16 @@ import { isEmpty, isUndefined} from 'lodash'; import { FieldError, ProcessorError } from 'error'; import { Evaluator } from 'utils'; -import { RadioComponent, SelectComponent, RuleFn, RuleFnSync, ValidationContext } from 'types'; +import { RadioComponent, SelectComponent, RuleFn, RuleFnSync, ValidationContext, FetchFn, SelectBoxesComponent } from 'types'; import { isObject, isPromise } from '../util'; import { ProcessorInfo } from 'types/process/ProcessorInfo'; +import { getErrorMessage } from 'utils/error'; function isValidatableRadioComponent(component: any): component is RadioComponent { return ( component && component.type === 'radio' && - !!component.validate?.onlyAvailableItems && - component.dataSrc === 'values' + !!component.validate?.onlyAvailableItems ); } @@ -23,7 +23,16 @@ function isValidateableSelectComponent(component: any): component is SelectCompo ); } -export function mapDynamicValues>(component: SelectComponent, values: T[]) { +function isValidateableSelectBoxesComponent(component: any): component is SelectBoxesComponent { + return ( + component && + !!component.validate?.onlyAvailableItems && + component.type === 'selectboxes' && + component.dataSrc === 'url' + ); +} + +function mapDynamicValues>(component: SelectComponent, values: T[]) { return values.map((value) => { if (component.valueProperty) { return value[component.valueProperty]; @@ -36,6 +45,29 @@ function mapStaticValues(values: { label: string; value: string }[]) { return values.map((obj) => obj.value); } +const getAvailableDynamicValues = async (component: any, context: ValidationContext) => { + let _fetch: FetchFn | null = null; + try { + _fetch = context.fetch ? context.fetch : fetch; + } + catch (err) { + _fetch = null; + } + try { + if (!_fetch) { + console.log('You must provide a fetch interface to the fetch processor.'); + return null; + } + const response = await _fetch((component as any).data.url, { method: 'GET' }); + const data = await response.json(); + return data ? mapDynamicValues(component, data) : null; + } + catch (err) { + console.error(getErrorMessage(err)); + return null; + } +} + async function getAvailableSelectValues(component: SelectComponent, context: ValidationContext) { if (isUndefined(component.dataSrc) && component.data.hasOwnProperty('values')) { component.dataSrc = 'values'; @@ -100,6 +132,8 @@ async function getAvailableSelectValues(component: SelectComponent, context: Val 'validate:validateAvailableItems' ); } + case 'url': + return await getAvailableDynamicValues(component, context); default: throw new ProcessorError( `Failed to validate available values in select component '${component.key}': data source ${component.dataSrc} is not valid}`, @@ -173,7 +207,7 @@ function getAvailableSelectValuesSync(component: SelectComponent, context: Valid } } -export function compareComplexValues(valueA: unknown, valueB: unknown, context: ValidationContext) { +function compareComplexValues(valueA: unknown, valueB: unknown, context: ValidationContext) { if (!isObject(valueA) || !isObject(valueB)) { return false; } @@ -196,11 +230,15 @@ export const validateAvailableItems: RuleFn = async (context: ValidationContext) return null; } - const values = component.values; + const values = component.dataSrc === 'url' ? await getAvailableDynamicValues(component, context) : component.values; if (values) { - return values.findIndex(({ value: optionValue }) => optionValue === value) !== -1 - ? null - : error; + if (isObject(value)) { + return values.find((optionValue) => compareComplexValues(optionValue, value, context)) !== + undefined + ? null + : error; + } + return values.find((optionValue) => optionValue === value) !== undefined ? null : error; } return null; @@ -217,6 +255,21 @@ export const validateAvailableItems: RuleFn = async (context: ValidationContext) : error; } + return values.find((optionValue) => optionValue === value) !== undefined ? null : error; + } + } else if (isValidateableSelectBoxesComponent(component)) { + if (value == null || isEmpty(value)) { + return null; + } + const values = await getAvailableDynamicValues(component, context); + if (values) { + if (isObject(value)) { + return values.find((optionValue) => compareComplexValues(optionValue, value, context)) !== + undefined + ? null + : error; + } + return values.find((optionValue) => optionValue === value) !== undefined ? null : error; } } @@ -237,6 +290,9 @@ export const shouldValidate = (context: any) => { if (isValidateableSelectComponent(component)) { return true; } + if (isValidateableSelectBoxesComponent(component)) { + return true; + } return false; } @@ -247,7 +303,7 @@ export const validateAvailableItemsSync: RuleFnSync = (context: ValidationContex if (!shouldValidate(context)) { return null; } - if (isValidatableRadioComponent(component)) { + if (isValidatableRadioComponent(component) && component.dataSrc !== 'url') { const values = component.values; if (values) { return values.findIndex(({ value: optionValue }) => optionValue === value) !== -1 diff --git a/src/process/validation/rules/validateAvailableItemsUrl.ts b/src/process/validation/rules/validateAvailableItemsUrl.ts deleted file mode 100644 index 2c26fa60..00000000 --- a/src/process/validation/rules/validateAvailableItemsUrl.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { isEmpty } from 'lodash'; -import { FieldError, ProcessorError } from 'error'; -import { RadioComponent, SelectComponent, RuleFn, RuleFnSync, ValidationContext, FetchFn, SelectBoxesComponent } from 'types'; -import { isObject } from '../util'; -import { ProcessorInfo } from 'types/process/ProcessorInfo'; -import { compareComplexValues, mapDynamicValues } from './validateAvailableItems'; -import { getErrorMessage } from 'utils/error'; - -function isValidatableRadioComponent(component: any): component is RadioComponent { - return ( - component && - component.type === 'radio' && - !!component.validate?.onlyAvailableItems && - component.dataSrc === 'url' - ); -} - -function isValidateableSelectComponent(component: any): component is SelectComponent { - return ( - component && - !!component.validate?.onlyAvailableItems && - component.type === 'select' && - component.dataSrc === 'url' - ); -} - -function isValidateableSelectBoxesComponent(component: any): component is SelectBoxesComponent { - return ( - component && - !!component.validate?.onlyAvailableItems && - component.type === 'selectboxes' && - component.dataSrc === 'url' - ); -} - -export const shouldValidate = (context: any) => { - const { component, value } = context; - if (value == null || isEmpty(value)) { - return false; - } - if (isValidatableRadioComponent(component)) { - return true; - } - if (isValidateableSelectComponent(component)) { - return true; - } - if (isValidateableSelectBoxesComponent(component)) { - return true; - } - return false; -} - -const getAvailableValues = async (component: any, context: ValidationContext) => { - let _fetch: FetchFn | null = null; - try { - _fetch = context.fetch ? context.fetch : fetch; - } - catch (err) { - _fetch = null; - } - try { - if (!_fetch) { - console.log('You must provide a fetch interface to the fetch processor.'); - return null; - } - const response = await _fetch((component as any).data.url, { method: 'GET' }); - const data = await response.json(); - return data ? mapDynamicValues(component, data) : null; - } - catch (err) { - console.error(getErrorMessage(err)); - return null; - } -} - -export const validateAvailableItemsUrl: RuleFn = async (context: ValidationContext) => { - const { component, value } = context; - const error = new FieldError('invalidOption', context, 'onlyAvailableItemsURL'); - try { - if (isValidatableRadioComponent(component)) { - if (value == null || isEmpty(value)) { - return null; - } - - const values = await getAvailableValues(component, context); - if (values) { - return values.findIndex((optionValue) => optionValue === value) !== -1 - ? null - : error; - } - - return null; - } else if (isValidateableSelectComponent(component)) { - if (value == null || isEmpty(value)) { - return null; - } - const values = await getAvailableValues(component, context); - if (values) { - if (isObject(value)) { - return values.find((optionValue) => compareComplexValues(optionValue, value, context)) !== - undefined - ? null - : error; - } - - return values.find((optionValue) => optionValue === value) !== undefined ? null : error; - } - } else if (isValidateableSelectBoxesComponent(component)) { - if (value == null || isEmpty(value)) { - return null; - } - const values = await getAvailableValues(component, context); - if (values) { - if (isObject(value)) { - return values.find((optionValue) => compareComplexValues(optionValue, value, context)) !== - undefined - ? null - : error; - } - - return values.find((optionValue) => optionValue === value) !== undefined ? null : error; - } - } - } catch (err: any) { - throw new ProcessorError(err.message || err, context, 'validate:validateAvailableItemsURL'); - } - return null; -}; - -export const validateAvailableItemsUrlInfo: ProcessorInfo = { - name: 'validateAvailableItemsUrl', - process: validateAvailableItemsUrl, - shouldProcess: shouldValidate, -} \ No newline at end of file