diff --git a/doc/reference/props.md b/doc/reference/props.md index 7d673339..14cb34dd 100644 --- a/doc/reference/props.md +++ b/doc/reference/props.md @@ -212,6 +212,7 @@ For each key, a `prop` definition is either a boolean, a constructor, a list of - `type`: the main type of the prop being validated - `element`: if the type was `Array`, then the `element` key describes the type of each element in the array. If it is not set, then we only validate the array, not its elements, - `shape`: if the type was `Object`, then the `shape` key describes the interface of the object. If it is not set, then we only validate the object, not its elements, + - `values`: if the type was `Object`, then the `values` key describes the interface of values in the object, this allows validating objects that are used as mappings, where keys are not known in advance but the shape of the values is. - `validate`: this is a function which should return a boolean to determine if the value is valid or not. Useful for custom validation logic. - `optional`: if true, the prop is not mandatory @@ -276,6 +277,10 @@ class ComponentB extends owl.Component { name: {type: String, optional: true}, url: String ]}, // object, with keys id (number), name (string, optional) and url (string) + someObj3: { + type: Object, + values: { type: Array, element: String }, + }, // object with arbitary keys where values are arrays of strings someFlag: Boolean, // a boolean, mandatory (even if `false`) someVal: [Boolean, Date], // either a boolean or a date otherValue: true, // indicates that it is a prop diff --git a/src/runtime/index.ts b/src/runtime/index.ts index a1013eae..1813ce77 100644 --- a/src/runtime/index.ts +++ b/src/runtime/index.ts @@ -54,7 +54,7 @@ export { onWillDestroy, onError, } from "./lifecycle_hooks"; -export { validate } from "./validation"; +export { validate, validateType } from "./validation"; export { OwlError } from "./error_handling"; export const __info__ = { diff --git a/src/runtime/validation.ts b/src/runtime/validation.ts index 3c499de4..8f657f7b 100644 --- a/src/runtime/validation.ts +++ b/src/runtime/validation.ts @@ -17,6 +17,7 @@ interface TypeInfo { validate?: Function; shape?: Schema; element?: TypeDescription; + values?: TypeDescription; } type ValueType = { value: any }; @@ -137,7 +138,7 @@ function validateArrayType(key: string, value: any, descr: TypeDescription): str return null; } -function validateType(key: string, value: any, descr: TypeDescription): string | null { +export function validateType(key: string, value: any, descr: TypeDescription): string | null { if (value === undefined) { return isOptional(descr) ? null : `'${key}' is undefined (should be a ${describe(descr)})`; } else if (isBaseType(descr)) { @@ -151,13 +152,24 @@ function validateType(key: string, value: any, descr: TypeDescription): string | let result: string | null = null; if ("element" in descr) { result = validateArrayType(key, value, descr.element!); - } else if ("shape" in descr && !result) { + } else if ("shape" in descr) { if (typeof value !== "object" || Array.isArray(value)) { result = `'${key}' is not an object`; } else { const errors = validateSchema(value, descr.shape!); if (errors.length) { - result = `'${key}' has not the correct shape (${errors.join(", ")})`; + result = `'${key}' doesn't have the correct shape (${errors.join(", ")})`; + } + } + } else if ("values" in descr) { + if (typeof value !== "object" || Array.isArray(value)) { + result = `'${key}' is not an object`; + } else { + const errors = Object.entries(value) + .map(([key, value]) => validateType(key, value, descr.values!)) + .filter(Boolean); + if (errors.length) { + result = `some of the values in '${key}' are invalid (${errors.join(", ")})`; } } } diff --git a/tests/components/props_validation.test.ts b/tests/components/props_validation.test.ts index 2ce4b244..265a36ba 100644 --- a/tests/components/props_validation.test.ts +++ b/tests/components/props_validation.test.ts @@ -404,7 +404,7 @@ describe("props validation", () => { await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( - "Invalid props for component 'SubComp': 'p' has not the correct shape (unknown key 'extra')" + "Invalid props for component 'SubComp': 'p' doesn't have the correct shape (unknown key 'extra')" ); props = { p: { id: "1", url: "url" } }; app = new App(Parent, { test: true }); @@ -413,7 +413,7 @@ describe("props validation", () => { await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( - "Invalid props for component 'SubComp': 'p' has not the correct shape ('id' is not a number)" + "Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('id' is not a number)" ); error = undefined; props = { p: { id: 1 } }; @@ -423,7 +423,7 @@ describe("props validation", () => { await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( - "Invalid props for component 'SubComp': 'p' has not the correct shape ('url' is missing (should be a string))" + "Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('url' is missing (should be a string))" ); }); @@ -470,7 +470,7 @@ describe("props validation", () => { await mountProm; expect(error!).toBeDefined(); expect(error!.message).toBe( - "Invalid props for component 'SubComp': 'p' has not the correct shape ('url' is not a boolean or list of numbers)" + "Invalid props for component 'SubComp': 'p' doesn't have the correct shape ('url' is not a boolean or list of numbers)" ); }); @@ -502,7 +502,7 @@ describe("props validation", () => { } expect(error!).toBeDefined(); expect(error!.message).toBe( - "Invalid props for component 'TestComponent': 'myprop[0]' has not the correct shape (unknown key 'a')" + "Invalid props for component 'TestComponent': 'myprop[0]' doesn't have the correct shape (unknown key 'a')" ); }); diff --git a/tests/validation.test.ts b/tests/validation.test.ts index a8d5c983..b5e8bba1 100644 --- a/tests/validation.test.ts +++ b/tests/validation.test.ts @@ -145,17 +145,51 @@ describe("validateSchema", () => { const schema: Schema = { p: { type: Object, shape: { id: Number, url: String } } }; expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]); expect(validateSchema({ p: {} }, schema)).toEqual([ - "'p' has not the correct shape ('id' is missing (should be a number), 'url' is missing (should be a string))", + "'p' doesn't have the correct shape ('id' is missing (should be a number), 'url' is missing (should be a string))", ]); expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([]); expect(validateSchema({ p: { id: 1, url: 1 } }, schema)).toEqual([ - "'p' has not the correct shape ('url' is not a string)", + "'p' doesn't have the correct shape ('url' is not a string)", ]); expect(validateSchema({ p: undefined }, schema)).toEqual([ "'p' is undefined (should be a object)", ]); }); + test("objects with a values schema", () => { + const schema: Schema = { + p: { type: Object, values: { type: Object, shape: { id: Number, url: String } } }, + }; + expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]); + expect(validateSchema({ p: {} }, schema)).toEqual([]); + expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([ + "some of the values in 'p' are invalid ('id' is not an object, 'url' is not an object)", + ]); + expect( + validateSchema( + { + p: { + a: { id: 1, url: "asf" }, + }, + }, + schema + ) + ).toEqual([]); + expect( + validateSchema( + { + p: { + a: { id: 1, url: "asf" }, + b: { id: 1, url: 1 }, + }, + }, + schema + ) + ).toEqual([ + "some of the values in 'p' are invalid ('b' doesn't have the correct shape ('url' is not a string))", + ]); + }); + test("objects with more complex shape", () => { const schema: Schema = { p: { @@ -168,10 +202,10 @@ describe("validateSchema", () => { }; expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]); expect(validateSchema({ p: {} }, schema)).toEqual([ - "'p' has not the correct shape ('id' is missing (should be a number), 'url' is missing (should be a boolean or list of numbers))", + "'p' doesn't have the correct shape ('id' is missing (should be a number), 'url' is missing (should be a boolean or list of numbers))", ]); expect(validateSchema({ p: { id: 1, url: "asf" } }, schema)).toEqual([ - "'p' has not the correct shape ('url' is not a boolean or list of numbers)", + "'p' doesn't have the correct shape ('url' is not a boolean or list of numbers)", ]); expect(validateSchema({ p: { id: 1, url: true } }, schema)).toEqual([]); expect(validateSchema({ p: undefined }, schema)).toEqual([ @@ -183,11 +217,11 @@ describe("validateSchema", () => { const schema: Schema = { p: { type: Object, shape: { id: Number, "*": true } } }; expect(validateSchema({ p: [] }, schema)).toEqual(["'p' is not an object"]); expect(validateSchema({ p: {} }, schema)).toEqual([ - "'p' has not the correct shape ('id' is missing (should be a number))", + "'p' doesn't have the correct shape ('id' is missing (should be a number))", ]); expect(validateSchema({ p: { id: 1 } }, schema)).toEqual([]); expect(validateSchema({ p: { id: "asdf" } }, schema)).toEqual([ - "'p' has not the correct shape ('id' is not a number)", + "'p' doesn't have the correct shape ('id' is not a number)", ]); expect(validateSchema({ p: { id: 1, url: 1 } }, schema)).toEqual([]); expect(validateSchema({ p: undefined }, schema)).toEqual([ @@ -222,7 +256,7 @@ describe("validateSchema", () => { expect(validateSchema({ p: [{}] }, schema)).toEqual([]); expect(validateSchema({ p: [{ num: 1 }] }, schema)).toEqual([]); expect(validateSchema({ p: [{ num: true }] }, schema)).toEqual([ - "'p[0]' has not the correct shape ('num' is not a number)", + "'p[0]' doesn't have the correct shape ('num' is not a number)", ]); });