feat(editor): Filter component + implement in If node (#7490)

New Filter component + implementation in If node (v2)

<img width="3283" alt="image"
src="https://github.com/n8n-io/n8n/assets/8850410/35c379ef-4b62-4d06-82e7-673d4edcd652">

---------

Co-authored-by: Giulio Andreini <andreini@netseven.it>
Co-authored-by: Michael Kret <michael.k@radency.com>
This commit is contained in:
Elias Meire
2023-12-13 14:45:22 +01:00
committed by GitHub
parent 09a5729305
commit 8a5343401d
56 changed files with 5060 additions and 900 deletions

View File

@@ -1071,6 +1071,7 @@ export type NodePropertyTypes =
| 'resourceLocator'
| 'curlImport'
| 'resourceMapper'
| 'filter'
| 'credentials';
export type CodeAutocompleteTypes = 'function' | 'functionItem';
@@ -1118,6 +1119,7 @@ export interface INodePropertyTypeOptions {
sortable?: boolean; // Supported when "multipleValues" set to true
expirable?: boolean; // Supported by: hidden (only in the credentials)
resourceMapper?: ResourceMapperTypeOptions;
filter?: FilterTypeOptions;
[key: string]: any;
}
@@ -1137,6 +1139,18 @@ export interface ResourceMapperTypeOptions {
};
}
type NonEmptyArray<T> = [T, ...T[]];
export type FilterTypeCombinator = 'and' | 'or';
export type FilterTypeOptions = Partial<{
caseSensitive: boolean | string; // default = true
leftValue: string; // when set, user can't edit left side of condition
allowedCombinators: NonEmptyArray<FilterTypeCombinator>; // default = ['and', 'or']
maxConditions: number; // default = 10
typeValidation: 'strict' | 'loose' | {}; // default = strict, `| {}` is a TypeScript trick to allow custom strings, but still give autocomplete
}>;
export interface IDisplayOptions {
hide?: {
[key: string]: NodeParameterValue[] | undefined;
@@ -2209,6 +2223,42 @@ export type ResourceMapperValue = {
matchingColumns: string[];
schema: ResourceMapperField[];
};
export type FilterOperatorType =
| 'string'
| 'number'
| 'boolean'
| 'array'
| 'object'
| 'dateTime'
| 'any';
export interface FilterOperatorValue {
type: FilterOperatorType;
operation: string;
rightType?: FilterOperatorType;
singleValue?: boolean; // default = false
}
export type FilterConditionValue = {
id: string;
leftValue: unknown;
operator: FilterOperatorValue;
rightValue: unknown;
};
export type FilterOptionsValue = {
caseSensitive: boolean;
leftValue: string;
typeValidation: 'strict' | 'loose';
};
export type FilterValue = {
options: FilterOptionsValue;
conditions: FilterConditionValue[];
combinator: FilterTypeCombinator;
};
export interface ExecutionOptions {
limit?: number;
}

View File

@@ -36,18 +36,22 @@ import type {
IWorkflowExecuteAdditionalData,
NodeParameterValue,
ResourceMapperValue,
ValidationResult,
ConnectionTypes,
INodeTypeDescription,
INodeOutputConfiguration,
INodeInputConfiguration,
GenericValue,
} from './Interfaces';
import { isResourceMapperValue, isValidResourceLocatorParameterValue } from './type-guards';
import {
isFilterValue,
isResourceMapperValue,
isValidResourceLocatorParameterValue,
} from './type-guards';
import { deepCopy } from './utils';
import { DateTime } from 'luxon';
import type { Workflow } from './Workflow';
import { validateFilterParameter } from './NodeParameters/FilterParameter';
import { validateFieldType } from './TypeValidation';
import { ApplicationError } from './errors/application.error';
export const cronNodeOptions: INodePropertyCollection[] = [
@@ -1186,188 +1190,6 @@ export function nodeIssuesToString(issues: INodeIssues, node?: INode): string[]
return nodeIssues;
}
// Validates field against the schema and tries to parse it to the correct type
export const validateFieldType = (
fieldName: string,
value: unknown,
type: FieldType,
options?: INodePropertyOptions[],
): ValidationResult => {
if (value === null || value === undefined) return { valid: true };
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'`;
switch (type.toLowerCase()) {
case 'number': {
try {
return { valid: true, newValue: tryToParseNumber(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'boolean': {
try {
return { valid: true, newValue: tryToParseBoolean(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'datetime': {
try {
return { valid: true, newValue: tryToParseDateTime(value) };
} catch (e) {
const luxonDocsURL =
'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat';
const errorMessage = `${defaultErrorMessage} <br/><br/> Consider using <a href="${luxonDocsURL}" target="_blank"><code>DateTime.fromFormat</code></a> to work with custom date formats.`;
return { valid: false, errorMessage };
}
}
case 'time': {
try {
return { valid: true, newValue: tryToParseTime(value) };
} catch (e) {
return {
valid: false,
errorMessage: `'${fieldName}' expects time (hh:mm:(:ss)) but we got '${String(value)}'.`,
};
}
}
case 'object': {
try {
return { valid: true, newValue: tryToParseObject(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'array': {
try {
return { valid: true, newValue: tryToParseArray(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'options': {
const validOptions = options?.map((option) => option.value).join(', ') || '';
const isValidOption = options?.some((option) => option.value === value) || false;
if (!isValidOption) {
return {
valid: false,
errorMessage: `'${fieldName}' expects one of the following values: [${validOptions}] but we got '${String(
value,
)}'`,
};
}
return { valid: true, newValue: value };
}
default: {
return { valid: true, newValue: value };
}
}
};
export const tryToParseNumber = (value: unknown): number => {
const isValidNumber = !isNaN(Number(value));
if (!isValidNumber) {
throw new ApplicationError('Failed to parse value to number', { extra: { value } });
}
return Number(value);
};
export const tryToParseBoolean = (value: unknown): value is boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
return value.toLowerCase() === 'true';
}
// If value is not a empty string, try to parse it to a number
if (!(typeof value === 'string' && value.trim() === '')) {
const num = Number(value);
if (num === 0) {
return false;
} else if (num === 1) {
return true;
}
}
throw new ApplicationError('Failed to parse value as boolean', {
extra: { value },
});
};
export const tryToParseDateTime = (value: unknown): DateTime => {
const dateString = String(value).trim();
// Rely on luxon to parse different date formats
const isoDate = DateTime.fromISO(dateString, { setZone: true });
if (isoDate.isValid) {
return isoDate;
}
const httpDate = DateTime.fromHTTP(dateString, { setZone: true });
if (httpDate.isValid) {
return httpDate;
}
const rfc2822Date = DateTime.fromRFC2822(dateString, { setZone: true });
if (rfc2822Date.isValid) {
return rfc2822Date;
}
const sqlDate = DateTime.fromSQL(dateString, { setZone: true });
if (sqlDate.isValid) {
return sqlDate;
}
throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
};
export const tryToParseTime = (value: unknown): string => {
const isTimeInput = /^\d{2}:\d{2}(:\d{2})?((\-|\+)\d{4})?((\-|\+)\d{1,2}(:\d{2})?)?$/s.test(
String(value),
);
if (!isTimeInput) {
throw new ApplicationError('Value is not a valid time', { extra: { value } });
}
return String(value);
};
export const tryToParseArray = (value: unknown): unknown[] => {
try {
if (typeof value === 'object' && Array.isArray(value)) {
return value;
}
let parsed;
try {
parsed = JSON.parse(String(value));
} catch (e) {
parsed = JSON.parse(String(value).replace(/'/g, '"'));
}
if (!Array.isArray(parsed)) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
return parsed;
} catch (e) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
};
export const tryToParseObject = (value: unknown): object => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
try {
const o = JSON.parse(String(value));
if (typeof o !== 'object' || Array.isArray(o)) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
return o;
} catch (e) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
};
/*
* Validates resource locator node parameters based on validation ruled defined in each parameter mode
*
@@ -1423,7 +1245,9 @@ export const validateResourceMapperParameter = (
}
}
if (!fieldValue?.toString().startsWith('=') && field.type) {
const validationResult = validateFieldType(field.id, fieldValue, field.type, field.options);
const validationResult = validateFieldType(field.id, fieldValue, field.type, {
valueOptions: field.options,
});
if (!validationResult.valid && validationResult.errorMessage) {
fieldErrors.push(validationResult.errorMessage);
}
@@ -1444,12 +1268,9 @@ export const validateParameter = (
const options = type === 'options' ? nodeProperties.options : undefined;
if (!value?.toString().startsWith('=')) {
const validationResult = validateFieldType(
nodeName,
value,
type,
options as INodePropertyOptions[],
);
const validationResult = validateFieldType(nodeName, value, type, {
valueOptions: options as INodePropertyOptions[],
});
if (!validationResult.valid && validationResult.errorMessage) {
return validationResult.errorMessage;
@@ -1583,6 +1404,14 @@ export function getParameterIssues(
foundIssues.parameters = { ...foundIssues.parameters, ...issues };
}
}
} else if (nodeProperties.type === 'filter' && isDisplayed) {
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
if (isFilterValue(value)) {
const issues = validateFilterParameter(nodeProperties, value);
if (Object.keys(issues).length > 0) {
foundIssues.parameters = { ...foundIssues.parameters, ...issues };
}
}
} else if (nodeProperties.validateType) {
const value = getParameterValueByPath(nodeValues, nodeProperties.name, path);
const error = validateParameter(nodeProperties, value, nodeProperties.validateType);

View File

@@ -0,0 +1,345 @@
import type { DateTime } from 'luxon';
import type {
FilterConditionValue,
FilterOperatorType,
FilterOptionsValue,
FilterValue,
INodeProperties,
ValidationResult,
} from '../Interfaces';
import { validateFieldType } from '../TypeValidation';
import * as LoggerProxy from '../LoggerProxy';
type Result<T, E> = { ok: true; result: T } | { ok: false; error: E };
type FilterConditionMetadata = {
index: number;
unresolvedExpressions: boolean;
itemIndex: number;
errorFormat: 'full' | 'inline';
};
export class FilterError extends Error {
constructor(
message: string,
readonly description: string,
) {
super(message);
}
}
function parseSingleFilterValue(
value: unknown,
type: FilterOperatorType,
strict = false,
): ValidationResult {
return type === 'any' || value === null || value === undefined || value === ''
? ({ valid: true, newValue: value } as ValidationResult)
: validateFieldType('filter', value, type, { strict, parseStrings: true });
}
function parseFilterConditionValues(
condition: FilterConditionValue,
options: FilterOptionsValue,
metadata: Partial<FilterConditionMetadata>,
): Result<{ left: unknown; right: unknown }, FilterError> {
const index = metadata.index ?? 0;
const itemIndex = metadata.itemIndex ?? 0;
const errorFormat = metadata.errorFormat ?? 'full';
const strict = options.typeValidation === 'strict';
const { operator } = condition;
const rightType = operator.rightType ?? operator.type;
const parsedLeftValue = parseSingleFilterValue(condition.leftValue, operator.type, strict);
const parsedRightValue = parseSingleFilterValue(condition.rightValue, rightType, strict);
const leftValid =
parsedLeftValue.valid ||
(metadata.unresolvedExpressions &&
typeof condition.leftValue === 'string' &&
condition.leftValue.startsWith('='));
const rightValid =
parsedRightValue.valid ||
!!operator.singleValue ||
(metadata.unresolvedExpressions &&
typeof condition.rightValue === 'string' &&
condition.rightValue.startsWith('='));
const leftValueString = String(condition.leftValue);
const rightValueString = String(condition.rightValue);
const errorDescription = 'Try to change the operator, or change the type with an expression';
const inCondition = errorFormat === 'full' ? ` in condition ${index + 1} ` : ' ';
const itemSuffix = `[item ${itemIndex}]`;
if (!leftValid && !rightValid) {
const providedValues = 'The provided values';
let types = `'${operator.type}'`;
if (rightType !== operator.type) {
types = `'${operator.type}' and '${rightType}' respectively`;
}
if (strict) {
return {
ok: false,
error: new FilterError(
`${providedValues} '${leftValueString}' and '${rightValueString}'${inCondition}are not of the expected type ${types} ${itemSuffix}`,
errorDescription,
),
};
}
return {
ok: false,
error: new FilterError(
`${providedValues} '${leftValueString}' and '${rightValueString}'${inCondition}cannot be converted to the expected type ${types} ${itemSuffix}`,
errorDescription,
),
};
}
const composeInvalidTypeMessage = (field: 'left' | 'right', type: string, value: string) => {
const fieldNumber = field === 'left' ? 1 : 2;
if (strict) {
return `The provided value ${fieldNumber} '${value}'${inCondition}is not of the expected type '${type}' ${itemSuffix}`;
}
return `The provided value ${fieldNumber} '${value}'${inCondition}cannot be converted to the expected type '${type}' ${itemSuffix}`;
};
if (!leftValid) {
return {
ok: false,
error: new FilterError(
composeInvalidTypeMessage('left', operator.type, leftValueString),
errorDescription,
),
};
}
if (!rightValid) {
return {
ok: false,
error: new FilterError(
composeInvalidTypeMessage('right', rightType, rightValueString),
errorDescription,
),
};
}
return { ok: true, result: { left: parsedLeftValue.newValue, right: parsedRightValue.newValue } };
}
export function executeFilterCondition(
condition: FilterConditionValue,
filterOptions: FilterOptionsValue,
metadata: Partial<FilterConditionMetadata> = {},
): boolean {
const ignoreCase = !filterOptions.caseSensitive;
const { operator } = condition;
const parsedValues = parseFilterConditionValues(condition, filterOptions, metadata);
if (!parsedValues.ok) {
throw parsedValues.error;
}
let { left: leftValue, right: rightValue } = parsedValues.result;
const exists = leftValue !== undefined && leftValue !== null;
if (condition.operator.operation === 'exists') {
return exists;
} else if (condition.operator.operation === 'notExists') {
return !exists;
}
switch (operator.type) {
case 'string': {
if (ignoreCase) {
if (typeof leftValue === 'string') {
leftValue = leftValue.toLocaleLowerCase();
}
if (
typeof rightValue === 'string' &&
!(condition.operator.operation === 'regex' || condition.operator.operation === 'notRegex')
) {
rightValue = rightValue.toLocaleLowerCase();
}
}
const left = (leftValue ?? '') as string;
const right = (rightValue ?? '') as string;
switch (condition.operator.operation) {
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
case 'contains':
return left.includes(right);
case 'notContains':
return !left.includes(right);
case 'startsWith':
return left.startsWith(right);
case 'notStartsWith':
return !left.startsWith(right);
case 'endsWith':
return left.endsWith(right);
case 'notEndsWith':
return !left.endsWith(right);
case 'regex':
return new RegExp(right).test(left);
case 'notRegex':
return !new RegExp(right).test(left);
}
break;
}
case 'number': {
const left = leftValue as number;
const right = rightValue as number;
switch (condition.operator.operation) {
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
case 'gt':
return left > right;
case 'lt':
return left < right;
case 'gte':
return left >= right;
case 'lte':
return left <= right;
}
}
case 'dateTime': {
const left = leftValue as DateTime;
const right = rightValue as DateTime;
if (!left || !right) {
return false;
}
switch (condition.operator.operation) {
case 'equals':
return left.toMillis() === right.toMillis();
case 'notEquals':
return left.toMillis() !== right.toMillis();
case 'after':
return left.toMillis() > right.toMillis();
case 'before':
return left.toMillis() < right.toMillis();
case 'afterOrEquals':
return left.toMillis() >= right.toMillis();
case 'beforeOrEquals':
return left.toMillis() <= right.toMillis();
}
}
case 'boolean': {
const left = leftValue as boolean;
const right = rightValue as boolean;
switch (condition.operator.operation) {
case 'true':
return left;
case 'false':
return !left;
case 'equals':
return left === right;
case 'notEquals':
return left !== right;
}
}
case 'array': {
const left = (leftValue ?? []) as unknown[];
const rightNumber = rightValue as number;
switch (condition.operator.operation) {
case 'contains':
if (ignoreCase && typeof rightValue === 'string') {
rightValue = rightValue.toLocaleLowerCase();
}
return left.includes(rightValue);
case 'notContains':
if (ignoreCase && typeof rightValue === 'string') {
rightValue = rightValue.toLocaleLowerCase();
}
return !left.includes(rightValue);
case 'lengthEquals':
return left.length === rightNumber;
case 'lengthNotEquals':
return left.length !== rightNumber;
case 'lengthGt':
return left.length > rightNumber;
case 'lengthLt':
return left.length < rightNumber;
case 'lengthGte':
return left.length >= rightNumber;
case 'lengthLte':
return left.length <= rightNumber;
case 'empty':
return left.length === 0;
case 'notEmpty':
return left.length !== 0;
}
}
case 'object': {
const left = leftValue;
switch (condition.operator.operation) {
case 'empty':
return !!left && Object.keys(left).length === 0;
case 'notEmpty':
return !!left && Object.keys(left).length !== 0;
}
}
}
LoggerProxy.warn(`Unknown filter parameter operator "${operator.type}:${operator.operation}"`);
return false;
}
type ExecuteFilterOptions = {
itemIndex?: number;
};
export function executeFilter(
value: FilterValue,
{ itemIndex }: ExecuteFilterOptions = {},
): boolean {
const conditionPass = (condition: FilterConditionValue, index: number) =>
executeFilterCondition(condition, value.options, { index, itemIndex });
if (value.combinator === 'and') {
return value.conditions.every(conditionPass);
} else if (value.combinator === 'or') {
return value.conditions.some(conditionPass);
}
LoggerProxy.warn(`Unknown filter combinator "${value.combinator as string}"`);
return false;
}
export const validateFilterParameter = (
nodeProperties: INodeProperties,
value: FilterValue,
): Record<string, string[]> => {
return value.conditions.reduce(
(issues, condition, index) => {
const key = `${nodeProperties.name}.${index}`;
try {
parseFilterConditionValues(condition, value.options, {
index,
unresolvedExpressions: true,
errorFormat: 'inline',
});
} catch (error) {
if (error instanceof FilterError) {
issues[key].push(error.message);
}
}
return issues;
},
{} as Record<string, string[]>,
);
};

View File

@@ -0,0 +1,232 @@
import { DateTime } from 'luxon';
import type { FieldType, INodePropertyOptions, ValidationResult } from './Interfaces';
import isObject from 'lodash/isObject';
import { ApplicationError } from './errors';
export const tryToParseNumber = (value: unknown): number => {
const isValidNumber = !isNaN(Number(value));
if (!isValidNumber) {
throw new ApplicationError('Failed to parse value to number', { extra: { value } });
}
return Number(value);
};
export const tryToParseString = (value: unknown): string => {
if (typeof value === 'object') return JSON.stringify(value);
if (typeof value === 'undefined') return '';
if (
typeof value === 'string' ||
typeof value === 'bigint' ||
typeof value === 'boolean' ||
typeof value === 'number'
) {
return value.toString();
}
return String(value);
};
export const tryToParseBoolean = (value: unknown): value is boolean => {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string' && ['true', 'false'].includes(value.toLowerCase())) {
return value.toLowerCase() === 'true';
}
// If value is not a empty string, try to parse it to a number
if (!(typeof value === 'string' && value.trim() === '')) {
const num = Number(value);
if (num === 0) {
return false;
} else if (num === 1) {
return true;
}
}
throw new ApplicationError('Failed to parse value as boolean', {
extra: { value },
});
};
export const tryToParseDateTime = (value: unknown): DateTime => {
const dateString = String(value).trim();
// Rely on luxon to parse different date formats
const isoDate = DateTime.fromISO(dateString, { setZone: true });
if (isoDate.isValid) {
return isoDate;
}
const httpDate = DateTime.fromHTTP(dateString, { setZone: true });
if (httpDate.isValid) {
return httpDate;
}
const rfc2822Date = DateTime.fromRFC2822(dateString, { setZone: true });
if (rfc2822Date.isValid) {
return rfc2822Date;
}
const sqlDate = DateTime.fromSQL(dateString, { setZone: true });
if (sqlDate.isValid) {
return sqlDate;
}
throw new ApplicationError('Value is not a valid date', { extra: { dateString } });
};
export const tryToParseTime = (value: unknown): string => {
const isTimeInput = /^\d{2}:\d{2}(:\d{2})?((\-|\+)\d{4})?((\-|\+)\d{1,2}(:\d{2})?)?$/s.test(
String(value),
);
if (!isTimeInput) {
throw new ApplicationError('Value is not a valid time', { extra: { value } });
}
return String(value);
};
export const tryToParseArray = (value: unknown): unknown[] => {
try {
if (typeof value === 'object' && Array.isArray(value)) {
return value;
}
let parsed: unknown[];
try {
parsed = JSON.parse(String(value)) as unknown[];
} catch (e) {
parsed = JSON.parse(String(value).replace(/'/g, '"')) as unknown[];
}
if (!Array.isArray(parsed)) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
return parsed;
} catch (e) {
throw new ApplicationError('Value is not a valid array', { extra: { value } });
}
};
export const tryToParseObject = (value: unknown): object => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
}
try {
const o = JSON.parse(String(value)) as object;
if (typeof o !== 'object' || Array.isArray(o)) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
return o;
} catch (e) {
throw new ApplicationError('Value is not a valid object', { extra: { value } });
}
};
type ValidateFieldTypeOptions = Partial<{
valueOptions: INodePropertyOptions[];
strict: boolean;
parseStrings: boolean;
}>;
// Validates field against the schema and tries to parse it to the correct type
export const validateFieldType = (
fieldName: string,
value: unknown,
type: FieldType,
options: ValidateFieldTypeOptions = {},
): ValidationResult => {
if (value === null || value === undefined) return { valid: true };
const strict = options.strict ?? false;
const valueOptions = options.valueOptions ?? [];
const parseStrings = options.parseStrings ?? false;
const defaultErrorMessage = `'${fieldName}' expects a ${type} but we got '${String(value)}'`;
switch (type.toLowerCase()) {
case 'string': {
if (!parseStrings) return { valid: true, newValue: value };
try {
if (strict && typeof value !== 'string') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseString(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'number': {
try {
if (strict && typeof value !== 'number') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseNumber(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'boolean': {
try {
if (strict && typeof value !== 'boolean') {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseBoolean(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'datetime': {
try {
return { valid: true, newValue: tryToParseDateTime(value) };
} catch (e) {
const luxonDocsURL =
'https://moment.github.io/luxon/api-docs/index.html#datetimefromformat';
const errorMessage = `${defaultErrorMessage} <br/><br/> Consider using <a href="${luxonDocsURL}" target="_blank"><code>DateTime.fromFormat</code></a> to work with custom date formats.`;
return { valid: false, errorMessage };
}
}
case 'time': {
try {
return { valid: true, newValue: tryToParseTime(value) };
} catch (e) {
return {
valid: false,
errorMessage: `'${fieldName}' expects time (hh:mm:(:ss)) but we got '${String(value)}'.`,
};
}
}
case 'object': {
try {
if (strict && !isObject(value)) {
return { valid: false, errorMessage: defaultErrorMessage };
}
return { valid: true, newValue: tryToParseObject(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'array': {
if (strict && !Array.isArray(value)) {
return { valid: false, errorMessage: defaultErrorMessage };
}
try {
return { valid: true, newValue: tryToParseArray(value) };
} catch (e) {
return { valid: false, errorMessage: defaultErrorMessage };
}
}
case 'options': {
const validOptions = valueOptions.map((option) => option.value).join(', ');
const isValidOption = valueOptions.some((option) => option.value === value);
if (!isValidOption) {
return {
valid: false,
errorMessage: `'${fieldName}' expects one of the following values: [${validOptions}] but we got '${String(
value,
)}'`,
};
}
return { valid: true, newValue: value };
}
default: {
return { valid: true, newValue: value };
}
}
};

View File

@@ -21,6 +21,7 @@ export * from './Workflow';
export * from './WorkflowDataProxy';
export * from './WorkflowHooks';
export * from './VersionedNodeType';
export * from './TypeValidation';
export { LoggerProxy, NodeHelpers, ObservableObject, TelemetryHelpers };
export {
isObjectEmpty,
@@ -40,11 +41,13 @@ export {
isINodePropertyCollectionList,
isINodePropertyOptionsList,
isResourceMapperValue,
isFilterValue,
} from './type-guards';
export { ExpressionExtensions } from './Extensions';
export * as ExpressionParser from './Extensions/ExpressionParser';
export { NativeMethods } from './NativeMethods';
export * from './NodeParameters/FilterParameter';
export type { DocMetadata, NativeDoc } from './Extensions';

View File

@@ -4,6 +4,7 @@ import type {
INodePropertyCollection,
INodeParameterResourceLocator,
ResourceMapperValue,
FilterValue,
} from './Interfaces';
export const isINodeProperties = (
@@ -54,3 +55,9 @@ export const isResourceMapperValue = (value: unknown): value is ResourceMapperVa
'value' in value
);
};
export const isFilterValue = (value: unknown): value is FilterValue => {
return (
typeof value === 'object' && value !== null && 'conditions' in value && 'combinator' in value
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { validateFieldType } from '@/NodeHelpers';
import { validateFieldType } from '@/TypeValidation';
import type { DateTime } from 'luxon';
const VALID_ISO_DATES = [
@@ -174,16 +174,20 @@ describe('Type Validation', () => {
it('should validate options properly', () => {
expect(
validateFieldType('options', 'oranges', 'options', [
{ name: 'apples', value: 'apples' },
{ name: 'oranges', value: 'oranges' },
]).valid,
validateFieldType('options', 'oranges', 'options', {
valueOptions: [
{ name: 'apples', value: 'apples' },
{ name: 'oranges', value: 'oranges' },
],
}).valid,
).toEqual(true);
expect(
validateFieldType('options', 'something else', 'options', [
{ name: 'apples', value: 'apples' },
{ name: 'oranges', value: 'oranges' },
]).valid,
validateFieldType('options', 'something else', 'options', {
valueOptions: [
{ name: 'apples', value: 'apples' },
{ name: 'oranges', value: 'oranges' },
],
}).valid,
).toEqual(false);
});
@@ -202,4 +206,24 @@ describe('Type Validation', () => {
expect(validateFieldType('time', '23:23:', 'time').valid).toEqual(false);
expect(validateFieldType('time', '23::23::23', 'time').valid).toEqual(false);
});
describe('options', () => {
describe('strict=true', () => {
it('should not convert/cast types', () => {
const options = { strict: true };
expect(validateFieldType('test', '42', 'number', options).valid).toBe(false);
expect(validateFieldType('test', 'true', 'boolean', options).valid).toBe(false);
expect(validateFieldType('test', [], 'object', options).valid).toBe(false);
});
});
describe('parseStrings=true', () => {
it('should parse strings from other types', () => {
const options = { parseStrings: true };
expect(validateFieldType('test', 42, 'string').newValue).toBe(42);
expect(validateFieldType('test', 42, 'string', options).newValue).toBe('42');
expect(validateFieldType('test', true, 'string', options).newValue).toBe('true');
});
});
});
});