Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow ValueTypeObject to be provided as a Partial #650

Merged
merged 1 commit into from
Feb 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/core/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`)
}

Expand Down
7 changes: 7 additions & 0 deletions src/core/utils.ts
Original file line number Diff line number Diff line change
@@ -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)
}
93 changes: 59 additions & 34 deletions src/core/value_properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(constructor: Constructor<T>) {
const valueDefinitionPairs = readInheritableStaticObjectPairs<T, ValueTypeDefinition>(constructor, "values")
Expand Down Expand Up @@ -77,7 +78,7 @@ export type ValueTypeConstant = typeof Array | typeof Boolean | typeof Number |

export type ValueTypeDefault = Array<any> | 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

Expand All @@ -91,7 +92,7 @@ function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair,
})
}

function parseValueTypeConstant(constant: ValueTypeConstant) {
export function parseValueTypeConstant(constant?: ValueTypeConstant) {
switch (constant) {
case Array:
return "array"
Expand All @@ -106,7 +107,7 @@ function parseValueTypeConstant(constant: ValueTypeConstant) {
}
}

function parseValueTypeDefault(defaultValue: ValueTypeDefault) {
export function parseValueTypeDefault(defaultValue?: ValueTypeDefault) {
switch (typeof defaultValue) {
case "boolean":
return "boolean"
Expand All @@ -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,
Expand Down
176 changes: 176 additions & 0 deletions src/tests/modules/core/value_properties_tests.ts
Original file line number Diff line number Diff line change
@@ -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)
}
}