diff --git a/schemas/json/layout/expression.schema.v1.json b/schemas/json/layout/expression.schema.v1.json index 1df3d5044e..efcdf035d7 100644 --- a/schemas/json/layout/expression.schema.v1.json +++ b/schemas/json/layout/expression.schema.v1.json @@ -41,7 +41,7 @@ "definitions": { "any": { "title": "Any expression", - "oneOf": [ + "anyOf": [ { "type": "null", "title": "Null/missing value" }, { "$ref": "#/definitions/strict-string" }, { "$ref": "#/definitions/strict-boolean" }, @@ -51,7 +51,7 @@ }, "string": { "title": "Any expression returning string", - "oneOf": [ + "anyOf": [ { "type": "null", "title": "Null/missing value" }, { "$ref": "#/definitions/strict-string" }, { "$ref": "#/definitions/func-if" }, @@ -61,18 +61,23 @@ }, "strict-string": { "title": "Any expression returning string (strict)", - "oneOf": [ + "anyOf": [ { "type": "string", "title": "String constant" }, { "$ref": "#/definitions/func-component" }, { "$ref": "#/definitions/func-dataModel" }, { "$ref": "#/definitions/func-instanceContext" }, { "$ref": "#/definitions/func-frontendSettings" }, - { "$ref": "#/definitions/func-concat" } + { "$ref": "#/definitions/func-concat" }, + { "$ref": "#/definitions/func-round" }, + { "$ref": "#/definitions/func-text" }, + { "$ref": "#/definitions/func-language" }, + { "$ref": "#/definitions/func-lowerCase" }, + { "$ref": "#/definitions/func-upperCase" } ] }, "boolean": { "title": "Any expression returning boolean", - "oneOf": [ + "anyOf": [ { "type": "null", "title": "Null/missing value" }, { "$ref": "#/definitions/strict-boolean" }, { "$ref": "#/definitions/func-if" }, @@ -82,7 +87,7 @@ }, "strict-boolean": { "title": "Any expression returning boolean (strict)", - "oneOf": [ + "anyOf": [ { "type": "boolean", "title": "Boolean constant" }, { "$ref": "#/definitions/func-equals" }, { "$ref": "#/definitions/func-notEquals" }, @@ -93,12 +98,17 @@ { "$ref": "#/definitions/func-not" }, { "$ref": "#/definitions/func-and" }, { "$ref": "#/definitions/func-or" }, - { "$ref": "#/definitions/func-authContext" } + { "$ref": "#/definitions/func-authContext" }, + { "$ref": "#/definitions/func-contains" }, + { "$ref": "#/definitions/func-notContains" }, + { "$ref": "#/definitions/func-endsWith" }, + { "$ref": "#/definitions/func-startsWith" }, + { "$ref": "#/definitions/func-commaContains" } ] }, "number": { "title": "Any expression returning a number", - "oneOf": [ + "anyOf": [ { "type": "null", "title": "Null/missing value" }, { "$ref": "#/definitions/strict-number" }, { "$ref": "#/definitions/func-if" }, @@ -107,14 +117,15 @@ }, "strict-number": { "title": "Any expression returning a number (strict)", - "oneOf": [ - { "type": "number", "title": "Numeric constant" } + "anyOf": [ + { "type": "number", "title": "Numeric constant" }, + { "$ref": "#/definitions/func-stringLength" } ] }, "func-if": { "title": "If/else conditional expression", "description": "This function will evaluate and return the result of either branch. If else is not given, null will be returned instead.", - "oneOf": [ + "anyOf": [ { "$ref": "#/definitions/func-if-with-else" }, { "$ref": "#/definitions/func-if-without-else" } ] @@ -293,6 +304,121 @@ { "$ref": "#/definitions/boolean" } ], "additionalItems": { "$ref": "#/definitions/boolean" } + }, + "func-round": { + "title": "Round function", + "description": "This function rounds a number to the nearest integer, or to the specified number of decimals", + "type": "array", + "items": [ + { "const": "round" }, + { "$ref": "#/definitions/number" }, + { "$ref": "#/definitions/number" } + ], + "additionalItems": false + }, + "func-text": { + "title": "Text function", + "description": "This function retrieves the value of a text resource key, or returns the key if no text resource is found", + "type": "array", + "items": [ + { "const": "text" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-language": { + "title": "Language function", + "description": "This function retrieves the current language (usually 'nb', 'nn' or 'en')", + "type": "array", + "items": [ + { "const": "language" } + ], + "additionalItems": false + }, + "func-contains": { + "title": "Contains function", + "description": "This function checks if the first string contains the second string", + "type": "array", + "items": [ + { "const": "contains" }, + { "$ref": "#/definitions/string" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-notContains": { + "title": "Not contains function", + "description": "This function checks if the first string does not contain the second string", + "type": "array", + "items": [ + { "const": "notContains" }, + { "$ref": "#/definitions/string" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-startsWith": { + "title": "Starts with function", + "description": "This function checks if the first string starts with the second string", + "type": "array", + "items": [ + { "const": "startsWith" }, + { "$ref": "#/definitions/string" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-endsWith": { + "title": "Ends with function", + "description": "This function checks if the first string ends with the second string", + "type": "array", + "items": [ + { "const": "endsWith" }, + { "$ref": "#/definitions/string" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-stringLength": { + "title": "String length function", + "description": "This function returns the length of a string", + "type": "array", + "items": [ + { "const": "stringLength" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-commaContains": { + "title": "Comma contains function", + "description": "This function checks if the first comma-separated string contains the second string", + "type": "array", + "items": [ + { "const": "commaContains" }, + { "$ref": "#/definitions/string" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-lowerCase": { + "title": "Lower case function", + "description": "This function converts a string to lower case", + "type": "array", + "items": [ + { "const": "lowerCase" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false + }, + "func-upperCase": { + "title": "Upper case function", + "description": "This function converts a string to upper case", + "type": "array", + "items": [ + { "const": "upperCase" }, + { "$ref": "#/definitions/string" } + ], + "additionalItems": false } } } diff --git a/src/features/expressions/errors.ts b/src/features/expressions/errors.ts index 0ff740525c..c5176ce969 100644 --- a/src/features/expressions/errors.ts +++ b/src/features/expressions/errors.ts @@ -6,12 +6,6 @@ export class ExprRuntimeError extends Error { } } -export class LookupNotFound extends ExprRuntimeError { - public constructor(context: ExprContext, message: string) { - super(context, message); - } -} - export class UnknownTargetType extends ExprRuntimeError { public constructor(context: ExprContext, type: string) { super(context, `Cannot cast to unknown type '${type}'`); diff --git a/src/features/expressions/index.ts b/src/features/expressions/index.ts index 55a838a578..d04bb5804b 100644 --- a/src/features/expressions/index.ts +++ b/src/features/expressions/index.ts @@ -3,7 +3,6 @@ import type { Mutable } from 'utility-types'; import { ExprRuntimeError, - LookupNotFound, NodeNotFoundWithoutContext, UnexpectedType, UnknownSourceType, @@ -430,7 +429,7 @@ export const ExprFunctions = { instanceContext: defineFunc({ impl(key): string | null { if (key === null || instanceContextKeys[key] !== true) { - throw new LookupNotFound(this, `Unknown Instance context property ${key}`); + throw new ExprRuntimeError(this, `Unknown Instance context property ${key}`); } return (this.dataSources.instanceContext && this.dataSources.instanceContext[key]) || null; @@ -441,7 +440,7 @@ export const ExprFunctions = { frontendSettings: defineFunc({ impl(key): any { if (key === null) { - throw new LookupNotFound(this, `Value cannot be null. (Parameter 'key')`); + throw new ExprRuntimeError(this, `Value cannot be null. (Parameter 'key')`); } return (this.dataSources.applicationSettings && this.dataSources.applicationSettings[key]) || null; @@ -452,7 +451,7 @@ export const ExprFunctions = { authContext: defineFunc({ impl(key): boolean | null { if (key === null || authContextKeys[key] !== true) { - throw new LookupNotFound(this, `Unknown auth context property ${key}`); + throw new ExprRuntimeError(this, `Unknown auth context property ${key}`); } return Boolean(this.dataSources.authContext?.[key]); @@ -463,7 +462,7 @@ export const ExprFunctions = { component: defineFunc({ impl(id): any { if (id === null) { - throw new LookupNotFound(this, `Cannot lookup component null`); + throw new ExprRuntimeError(this, `Cannot lookup component null`); } const node = this.failWithoutNode(); @@ -481,7 +480,7 @@ export const ExprFunctions = { // Expressions can technically be used without having all the layouts available, which might lead to unexpected // results. We should note this in the error message, so we know the reason we couldn't find the component. const hasAllLayouts = node instanceof LayoutPage ? !!node.top : !!node.top.top; - throw new LookupNotFound( + throw new ExprRuntimeError( this, hasAllLayouts ? `Unable to find component with identifier ${id} or it does not have a simpleBinding` @@ -494,7 +493,7 @@ export const ExprFunctions = { dataModel: defineFunc({ impl(path): any { if (path === null) { - throw new LookupNotFound(this, `Cannot lookup dataModel null`); + throw new ExprRuntimeError(this, `Cannot lookup dataModel null`); } const maybeNode = this.failWithoutNode(); @@ -511,25 +510,19 @@ export const ExprFunctions = { returns: ExprVal.Any, }), round: defineFunc({ - impl(number, decimalPoints: number | null): number | null { - if (number === null) { - throw new LookupNotFound(this, `"Value" parameter cannot be null.`); - } - - if (decimalPoints !== undefined && decimalPoints !== null) { - const factor = 10 ** decimalPoints; - return Math.round(number * factor) / factor; - } - - return Math.round(number); + impl(number, decimalPoints) { + const realNumber = number === null ? 0 : number; + const realDecimalPoints = decimalPoints === null ? 0 : decimalPoints; + return parseFloat(`${realNumber}`).toFixed(realDecimalPoints); }, args: [ExprVal.Number, ExprVal.Number] as const, - returns: ExprVal.Number, + minArguments: 1, + returns: ExprVal.String, }), text: defineFunc({ - impl(key): string | null { + impl(key) { if (key === null) { - throw new LookupNotFound(this, `"Key" parameter cannot be null.`); + return null; } return getTextResourceByKey(key, this.dataSources.textResources); @@ -538,20 +531,20 @@ export const ExprFunctions = { returns: ExprVal.String, }), language: defineFunc({ - impl(): string | null { + impl() { const selectedLanguage = this.dataSources.profile?.selectedAppLanguage || this.dataSources.profile?.profile?.profileSettingPreference?.language; - return selectedLanguage || null; + return selectedLanguage || 'nb'; }, args: [] as const, returns: ExprVal.String, }), contains: defineFunc({ - impl(string: string, stringToContain: string): boolean { + impl(string, stringToContain): boolean { if (string === null || stringToContain === null) { - throw new LookupNotFound(this, `"string" or "stringToContain" parameter cannot be null.`); + return false; } return string.includes(stringToContain); @@ -562,7 +555,7 @@ export const ExprFunctions = { notContains: defineFunc({ impl(string: string, stringToNotContain: string): boolean { if (string === null || stringToNotContain === null) { - throw new LookupNotFound(this, `"string" or "stringToNotContain" parameter cannot be null.`); + return true; } return !string.includes(stringToNotContain); }, @@ -572,7 +565,7 @@ export const ExprFunctions = { endsWith: defineFunc({ impl(string: string, stringToMatch: string): boolean { if (string === null || stringToMatch === null) { - throw new LookupNotFound(this, `"string" or "stringToMatch" parameter cannot be null.`); + return false; } return string.endsWith(stringToMatch); }, @@ -582,29 +575,22 @@ export const ExprFunctions = { startsWith: defineFunc({ impl(string: string, stringToMatch: string): boolean { if (string === null || stringToMatch === null) { - throw new LookupNotFound(this, `"string" or "stringToMatch" parameter cannot be null.`); + return false; } - return string.startsWith(stringToMatch); }, args: [ExprVal.String, ExprVal.String] as const, returns: ExprVal.Boolean, }), stringLength: defineFunc({ - impl(string: string): number { - if (string === null) { - throw new LookupNotFound(this, `"string" parameter cannot be null.`); - } - - return string.length; - }, + impl: (string) => (string === null ? 0 : string.length), args: [ExprVal.String] as const, returns: ExprVal.Number, }), commaContains: defineFunc({ - impl(commaSeparatedString: string, stringToMatch: string): boolean { + impl(commaSeparatedString, stringToMatch) { if (commaSeparatedString === null || stringToMatch === null) { - throw new LookupNotFound(this, `"commaSeparatedString" or "stringToMatch" parameter cannot be null.`); + return false; } // Split the comma separated string into an array and remove whitespace from each part @@ -615,9 +601,9 @@ export const ExprFunctions = { returns: ExprVal.Boolean, }), lowerCase: defineFunc({ - impl(string: string): string { + impl(string) { if (string === null) { - throw new LookupNotFound(this, `"string" parameter cannot be null.`); + return null; } return string.toLowerCase(); }, @@ -625,9 +611,9 @@ export const ExprFunctions = { returns: ExprVal.String, }), upperCase: defineFunc({ - impl(string: string): string { + impl(string) { if (string === null) { - throw new LookupNotFound(this, `"string" parameter cannot be null.`); + return null; } return string.toUpperCase(); }, diff --git a/src/features/expressions/schema.test.ts b/src/features/expressions/schema.test.ts new file mode 100644 index 0000000000..ef23cd6bc4 --- /dev/null +++ b/src/features/expressions/schema.test.ts @@ -0,0 +1,127 @@ +import Ajv from 'ajv'; +import expressionSchema from 'schemas/json/layout/expression.schema.v1.json'; + +import { ExprFunctions } from 'src/features/expressions/index'; +import { ExprVal } from 'src/features/expressions/types'; +import type { FuncDef } from 'src/features/expressions/types'; + +type Func = { name: string } & FuncDef; + +describe('expression schema tests', () => { + const functions: Func[] = []; + for (const name of Object.keys(ExprFunctions)) { + const func = ExprFunctions[name]; + functions.push({ name, ...func }); + } + + it.each(functions)( + '$name should have a valid func-$name definition', + ({ name, args, minArguments, returns, lastArgSpreads }) => { + if (name === 'if') { + // if is a special case, we'll skip it here + return; + } + + expect(expressionSchema.definitions[`func-${name}`]).toBeDefined(); + expect(expressionSchema.definitions[`func-${name}`].type).toBe('array'); + expect(expressionSchema.definitions[`func-${name}`].items[0]).toEqual({ const: name }); + + if (returns === ExprVal.Any) { + // At least one of the definitions should be a match + const allTypes: any[] = []; + for (const type of ['number', 'string', 'boolean']) { + allTypes.push(...expressionSchema.definitions[`strict-${type}`].anyOf); + } + expect(allTypes).toContainEqual({ + $ref: `#/definitions/func-${name}`, + }); + } else { + const returnString = exprValToString(returns); + expect(expressionSchema.definitions[`strict-${returnString}`].anyOf).toContainEqual({ + $ref: `#/definitions/func-${name}`, + }); + } + + if (minArguments === undefined) { + expect(expressionSchema.definitions[`func-${name}`].items.length).toBe(args.length + 1); + } else { + expect(expressionSchema.definitions[`func-${name}`].items.length).toBeGreaterThanOrEqual(minArguments + 1); + } + + if (lastArgSpreads) { + const lastArg = args[args.length - 1]; + expect(expressionSchema.definitions[`func-${name}`].additionalItems).toEqual({ $ref: exprValToDef(lastArg) }); + } else { + expect(expressionSchema.definitions[`func-${name}`].additionalItems).toBe(false); + } + }, + ); + + const ajv = new Ajv({ strict: false }); + const validate = ajv.compile(expressionSchema); + + it.each(functions)( + '$name should validate against generated function calls', + ({ name, args, minArguments, lastArgSpreads }) => { + if (name === 'if') { + // if is a special case, we'll skip it here + return; + } + + const funcDef = expressionSchema.definitions[`func-${name}`]; + + // With exactly the right number of arguments + const funcCall = [name, ...args.map(exprValToString)]; + if (lastArgSpreads) { + funcCall.push(...args.map(exprValToString)); + } + + // Use enum value, if defined in schema + for (let i = 0; i < args.length; i++) { + const argDef = funcDef.items.length > i + 1 ? funcDef.items[i + 1] : undefined; + if (argDef?.enum) { + funcCall[i + 1] = argDef.enum[0]; + } + } + + const valid = validate(funcCall); + expect(validate.errors).toEqual(null); + expect(valid).toBe(true); + + // With too few arguments + const funcCallMinArguments = [name]; + const validMinArguments = validate(funcCallMinArguments); + + // This always validates, because the schema allows for less than the minimum number of arguments. If it didn't, + // you wouldn't get autocomplete for functions until you had the minimum number of arguments, which makes for + // a bad developer experience. We test this explicitly below, even though is does not seem to be the desired + // behavior. + expect(validate.errors).toEqual(null); + expect(validMinArguments).toBe(true); + + // With too many arguments + const funcCallExtra = [name, ...args.map(exprValToString), 'extra']; + const validExtra = validate(funcCallExtra); + if (lastArgSpreads) { + // This always validates, because the last argument spread + expect(validate.errors).toEqual(null); + expect(validExtra).toBe(true); + } else { + expect(validExtra).toBe(false); + } + }, + ); + + it('invalid functions should not validate', () => { + const valid = validate(['invalid_function']); + expect(valid).toBe(false); + }); +}); + +function exprValToString(val: ExprVal): string { + return val.toString().replaceAll('_', ''); +} + +function exprValToDef(val: ExprVal): string { + return `#/definitions/${exprValToString(val)}`; +} diff --git a/src/features/expressions/shared-tests/functions/commaContains/empty-string.json b/src/features/expressions/shared-tests/functions/commaContains/empty-string.json new file mode 100644 index 0000000000..5beadc3d54 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/commaContains/empty-string.json @@ -0,0 +1,5 @@ +{ + "name": "Should not contain an empty string", + "expression": ["commaContains", "40, 50, 60", ""], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/commaContains/null.json b/src/features/expressions/shared-tests/functions/commaContains/null.json new file mode 100644 index 0000000000..30e4156c53 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/commaContains/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should not contain a null value", + "expression": ["commaContains", "40, 50, 60", null], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/commaContains/null2.json b/src/features/expressions/shared-tests/functions/commaContains/null2.json new file mode 100644 index 0000000000..009bae2547 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/commaContains/null2.json @@ -0,0 +1,5 @@ +{ + "name": "Should not split a null value", + "expression": ["commaContains", null, "hello world"], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/contains/empty-string.json b/src/features/expressions/shared-tests/functions/contains/empty-string.json new file mode 100644 index 0000000000..b4e14894fc --- /dev/null +++ b/src/features/expressions/shared-tests/functions/contains/empty-string.json @@ -0,0 +1,5 @@ +{ + "name": "Should always contain en empty string", + "expression": ["contains", "Hello", ""], + "expects": true +} diff --git a/src/features/expressions/shared-tests/functions/contains/null.json b/src/features/expressions/shared-tests/functions/contains/null.json new file mode 100644 index 0000000000..f53527bdeb --- /dev/null +++ b/src/features/expressions/shared-tests/functions/contains/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should always return false when the input is null", + "expression": ["contains", null, "null"], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/contains/null2.json b/src/features/expressions/shared-tests/functions/contains/null2.json new file mode 100644 index 0000000000..0302631b7d --- /dev/null +++ b/src/features/expressions/shared-tests/functions/contains/null2.json @@ -0,0 +1,5 @@ +{ + "name": "Should always return false when the input is null", + "expression": ["contains", "null", null], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/contains/null3.json b/src/features/expressions/shared-tests/functions/contains/null3.json new file mode 100644 index 0000000000..b525fb7779 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/contains/null3.json @@ -0,0 +1,5 @@ +{ + "name": "Should always return false when the input is null", + "expression": ["contains", null, null], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/contains/null4.json b/src/features/expressions/shared-tests/functions/contains/null4.json new file mode 100644 index 0000000000..286a2e3cfe --- /dev/null +++ b/src/features/expressions/shared-tests/functions/contains/null4.json @@ -0,0 +1,5 @@ +{ + "name": "Should treat stringy nulls as null", + "expression": ["contains", "null", "null"], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/endsWith/empty-string.json b/src/features/expressions/shared-tests/functions/endsWith/empty-string.json new file mode 100644 index 0000000000..1626ffef4c --- /dev/null +++ b/src/features/expressions/shared-tests/functions/endsWith/empty-string.json @@ -0,0 +1,5 @@ +{ + "name": "All strings ends with an empty string", + "expression": ["endsWith", "Hello", ""], + "expects": true +} diff --git a/src/features/expressions/shared-tests/functions/endsWith/ends-with-null.json b/src/features/expressions/shared-tests/functions/endsWith/ends-with-null.json new file mode 100644 index 0000000000..59e6f17eb2 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/endsWith/ends-with-null.json @@ -0,0 +1,5 @@ +{ + "name": "No string ends with a null", + "expression": ["endsWith", "Hello", null], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/language/should-return-nb-if-not-set.json b/src/features/expressions/shared-tests/functions/language/should-return-nb-if-not-set.json new file mode 100644 index 0000000000..d5684add63 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/language/should-return-nb-if-not-set.json @@ -0,0 +1,5 @@ +{ + "name": "Should default to nb if no profile is set", + "expression": ["language"], + "expects": "nb" +} diff --git a/src/features/expressions/shared-tests/functions/lowerCase/null.json b/src/features/expressions/shared-tests/functions/lowerCase/null.json new file mode 100644 index 0000000000..d341f97670 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/lowerCase/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should return a null when the input is a null", + "expression": ["lowerCase", null], + "expects": null +} diff --git a/src/features/expressions/shared-tests/functions/notContains/null.json b/src/features/expressions/shared-tests/functions/notContains/null.json new file mode 100644 index 0000000000..206d1f0583 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/notContains/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should always return true if the input is null", + "expression": ["notContains", null, null], + "expects": true +} diff --git a/src/features/expressions/shared-tests/functions/notContains/null2.json b/src/features/expressions/shared-tests/functions/notContains/null2.json new file mode 100644 index 0000000000..1ff4196b42 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/notContains/null2.json @@ -0,0 +1,5 @@ +{ + "name": "Should always return true if the input is null", + "expression": ["notContains", "null", null], + "expects": true +} diff --git a/src/features/expressions/shared-tests/functions/notContains/null3.json b/src/features/expressions/shared-tests/functions/notContains/null3.json new file mode 100644 index 0000000000..b473ca2a5a --- /dev/null +++ b/src/features/expressions/shared-tests/functions/notContains/null3.json @@ -0,0 +1,5 @@ +{ + "name": "Should always return true if the input is null", + "expression": ["notContains", null, "null"], + "expects": true +} diff --git a/src/features/expressions/shared-tests/functions/notContains/null4.json b/src/features/expressions/shared-tests/functions/notContains/null4.json new file mode 100644 index 0000000000..f0d906f8e8 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/notContains/null4.json @@ -0,0 +1,5 @@ +{ + "name": "Should treat stringy nulls as null", + "expression": ["notContains", "null", "null"], + "expects": true +} diff --git a/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal-as-strings.json b/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal-as-strings.json index 796a004f16..0e7d6530ea 100644 --- a/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal-as-strings.json +++ b/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal-as-strings.json @@ -1,5 +1,5 @@ { "name": "Should round a number to the nearest integer with a precision of 2", "expression": ["round", "2.2199999", "2"], - "expects": 2.22 + "expects": "2.22" } diff --git a/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal.json b/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal.json index 18d3156860..49642cf5a1 100644 --- a/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal.json +++ b/src/features/expressions/shared-tests/functions/round/nearest-integer-with-decimal.json @@ -1,5 +1,5 @@ { "name": "Should round a number to the nearest integer with a precision of 2", "expression": ["round", 3.2199999, 2], - "expects": 3.22 + "expects": "3.22" } diff --git a/src/features/expressions/shared-tests/functions/round/nearest-integer.json b/src/features/expressions/shared-tests/functions/round/nearest-integer.json index 05ee5aee77..5856e76c8c 100644 --- a/src/features/expressions/shared-tests/functions/round/nearest-integer.json +++ b/src/features/expressions/shared-tests/functions/round/nearest-integer.json @@ -1,5 +1,5 @@ { "name": "Should round to nearest integer", "expression": ["round", 3.2, null], - "expects": 3 + "expects": "3" } diff --git a/src/features/expressions/shared-tests/functions/round/round-0-decimal-places.json b/src/features/expressions/shared-tests/functions/round/round-0-decimal-places.json new file mode 100644 index 0000000000..1822cf78ed --- /dev/null +++ b/src/features/expressions/shared-tests/functions/round/round-0-decimal-places.json @@ -0,0 +1,5 @@ +{ + "name": "Should be able to round 0 to 0 decimal places", + "expression": ["round", 0, 0], + "expects": "0" +} diff --git a/src/features/expressions/shared-tests/functions/round/round-0-decimal-places2.json b/src/features/expressions/shared-tests/functions/round/round-0-decimal-places2.json new file mode 100644 index 0000000000..b79c24939c --- /dev/null +++ b/src/features/expressions/shared-tests/functions/round/round-0-decimal-places2.json @@ -0,0 +1,5 @@ +{ + "name": "Should be able to round 0 to 0 decimal places", + "expression": ["round", 2.9999, 0], + "expects": "3" +} diff --git a/src/features/expressions/shared-tests/functions/round/round-negative-number.json b/src/features/expressions/shared-tests/functions/round/round-negative-number.json index c5e464a7c2..63b9e5edd6 100644 --- a/src/features/expressions/shared-tests/functions/round/round-negative-number.json +++ b/src/features/expressions/shared-tests/functions/round/round-negative-number.json @@ -1,5 +1,5 @@ { "name": "Should round negative number", "expression": ["round", -2.999, null], - "expects": -3 + "expects": "-3" } diff --git a/src/features/expressions/shared-tests/functions/round/round-null.json b/src/features/expressions/shared-tests/functions/round/round-null.json new file mode 100644 index 0000000000..dc1a2c6664 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/round/round-null.json @@ -0,0 +1,5 @@ +{ + "name": "Should be able to round a null", + "expression": ["round", null], + "expects": "0" +} diff --git a/src/features/expressions/shared-tests/functions/round/round-null2.json b/src/features/expressions/shared-tests/functions/round/round-null2.json new file mode 100644 index 0000000000..bebf3af4a2 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/round/round-null2.json @@ -0,0 +1,5 @@ +{ + "name": "Should be able to round a null", + "expression": ["round", null, 2], + "expects": "0.00" +} diff --git a/src/features/expressions/shared-tests/functions/round/round-strings.json b/src/features/expressions/shared-tests/functions/round/round-strings.json index 5f4c373ee8..b067d72536 100644 --- a/src/features/expressions/shared-tests/functions/round/round-strings.json +++ b/src/features/expressions/shared-tests/functions/round/round-strings.json @@ -1,5 +1,5 @@ { "name": "Should be able to round a number to the nearest integer even if it is a string", "expression": ["round", "3.99", null], - "expects": 4 + "expects": "4" } diff --git a/src/features/expressions/shared-tests/functions/round/round-with-too-many-args.json b/src/features/expressions/shared-tests/functions/round/round-with-too-many-args.json new file mode 100644 index 0000000000..603931c73f --- /dev/null +++ b/src/features/expressions/shared-tests/functions/round/round-with-too-many-args.json @@ -0,0 +1,5 @@ +{ + "name": "Should fail when given too many arguments", + "expression": ["round", 3.99, 2, 3], + "expectsFailure": "Expected 1-2 argument(s), got 3" +} diff --git a/src/features/expressions/shared-tests/functions/round/round-without-decimalCount.json b/src/features/expressions/shared-tests/functions/round/round-without-decimalCount.json new file mode 100644 index 0000000000..0bdcbef2b7 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/round/round-without-decimalCount.json @@ -0,0 +1,5 @@ +{ + "name": "Should be able to round a number without the optional second argument", + "expression": ["round", "3.99"], + "expects": "4" +} diff --git a/src/features/expressions/shared-tests/functions/round/round-without-decimalCount2.json b/src/features/expressions/shared-tests/functions/round/round-without-decimalCount2.json new file mode 100644 index 0000000000..01b5fa64d4 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/round/round-without-decimalCount2.json @@ -0,0 +1,5 @@ +{ + "name": "Should be able to round a number without the optional second argument", + "expression": ["round", 3.99], + "expects": "4" +} diff --git a/src/features/expressions/shared-tests/functions/startsWith/empty-string.json b/src/features/expressions/shared-tests/functions/startsWith/empty-string.json new file mode 100644 index 0000000000..5da24b3da3 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/startsWith/empty-string.json @@ -0,0 +1,5 @@ +{ + "name": "All strings start with an empty string", + "expression": ["startsWith", "Hello world", ""], + "expects": true +} diff --git a/src/features/expressions/shared-tests/functions/startsWith/null.json b/src/features/expressions/shared-tests/functions/startsWith/null.json new file mode 100644 index 0000000000..b088b4f16e --- /dev/null +++ b/src/features/expressions/shared-tests/functions/startsWith/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should always be false when given a null", + "expression": ["startsWith", null, "null"], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/startsWith/null2.json b/src/features/expressions/shared-tests/functions/startsWith/null2.json new file mode 100644 index 0000000000..92656df76b --- /dev/null +++ b/src/features/expressions/shared-tests/functions/startsWith/null2.json @@ -0,0 +1,5 @@ +{ + "name": "Should always be false when given a null", + "expression": ["startsWith", null, null], + "expects": false +} diff --git a/src/features/expressions/shared-tests/functions/stringLength/null.json b/src/features/expressions/shared-tests/functions/stringLength/null.json new file mode 100644 index 0000000000..c4b7e2ec81 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/stringLength/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should return 0 when the input is a null", + "expression": ["stringLength", null], + "expects": 0 +} diff --git a/src/features/expressions/shared-tests/functions/text/null.json b/src/features/expressions/shared-tests/functions/text/null.json new file mode 100644 index 0000000000..fec35efc08 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/text/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should return null when the input is null", + "expression": ["text", null], + "expects": null +} diff --git a/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group-no-index-markers.json b/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group-no-index-markers.json new file mode 100644 index 0000000000..c6cf039f17 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group-no-index-markers.json @@ -0,0 +1,114 @@ +{ + "name": "Should text resource with resolved variable inside a repeating group, without index markers", + "disabledFrontend": true, + "expression": ["text", "found.key"], + "expects": "Hello world Arne", + "context": { + "component": "myndig", + "rowIndices": [1, 0], + "currentLayout": "Page2" + }, + "textResources": [ + { + "id": "found.key", + "value": "Hello world {0}", + "variables": [ + { + "key": "Bedrifter.Ansatte.Navn", + "dataSource": "dataModel.default" + } + ] + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [] + } + }, + "Page2": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "bedrifter", + "type": "Group", + "maxCount": 99, + "dataModelBindings": { + "group": "Bedrifter" + }, + "children": ["bedriftsNavn", "ansatte"] + }, + { + "id": "bedriftsNavn", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Bedrifter.Navn" + } + }, + { + "id": "ansatte", + "type": "Group", + "maxCount": 99, + "dataModelBindings": { + "group": "Bedrifter.Ansatte" + }, + "children": ["navn", "alder", "myndig"] + }, + { + "id": "navn", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Bedrifter.Ansatte.Navn" + } + }, + { + "id": "alder", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Bedrifter.Ansatte.Alder" + } + }, + { + "id": "myndig", + "type": "Paragraph", + "textResourceBindings": { + "title": "Hurra, den ansatte er myndig!" + } + } + ] + } + } + }, + "dataModel": { + "Bedrifter": [ + { + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "Navn": "Kaare", + "Alder": 24 + }, + { + "Navn": "Per", + "Alder": 24 + } + ] + }, + { + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "Navn": "Arne", + "Alder": 24 + }, + { + "Navn": "Vidar", + "Alder": 14 + } + ] + } + ] + } +} diff --git a/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group.json b/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group.json new file mode 100644 index 0000000000..c6e2f17569 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable-in-rep-group.json @@ -0,0 +1,114 @@ +{ + "name": "Should text resource with resolved variable inside a repeating group", + "disabledFrontend": true, + "expression": ["text", "found.key"], + "expects": "Hello world Vidar", + "context": { + "component": "myndig", + "rowIndices": [1, 1], + "currentLayout": "Page2" + }, + "textResources": [ + { + "id": "found.key", + "value": "Hello world {0}", + "variables": [ + { + "key": "Bedrifter[{0}].Ansatte[{1}].Navn", + "dataSource": "dataModel.default" + } + ] + } + ], + "layouts": { + "Page1": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [] + } + }, + "Page2": { + "$schema": "https://altinncdn.no/schemas/json/layout/layout.schema.v1.json", + "data": { + "layout": [ + { + "id": "bedrifter", + "type": "Group", + "maxCount": 99, + "dataModelBindings": { + "group": "Bedrifter" + }, + "children": ["bedriftsNavn", "ansatte"] + }, + { + "id": "bedriftsNavn", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Bedrifter.Navn" + } + }, + { + "id": "ansatte", + "type": "Group", + "maxCount": 99, + "dataModelBindings": { + "group": "Bedrifter.Ansatte" + }, + "children": ["navn", "alder", "myndig"] + }, + { + "id": "navn", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Bedrifter.Ansatte.Navn" + } + }, + { + "id": "alder", + "type": "Input", + "dataModelBindings": { + "simpleBinding": "Bedrifter.Ansatte.Alder" + } + }, + { + "id": "myndig", + "type": "Paragraph", + "textResourceBindings": { + "title": "Hurra, den ansatte er myndig!" + } + } + ] + } + } + }, + "dataModel": { + "Bedrifter": [ + { + "Navn": "Hell og lykke AS", + "Ansatte": [ + { + "Navn": "Kaare", + "Alder": 24 + }, + { + "Navn": "Per", + "Alder": 24 + } + ] + }, + { + "Navn": "Nedtur og motgang AS", + "Ansatte": [ + { + "Navn": "Arne", + "Alder": 24 + }, + { + "Navn": "Vidar", + "Alder": 14 + } + ] + } + ] + } +} diff --git a/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable.json b/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable.json new file mode 100644 index 0000000000..19b5dbaa7d --- /dev/null +++ b/src/features/expressions/shared-tests/functions/text/should-return-text-resource-with-variable.json @@ -0,0 +1,25 @@ +{ + "name": "Should text resource with resolved variable", + "disabledFrontend": true, + "expression": ["text", "found.key"], + "expects": "Hello world foo bar", + "textResources": [ + { + "id": "found.key", + "value": "Hello world {0}", + "variables": [ + { + "key": "My.Model.Value", + "dataSource": "dataModel.default" + } + ] + } + ], + "dataModel": { + "My": { + "Model": { + "Value": "foo bar" + } + } + } +} diff --git a/src/features/expressions/shared-tests/functions/text/should-return-text-resource.json b/src/features/expressions/shared-tests/functions/text/should-return-text-resource.json new file mode 100644 index 0000000000..06fa4cce9e --- /dev/null +++ b/src/features/expressions/shared-tests/functions/text/should-return-text-resource.json @@ -0,0 +1,11 @@ +{ + "name": "Should return text resource value", + "expression": ["text", "found.key"], + "expects": "Hello world", + "textResources": [ + { + "id": "found.key", + "value": "Hello world" + } + ] +} diff --git a/src/features/expressions/shared-tests/functions/upperCase/null.json b/src/features/expressions/shared-tests/functions/upperCase/null.json new file mode 100644 index 0000000000..ba45196545 --- /dev/null +++ b/src/features/expressions/shared-tests/functions/upperCase/null.json @@ -0,0 +1,5 @@ +{ + "name": "Should return a null when the input is a null", + "expression": ["upperCase", null], + "expects": null +} diff --git a/src/features/expressions/shared.test.ts b/src/features/expressions/shared.test.ts index 8fde380f31..843201b501 100644 --- a/src/features/expressions/shared.test.ts +++ b/src/features/expressions/shared.test.ts @@ -53,6 +53,7 @@ describe('Expressions shared function tests', () => { it.each(folder.content)( '$name', ({ + disabledFrontend, expression, expects, expectsFailure, @@ -65,6 +66,11 @@ describe('Expressions shared function tests', () => { textResources, profile, }) => { + if (disabledFrontend) { + // Skipped tests usually means that the frontend does not support the feature yet + return; + } + const dataSources: HierarchyDataSources = { ...getHierarchyDataSourcesMock(), formData: dataModel ? dot.dot(dataModel) : {}, diff --git a/src/features/expressions/shared.ts b/src/features/expressions/shared.ts index 4adbe7c2f0..998cacedca 100644 --- a/src/features/expressions/shared.ts +++ b/src/features/expressions/shared.ts @@ -19,6 +19,7 @@ export interface Layouts { export interface SharedTest { name: string; + disabledFrontend?: boolean; layouts?: Layouts; dataModel?: any; instance?: IInstance; diff --git a/src/features/expressions/validation.ts b/src/features/expressions/validation.ts index f2783b3c95..fa2e2d1a9a 100644 --- a/src/features/expressions/validation.ts +++ b/src/features/expressions/validation.ts @@ -115,14 +115,16 @@ function validateFunctionArgLength( return; } - if (actual.length !== minExpected) { - addError( - ctx, - path, - ValidationErrorMessage.ArgsWrongNum, - `${minExpected}${canSpread ? '+' : ''}`, - `${actual.length}`, - ); + const maxExpected = ExprFunctions[func]?.args.length; + if (actual.length < minExpected || actual.length > maxExpected) { + let expected = `${minExpected}`; + if (canSpread) { + expected += '+'; + } else if (maxExpected !== minExpected) { + expected += `-${maxExpected}`; + } + + addError(ctx, path, ValidationErrorMessage.ArgsWrongNum, `${expected}`, `${actual.length}`); } } diff --git a/src/utils/layout/schema.test.ts b/src/utils/layout/schema.test.ts index 1d81a6e2ee..8378d651e0 100644 --- a/src/utils/layout/schema.test.ts +++ b/src/utils/layout/schema.test.ts @@ -62,12 +62,8 @@ describe('Layout schema', () => { // Ignore errors about id not matching pattern. This is common, and we don't care that much about it. return false; } - if (error.message?.startsWith("must have required property 'size'")) { - // Fairly common for Header components. Maybe we should make this one optional? - return false; - } - return true; + return !error.message?.startsWith("must have required property 'size'"); }); for (const { appName, setName, entireFiles } of allLayoutSets) {