diff --git a/src/core/action.ts b/src/core/action.ts index 8fba7a10..3350ea84 100644 --- a/src/core/action.ts +++ b/src/core/action.ts @@ -2,6 +2,8 @@ import { ActionDescriptor, parseActionDescriptorString, stringifyEventTarget } f import { Token } from "../mutation-observers" import { Schema } from "./schema" import { camelize } from "./string_helpers" +import { hasProperty } from "./utils" + export class Action { readonly element: Element readonly index: number @@ -54,7 +56,7 @@ export class Action { return false } - if (!Object.prototype.hasOwnProperty.call(this.keyMappings, standardFilter)) { + if (!hasProperty(this.keyMappings, standardFilter)) { error(`contains unknown key filter: ${this.keyFilter}`) } diff --git a/src/core/utils.ts b/src/core/utils.ts new file mode 100644 index 00000000..ae3a262f --- /dev/null +++ b/src/core/utils.ts @@ -0,0 +1,7 @@ +export function isSomething(object: any): boolean { + return object !== null && object !== undefined +} + +export function hasProperty(object: any, property: string): boolean { + return Object.prototype.hasOwnProperty.call(object, property) +} diff --git a/src/core/value_properties.ts b/src/core/value_properties.ts index 6a431314..93413300 100644 --- a/src/core/value_properties.ts +++ b/src/core/value_properties.ts @@ -2,6 +2,7 @@ import { Constructor } from "./constructor" import { Controller } from "./controller" import { readInheritableStaticObjectPairs } from "./inheritable_statics" import { camelize, capitalize, dasherize } from "./string_helpers" +import { isSomething, hasProperty } from "./utils" export function ValuePropertiesBlessing(constructor: Constructor) { const valueDefinitionPairs = readInheritableStaticObjectPairs(constructor, "values") @@ -77,7 +78,7 @@ export type ValueTypeConstant = typeof Array | typeof Boolean | typeof Number | export type ValueTypeDefault = Array | boolean | number | Object | string -export type ValueTypeObject = { type: ValueTypeConstant; default: ValueTypeDefault } +export type ValueTypeObject = Partial<{ type: ValueTypeConstant; default: ValueTypeDefault }> export type ValueTypeDefinition = ValueTypeConstant | ValueTypeDefault | ValueTypeObject @@ -91,7 +92,7 @@ function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair, }) } -function parseValueTypeConstant(constant: ValueTypeConstant) { +export function parseValueTypeConstant(constant?: ValueTypeConstant) { switch (constant) { case Array: return "array" @@ -106,7 +107,7 @@ function parseValueTypeConstant(constant: ValueTypeConstant) { } } -function parseValueTypeDefault(defaultValue: ValueTypeDefault) { +export function parseValueTypeDefault(defaultValue?: ValueTypeDefault) { switch (typeof defaultValue) { case "boolean": return "boolean" @@ -120,73 +121,97 @@ function parseValueTypeDefault(defaultValue: ValueTypeDefault) { if (Object.prototype.toString.call(defaultValue) === "[object Object]") return "object" } -function parseValueTypeObject(payload: { controller?: string; token: string; typeObject: ValueTypeObject }) { - const typeFromObject = parseValueTypeConstant(payload.typeObject.type) +type ValueTypeObjectPayload = { + controller?: string + token: string + typeObject: ValueTypeObject +} + +export function parseValueTypeObject(payload: ValueTypeObjectPayload) { + const { controller, token, typeObject } = payload + + const hasType = isSomething(typeObject.type) + const hasDefault = isSomething(typeObject.default) - if (!typeFromObject) return + const fullObject = hasType && hasDefault + const onlyType = hasType && !hasDefault + const onlyDefault = !hasType && hasDefault - const defaultValueType = parseValueTypeDefault(payload.typeObject.default) + const typeFromObject = parseValueTypeConstant(typeObject.type) + const typeFromDefaultValue = parseValueTypeDefault(payload.typeObject.default) - if (typeFromObject !== defaultValueType) { - const propertyPath = payload.controller ? `${payload.controller}.${payload.token}` : payload.token + if (onlyType) return typeFromObject + if (onlyDefault) return typeFromDefaultValue + + if (typeFromObject !== typeFromDefaultValue) { + const propertyPath = controller ? `${controller}.${token}` : token throw new Error( - `The specified default value for the Stimulus Value "${propertyPath}" must match the defined type "${typeFromObject}". The provided default value of "${payload.typeObject.default}" is of type "${defaultValueType}".` + `The specified default value for the Stimulus Value "${propertyPath}" must match the defined type "${typeFromObject}". The provided default value of "${typeObject.default}" is of type "${typeFromDefaultValue}".` ) } - return typeFromObject + if (fullObject) return typeFromObject } -function parseValueTypeDefinition(payload: { +type ValueTypeDefinitionPayload = { controller?: string token: string typeDefinition: ValueTypeDefinition -}): ValueType { - const typeFromObject = parseValueTypeObject({ - controller: payload.controller, - token: payload.token, - typeObject: payload.typeDefinition as ValueTypeObject, - }) - const typeFromDefaultValue = parseValueTypeDefault(payload.typeDefinition as ValueTypeDefault) - const typeFromConstant = parseValueTypeConstant(payload.typeDefinition as ValueTypeConstant) +} + +export function parseValueTypeDefinition(payload: ValueTypeDefinitionPayload): ValueType { + const { controller, token, typeDefinition } = payload + + const typeObject = { controller, token, typeObject: typeDefinition as ValueTypeObject } + + const typeFromObject = parseValueTypeObject(typeObject as ValueTypeObjectPayload) + const typeFromDefaultValue = parseValueTypeDefault(typeDefinition as ValueTypeDefault) + const typeFromConstant = parseValueTypeConstant(typeDefinition as ValueTypeConstant) const type = typeFromObject || typeFromDefaultValue || typeFromConstant if (type) return type - const propertyPath = payload.controller ? `${payload.controller}.${payload.typeDefinition}` : payload.token + const propertyPath = controller ? `${controller}.${typeDefinition}` : token - throw new Error(`Unknown value type "${propertyPath}" for "${payload.token}" value`) + throw new Error(`Unknown value type "${propertyPath}" for "${token}" value`) } -function defaultValueForDefinition(typeDefinition: ValueTypeDefinition): ValueTypeDefault { +export function defaultValueForDefinition(typeDefinition: ValueTypeDefinition): ValueTypeDefault { const constant = parseValueTypeConstant(typeDefinition as ValueTypeConstant) - if (constant) return defaultValuesByType[constant] - const defaultValue = (typeDefinition as ValueTypeObject).default - if (defaultValue !== undefined) return defaultValue + const hasDefault = hasProperty(typeDefinition, "default") + const hasType = hasProperty(typeDefinition, "type") + const typeObject = typeDefinition as ValueTypeObject + + if (hasDefault) return typeObject.default! + + if (hasType) { + const { type } = typeObject + const constantFromType = parseValueTypeConstant(type) + + if (constantFromType) return defaultValuesByType[constantFromType] + } return typeDefinition } -function valueDescriptorForTokenAndTypeDefinition(payload: { - token: string - typeDefinition: ValueTypeDefinition - controller?: string -}) { - const key = `${dasherize(payload.token)}-value` +function valueDescriptorForTokenAndTypeDefinition(payload: ValueTypeDefinitionPayload) { + const { token, typeDefinition } = payload + + const key = `${dasherize(token)}-value` const type = parseValueTypeDefinition(payload) return { type, key, name: camelize(key), get defaultValue() { - return defaultValueForDefinition(payload.typeDefinition) + return defaultValueForDefinition(typeDefinition) }, get hasCustomDefaultValue() { - return parseValueTypeDefault(payload.typeDefinition) !== undefined + return parseValueTypeDefault(typeDefinition) !== undefined }, reader: readers[type], writer: writers[type] || writers.default, diff --git a/src/tests/modules/core/value_properties_tests.ts b/src/tests/modules/core/value_properties_tests.ts new file mode 100644 index 00000000..0c8a583d --- /dev/null +++ b/src/tests/modules/core/value_properties_tests.ts @@ -0,0 +1,176 @@ +import { ValueController } from "../../controllers/value_controller" +import { ControllerTestCase } from "../../cases/controller_test_case" + +import { + parseValueTypeDefault, + parseValueTypeConstant, + parseValueTypeObject, + parseValueTypeDefinition, + defaultValueForDefinition, +} from "../../../core/value_properties" + +export default class ValuePropertiesTests extends ControllerTestCase(ValueController) { + "test parseValueTypeConstant"() { + this.assert.equal(parseValueTypeConstant(String), "string") + this.assert.equal(parseValueTypeConstant(Boolean), "boolean") + this.assert.equal(parseValueTypeConstant(Array), "array") + this.assert.equal(parseValueTypeConstant(Object), "object") + this.assert.equal(parseValueTypeConstant(Number), "number") + + this.assert.equal(parseValueTypeConstant("" as any), undefined) + this.assert.equal(parseValueTypeConstant({} as any), undefined) + this.assert.equal(parseValueTypeConstant([] as any), undefined) + this.assert.equal(parseValueTypeConstant(true as any), undefined) + this.assert.equal(parseValueTypeConstant(false as any), undefined) + this.assert.equal(parseValueTypeConstant(0 as any), undefined) + this.assert.equal(parseValueTypeConstant(1 as any), undefined) + this.assert.equal(parseValueTypeConstant(null!), undefined) + this.assert.equal(parseValueTypeConstant(undefined), undefined) + } + + "test parseValueTypeDefault"() { + this.assert.equal(parseValueTypeDefault(""), "string") + this.assert.equal(parseValueTypeDefault("Some string"), "string") + + this.assert.equal(parseValueTypeDefault(true), "boolean") + this.assert.equal(parseValueTypeDefault(false), "boolean") + + this.assert.equal(parseValueTypeDefault([]), "array") + this.assert.equal(parseValueTypeDefault([1, 2, 3]), "array") + this.assert.equal(parseValueTypeDefault([true, false, true]), "array") + this.assert.equal(parseValueTypeDefault([{}, {}, {}]), "array") + + this.assert.equal(parseValueTypeDefault({}), "object") + this.assert.equal(parseValueTypeDefault({ one: "key" }), "object") + + this.assert.equal(parseValueTypeDefault(-1), "number") + this.assert.equal(parseValueTypeDefault(0), "number") + this.assert.equal(parseValueTypeDefault(1), "number") + this.assert.equal(parseValueTypeDefault(-0.1), "number") + this.assert.equal(parseValueTypeDefault(0.0), "number") + this.assert.equal(parseValueTypeDefault(0.1), "number") + + this.assert.equal(parseValueTypeDefault(null!), undefined) + this.assert.equal(parseValueTypeDefault(undefined!), undefined) + } + + "test parseValueTypeObject"() { + const typeObject = (object: any) => { + return parseValueTypeObject({ + controller: this.controller.identifier, + token: "url", + typeObject: object, + }) + } + + this.assert.equal(typeObject({ type: String, default: "" }), "string") + this.assert.equal(typeObject({ type: String, default: "123" }), "string") + this.assert.equal(typeObject({ type: String }), "string") + this.assert.equal(typeObject({ default: "" }), "string") + this.assert.equal(typeObject({ default: "123" }), "string") + + this.assert.equal(typeObject({ type: Number, default: 0 }), "number") + this.assert.equal(typeObject({ type: Number, default: 1 }), "number") + this.assert.equal(typeObject({ type: Number, default: -1 }), "number") + this.assert.equal(typeObject({ type: Number }), "number") + this.assert.equal(typeObject({ default: 0 }), "number") + this.assert.equal(typeObject({ default: 1 }), "number") + this.assert.equal(typeObject({ default: -1 }), "number") + + this.assert.equal(typeObject({ type: Array, default: [] }), "array") + this.assert.equal(typeObject({ type: Array, default: [1] }), "array") + this.assert.equal(typeObject({ type: Array }), "array") + this.assert.equal(typeObject({ default: [] }), "array") + this.assert.equal(typeObject({ default: [1] }), "array") + + this.assert.equal(typeObject({ type: Object, default: {} }), "object") + this.assert.equal(typeObject({ type: Object, default: { some: "key" } }), "object") + this.assert.equal(typeObject({ type: Object }), "object") + this.assert.equal(typeObject({ default: {} }), "object") + this.assert.equal(typeObject({ default: { some: "key" } }), "object") + + this.assert.equal(typeObject({ type: Boolean, default: true }), "boolean") + this.assert.equal(typeObject({ type: Boolean, default: false }), "boolean") + this.assert.equal(typeObject({ type: Boolean }), "boolean") + this.assert.equal(typeObject({ default: false }), "boolean") + + this.assert.throws(() => typeObject({ type: Boolean, default: "something else" }), { + name: "Error", + message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "something else" is of type "string".`, + }) + + this.assert.throws(() => typeObject({ type: Boolean, default: "true" }), { + name: "Error", + message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "true" is of type "string".`, + }) + } + + "test parseValueTypeDefinition booleans"() { + const typeDefinition = (definition: any) => { + return parseValueTypeDefinition({ + controller: this.controller.identifier, + token: "url", + typeDefinition: definition, + }) + } + + this.assert.equal(typeDefinition(Boolean), "boolean") + this.assert.equal(typeDefinition(true), "boolean") + this.assert.equal(typeDefinition(false), "boolean") + this.assert.equal(typeDefinition({ type: Boolean, default: false }), "boolean") + this.assert.equal(typeDefinition({ type: Boolean }), "boolean") + this.assert.equal(typeDefinition({ default: true }), "boolean") + + // since the provided value is actually an object, it's going to be of type "object" + this.assert.equal(typeDefinition({ default: null }), "object") + this.assert.equal(typeDefinition({ default: undefined }), "object") + + this.assert.equal(typeDefinition({}), "object") + this.assert.equal(typeDefinition(""), "string") + this.assert.equal(typeDefinition([]), "array") + + this.assert.throws(() => typeDefinition(null)) + this.assert.throws(() => typeDefinition(undefined)) + } + + "test defaultValueForDefinition"() { + this.assert.deepEqual(defaultValueForDefinition(String), "") + this.assert.deepEqual(defaultValueForDefinition(Boolean), false) + this.assert.deepEqual(defaultValueForDefinition(Object), {}) + this.assert.deepEqual(defaultValueForDefinition(Array), []) + this.assert.deepEqual(defaultValueForDefinition(Number), 0) + + this.assert.deepEqual(defaultValueForDefinition({ type: String }), "") + this.assert.deepEqual(defaultValueForDefinition({ type: Boolean }), false) + this.assert.deepEqual(defaultValueForDefinition({ type: Object }), {}) + this.assert.deepEqual(defaultValueForDefinition({ type: Array }), []) + this.assert.deepEqual(defaultValueForDefinition({ type: Number }), 0) + + this.assert.deepEqual(defaultValueForDefinition({ type: String, default: null }), null) + this.assert.deepEqual(defaultValueForDefinition({ type: Boolean, default: null }), null) + this.assert.deepEqual(defaultValueForDefinition({ type: Object, default: null }), null) + this.assert.deepEqual(defaultValueForDefinition({ type: Array, default: null }), null) + this.assert.deepEqual(defaultValueForDefinition({ type: Number, default: null }), null) + + this.assert.deepEqual(defaultValueForDefinition({ type: String, default: "some string" }), "some string") + this.assert.deepEqual(defaultValueForDefinition({ type: Boolean, default: true }), true) + this.assert.deepEqual(defaultValueForDefinition({ type: Object, default: { some: "key" } }), { some: "key" }) + this.assert.deepEqual(defaultValueForDefinition({ type: Array, default: [1, 2, 3] }), [1, 2, 3]) + this.assert.deepEqual(defaultValueForDefinition({ type: Number, default: 99 }), 99) + + this.assert.deepEqual(defaultValueForDefinition("some string"), "some string") + this.assert.deepEqual(defaultValueForDefinition(true), true) + this.assert.deepEqual(defaultValueForDefinition({ some: "key" }), { some: "key" }) + this.assert.deepEqual(defaultValueForDefinition([1, 2, 3]), [1, 2, 3]) + this.assert.deepEqual(defaultValueForDefinition(99), 99) + + this.assert.deepEqual(defaultValueForDefinition({ default: "some string" }), "some string") + this.assert.deepEqual(defaultValueForDefinition({ default: true }), true) + this.assert.deepEqual(defaultValueForDefinition({ default: { some: "key" } }), { some: "key" }) + this.assert.deepEqual(defaultValueForDefinition({ default: [1, 2, 3] }), [1, 2, 3]) + this.assert.deepEqual(defaultValueForDefinition({ default: 99 }), 99) + + this.assert.deepEqual(defaultValueForDefinition({ default: null }), null) + this.assert.deepEqual(defaultValueForDefinition({ default: undefined }), undefined) + } +}